forked from dsutanto/bChot-android
First Commit
This commit is contained in:
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -0,0 +1,31 @@
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023, 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")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.architecture"
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.di)
|
||||
api(projects.libraries.core)
|
||||
api(libs.metro.runtime)
|
||||
api(libs.appyx.core)
|
||||
api(libs.androidx.lifecycle.runtime)
|
||||
api(libs.molecule.runtime)
|
||||
|
||||
testCommonDependencies(libs)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
|
||||
fun interface AssistedNodeFactory<NODE : Node> {
|
||||
fun create(buildContext: BuildContext, plugins: List<Plugin>): NODE
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlin.contracts.ExperimentalContracts
|
||||
import kotlin.contracts.InvocationKind
|
||||
import kotlin.contracts.contract
|
||||
|
||||
/**
|
||||
* Sealed type that allows to model an asynchronous operation triggered by the user.
|
||||
*/
|
||||
@Stable
|
||||
sealed interface AsyncAction<out T> {
|
||||
/**
|
||||
* Represents an uninitialized operation (i.e. yet to be run by the user).
|
||||
*/
|
||||
data object Uninitialized : AsyncAction<Nothing>
|
||||
|
||||
/**
|
||||
* Represents an operation that is currently waiting for user confirmation.
|
||||
*/
|
||||
interface Confirming : AsyncAction<Nothing>
|
||||
|
||||
data object ConfirmingNoParams : Confirming
|
||||
|
||||
/**
|
||||
* User cancels the action, use this object to ask for confirmation.
|
||||
*/
|
||||
data object ConfirmingCancellation : Confirming
|
||||
|
||||
/**
|
||||
* Represents an operation that is currently ongoing.
|
||||
*/
|
||||
data object Loading : AsyncAction<Nothing>
|
||||
|
||||
/**
|
||||
* Represents a failed operation.
|
||||
*
|
||||
* @property error the error that caused the operation to fail.
|
||||
*/
|
||||
data class Failure(
|
||||
val error: Throwable,
|
||||
) : AsyncAction<Nothing>
|
||||
|
||||
/**
|
||||
* Represents a successful operation.
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
* @property data the data returned by the operation.
|
||||
*/
|
||||
data class Success<out T>(
|
||||
val data: T,
|
||||
) : AsyncAction<T>
|
||||
|
||||
/**
|
||||
* Returns the data returned by the operation, or null otherwise.
|
||||
*/
|
||||
fun dataOrNull(): T? = when (this) {
|
||||
is Success -> data
|
||||
else -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error that caused the operation to fail, or null otherwise.
|
||||
*/
|
||||
fun errorOrNull(): Throwable? = when (this) {
|
||||
is Failure -> error
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isUninitialized(): Boolean = this == Uninitialized
|
||||
|
||||
fun isConfirming(): Boolean = this is Confirming
|
||||
|
||||
fun isLoading(): Boolean = this == Loading
|
||||
|
||||
fun isFailure(): Boolean = this is Failure
|
||||
|
||||
fun isSuccess(): Boolean = this is Success
|
||||
|
||||
fun isReady() = isSuccess() || isFailure()
|
||||
}
|
||||
|
||||
suspend inline fun <T> MutableState<AsyncAction<T>>.runCatchingUpdatingState(
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
block: () -> T,
|
||||
): Result<T> = runUpdatingState(
|
||||
state = this,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = {
|
||||
runCatchingExceptions {
|
||||
block()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
|
||||
state: MutableState<AsyncAction<T>>,
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
): Result<T> = runUpdatingState(
|
||||
state = state,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = {
|
||||
runCatchingExceptions {
|
||||
this()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend inline fun <T> MutableState<AsyncAction<T>>.runUpdatingState(
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
resultBlock: () -> Result<T>,
|
||||
): Result<T> = runUpdatingState(
|
||||
state = this,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = resultBlock,
|
||||
)
|
||||
|
||||
/**
|
||||
* Run the given block and update the state accordingly, using only Loading and Failure states.
|
||||
* It's up to the caller to manage the Success state.
|
||||
*/
|
||||
@OptIn(ExperimentalContracts::class)
|
||||
inline fun <T> MutableState<AsyncAction<T>>.runUpdatingStateNoSuccess(
|
||||
resultBlock: () -> Result<Unit>,
|
||||
): Result<Unit> {
|
||||
contract {
|
||||
callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
|
||||
}
|
||||
value = AsyncAction.Loading
|
||||
return resultBlock()
|
||||
.onFailure { failure ->
|
||||
value = AsyncAction.Failure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls the specified [Result]-returning function [resultBlock]
|
||||
* encapsulating its progress and return value into an [AsyncAction] while
|
||||
* posting its updates to the MutableState [state].
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
* @param state the [MutableState] to post updates to.
|
||||
* @param errorTransform a function to transform the error before posting it.
|
||||
* @param resultBlock a suspending function that returns a [Result].
|
||||
* @return the [Result] returned by [resultBlock].
|
||||
*/
|
||||
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
|
||||
suspend inline fun <T> runUpdatingState(
|
||||
state: MutableState<AsyncAction<T>>,
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
resultBlock: suspend () -> Result<T>,
|
||||
): Result<T> {
|
||||
// Restore when the issue with contracts and AGP 8.13.x is fixed
|
||||
// contract {
|
||||
// callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
|
||||
// }
|
||||
state.value = AsyncAction.Loading
|
||||
return try {
|
||||
resultBlock()
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
state.value = AsyncAction.Failure(errorTransform(e))
|
||||
throw e
|
||||
}.fold(
|
||||
onSuccess = {
|
||||
state.value = AsyncAction.Success(it)
|
||||
Result.success(it)
|
||||
},
|
||||
onFailure = {
|
||||
val error = errorTransform(it)
|
||||
state.value = AsyncAction.Failure(error)
|
||||
Result.failure(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.Stable
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
|
||||
/**
|
||||
* Sealed type that allows to model an asynchronous operation.
|
||||
*/
|
||||
@Stable
|
||||
sealed interface AsyncData<out T> {
|
||||
/**
|
||||
* Represents a failed operation.
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
* @property error the error that caused the operation to fail.
|
||||
* @property prevData the data returned by a previous successful run of the operation if any.
|
||||
*/
|
||||
data class Failure<out T>(
|
||||
val error: Throwable,
|
||||
val prevData: T? = null,
|
||||
) : AsyncData<T>
|
||||
|
||||
/**
|
||||
* Represents an operation that is currently ongoing.
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
* @property prevData the data returned by a previous successful run of the operation if any.
|
||||
*/
|
||||
data class Loading<out T>(
|
||||
val prevData: T? = null,
|
||||
) : AsyncData<T>
|
||||
|
||||
/**
|
||||
* Represents a successful operation.
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
* @property data the data returned by the operation.
|
||||
*/
|
||||
data class Success<out T>(
|
||||
val data: T,
|
||||
) : AsyncData<T>
|
||||
|
||||
/**
|
||||
* Represents an uninitialized operation (i.e. yet to be run).
|
||||
*/
|
||||
data object Uninitialized : AsyncData<Nothing>
|
||||
|
||||
/**
|
||||
* Returns the data returned by the operation, or null otherwise.
|
||||
*
|
||||
* Please note this method may return stale data if the operation is not [Success].
|
||||
*/
|
||||
fun dataOrNull(): T? = when (this) {
|
||||
is Failure -> prevData
|
||||
is Loading -> prevData
|
||||
is Success -> data
|
||||
Uninitialized -> null
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error that caused the operation to fail, or null otherwise.
|
||||
*/
|
||||
fun errorOrNull(): Throwable? = when (this) {
|
||||
is Failure -> error
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun isFailure(): Boolean = this is Failure<T>
|
||||
|
||||
fun isLoading(): Boolean = this is Loading<T>
|
||||
|
||||
fun isSuccess(): Boolean = this is Success<T>
|
||||
|
||||
fun isUninitialized(): Boolean = this == Uninitialized
|
||||
|
||||
fun isReady() = isSuccess() || isFailure()
|
||||
}
|
||||
|
||||
suspend inline fun <T> MutableState<AsyncData<T>>.runCatchingUpdatingState(
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
block: () -> T,
|
||||
): Result<T> = runUpdatingState(
|
||||
state = this,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = {
|
||||
runCatchingExceptions {
|
||||
block()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
|
||||
state: MutableState<AsyncData<T>>,
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
): Result<T> = runUpdatingState(
|
||||
state = state,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = {
|
||||
runCatchingExceptions {
|
||||
this()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
suspend inline fun <T> MutableState<AsyncData<T>>.runUpdatingState(
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
resultBlock: () -> Result<T>,
|
||||
): Result<T> = runUpdatingState(
|
||||
state = this,
|
||||
errorTransform = errorTransform,
|
||||
resultBlock = resultBlock,
|
||||
)
|
||||
|
||||
/**
|
||||
* Calls the specified [Result]-returning function [resultBlock]
|
||||
* encapsulating its progress and return value into an [AsyncData] while
|
||||
* posting its updates to the MutableState [state].
|
||||
*
|
||||
* @param T the type of data returned by the operation.
|
||||
* @param state the [MutableState] to post updates to.
|
||||
* @param errorTransform a function to transform the error before posting it.
|
||||
* @param resultBlock a suspending function that returns a [Result].
|
||||
* @return the [Result] returned by [resultBlock].
|
||||
*/
|
||||
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
|
||||
suspend inline fun <T> runUpdatingState(
|
||||
state: MutableState<AsyncData<T>>,
|
||||
errorTransform: (Throwable) -> Throwable = { it },
|
||||
resultBlock: suspend () -> Result<T>,
|
||||
): Result<T> {
|
||||
// Restore when the issue with contracts and AGP 8.13.x is fixed
|
||||
// contract {
|
||||
// callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
|
||||
// }
|
||||
val prevData = state.value.dataOrNull()
|
||||
state.value = AsyncData.Loading(prevData = prevData)
|
||||
return resultBlock().fold(
|
||||
onSuccess = {
|
||||
state.value = AsyncData.Success(it)
|
||||
Result.success(it)
|
||||
},
|
||||
onFailure = {
|
||||
val error = errorTransform(it)
|
||||
state.value = AsyncData.Failure(
|
||||
error = error,
|
||||
prevData = prevData,
|
||||
)
|
||||
Result.failure(error)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
inline fun <T, R> AsyncData<T>.map(
|
||||
transform: (T) -> R,
|
||||
): AsyncData<R> {
|
||||
return when (this) {
|
||||
is AsyncData.Failure -> AsyncData.Failure(
|
||||
error = error,
|
||||
prevData = prevData?.let { transform(prevData) }
|
||||
)
|
||||
is AsyncData.Loading -> AsyncData.Loading(prevData?.let { transform(prevData) })
|
||||
is AsyncData.Success -> AsyncData.Success(transform(data))
|
||||
AsyncData.Uninitialized -> AsyncData.Uninitialized
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Stable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.children.ChildEntry
|
||||
import com.bumble.appyx.core.composable.Children
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.navigation.model.combined.plus
|
||||
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
||||
import com.bumble.appyx.core.navigation.transition.TransitionHandler
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader
|
||||
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
|
||||
/**
|
||||
* This class is a [ParentNode] that contains a [BackStack] and an [Overlay]. It is used to represent a flow in the app.
|
||||
* Should be used instead of [ParentNode] in flow nodes.
|
||||
*/
|
||||
@Stable
|
||||
abstract class BaseFlowNode<NavTarget : Any>(
|
||||
val backstack: BackStack<NavTarget>,
|
||||
buildContext: BuildContext,
|
||||
plugins: List<Plugin>,
|
||||
val overlay: Overlay<NavTarget> = Overlay(null),
|
||||
val permanentNavModel: PermanentNavModel<NavTarget> = PermanentNavModel(emptySet(), null),
|
||||
childKeepMode: ChildEntry.KeepMode = ChildEntry.KeepMode.KEEP,
|
||||
) : ParentNode<NavTarget>(
|
||||
navModel = overlay + backstack + permanentNavModel,
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
childKeepMode = childKeepMode,
|
||||
) {
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
lifecycle.logLifecycle(this::class.java.simpleName)
|
||||
whenChildAttached<Node> { _, child ->
|
||||
// BackstackNode will be logged by their parent.
|
||||
if (child !is BaseFlowNode<*>) {
|
||||
child.lifecycle.logLifecycle(child::class.java.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.BackstackView(
|
||||
modifier: Modifier = Modifier,
|
||||
transitionHandler: TransitionHandler<NavTarget, BackStack.State> = rememberBackstackSlider(
|
||||
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||
),
|
||||
) {
|
||||
Children(
|
||||
modifier = modifier,
|
||||
navModel = backstack,
|
||||
transitionHandler = transitionHandler,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.OverlayView(
|
||||
modifier: Modifier = Modifier,
|
||||
transitionHandler: TransitionHandler<NavTarget, BackStack.State> = rememberBackstackFader(),
|
||||
) {
|
||||
Children(
|
||||
modifier = modifier,
|
||||
navModel = overlay,
|
||||
transitionHandler = transitionHandler,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
inline fun <reified NavTarget : Any> BaseFlowNode<NavTarget>.BackstackWithOverlayBox(
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable BoxScope.() -> Unit = {},
|
||||
) {
|
||||
Box(modifier = modifier) {
|
||||
BackstackView()
|
||||
OverlayView()
|
||||
content()
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import android.content.Context
|
||||
import android.content.ContextWrapper
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
||||
|
||||
inline fun <reified T : Any> Node.bindings() = bindings(T::class.java)
|
||||
inline fun <reified T : Any> Context.bindings() = bindings(T::class.java)
|
||||
|
||||
fun <T : Any> Context.bindings(klass: Class<T>): T {
|
||||
// search the components in the dependency injection graph
|
||||
return generateSequence(this) { (it as? ContextWrapper)?.baseContext }
|
||||
.plus(applicationContext)
|
||||
.filterIsInstance<DependencyInjectionGraphOwner>()
|
||||
.map { it.graph }
|
||||
.flatMap { it as? Collection<*> ?: listOf(it) }
|
||||
.filterIsInstance(klass)
|
||||
.firstOrNull()
|
||||
?: error("Unable to find bindings for ${klass.name}")
|
||||
}
|
||||
|
||||
fun <T : Any> Node.bindings(klass: Class<T>): T {
|
||||
// search the components in the node hierarchy
|
||||
return generateSequence(this, Node::parent)
|
||||
.filterIsInstance<DependencyInjectionGraphOwner>()
|
||||
.map { it.graph }
|
||||
.flatMap { it as? Collection<*> ?: listOf(it) }
|
||||
.filterIsInstance(klass)
|
||||
.firstOrNull()
|
||||
?: error("Unable to find bindings for ${klass.name}")
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
|
||||
/**
|
||||
* This interface represents an entrypoint to a feature. Should be used to return the entrypoint node of the feature without exposing the internal types.
|
||||
*/
|
||||
interface FeatureEntryPoint
|
||||
|
||||
/**
|
||||
* Can be used when the feature only exposes a simple node without the need of plugins.
|
||||
*/
|
||||
fun interface SimpleFeatureEntryPoint : FeatureEntryPoint {
|
||||
fun createNode(parentNode: Node, buildContext: BuildContext): Node
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import timber.log.Timber
|
||||
|
||||
fun Lifecycle.logLifecycle(name: String) {
|
||||
subscribe(
|
||||
onCreate = { Timber.tag("Lifecycle").d("onCreate $name") },
|
||||
onPause = { Timber.tag("Lifecycle").d("onPause $name") },
|
||||
onResume = { Timber.tag("Lifecycle").d("onResume $name") },
|
||||
onDestroy = { Timber.tag("Lifecycle").d("onDestroy $name") },
|
||||
)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
|
||||
inline fun <reified I : Plugin> Node.callback(): I {
|
||||
return requireNotNull(plugins<I>().singleOrNull()) { "Make sure to actually pass a Callback plugin to your node" }
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
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.Multibinds
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
inline fun <reified N : Node> Node.createNode(
|
||||
buildContext: BuildContext,
|
||||
plugins: List<Plugin> = emptyList()
|
||||
): N {
|
||||
val bindings: NodeFactoriesBindings = bindings()
|
||||
return bindings.createNode(buildContext, plugins)
|
||||
}
|
||||
|
||||
inline fun <reified N : Node> NodeFactoriesBindings.createNode(
|
||||
buildContext: BuildContext,
|
||||
plugins: List<Plugin>,
|
||||
): N {
|
||||
val nodeClass = N::class
|
||||
val nodeFactoryMap = nodeFactories()
|
||||
// Note to developers: If you got the error below, make sure to build again after
|
||||
// clearing the cache (sometimes several times) to let codegen generate the NodeFactory.
|
||||
val nodeFactory = nodeFactoryMap[nodeClass] ?: error("Cannot find NodeFactory for ${nodeClass.java.name}.")
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val castedNodeFactory = nodeFactory as? AssistedNodeFactory<N>
|
||||
val node = castedNodeFactory?.create(buildContext, plugins)
|
||||
return node as N
|
||||
}
|
||||
|
||||
fun interface NodeFactoriesBindings {
|
||||
@Multibinds
|
||||
fun nodeFactories(): Map<KClass<out Node>, AssistedNodeFactory<*>>
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
|
||||
interface NodeInputs : Plugin
|
||||
|
||||
inline fun <reified I : NodeInputs> Node.inputs(): I {
|
||||
return requireNotNull(plugins<I>().firstOrNull()) { "Make sure to actually pass NodeInputs plugin to your node" }
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import dev.zacsweers.metro.MapKey
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
|
||||
@MapKey
|
||||
annotation class NodeKey(val value: KClass<out Node>)
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.bumble.appyx.core.children.nodeOrNull
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.node.ParentNode
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
fun <NavTarget : Any> ParentNode<NavTarget>.childNode(navTarget: NavTarget): Node? {
|
||||
val childMap = children.value
|
||||
val key = childMap.keys.find { it.navTarget == navTarget }
|
||||
return childMap[key]?.nodeOrNull
|
||||
}
|
||||
|
||||
suspend inline fun <reified N : Node, NavTarget : Any> ParentNode<NavTarget>.waitForChildAttached(crossinline predicate: (NavTarget) -> Boolean): N =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
lifecycleScope.launch {
|
||||
children.collect { childMap ->
|
||||
val expectedChildNode = childMap.entries
|
||||
.map { it.key.navTarget }
|
||||
.lastOrNull(predicate)
|
||||
?.let {
|
||||
childNode(it) as? N
|
||||
}
|
||||
if (expectedChildNode != null && !continuation.isCompleted) {
|
||||
continuation.resume(expectedChildNode)
|
||||
}
|
||||
}
|
||||
}.invokeOnCompletion {
|
||||
continuation.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for a child to be attached to the parent node, only using the NavTarget.
|
||||
*/
|
||||
suspend inline fun <NavTarget : Any> ParentNode<NavTarget>.waitForNavTargetAttached(crossinline predicate: (NavTarget) -> Boolean) =
|
||||
suspendCancellableCoroutine { continuation ->
|
||||
lifecycleScope.launch {
|
||||
children.collect { childMap ->
|
||||
val node = childMap.entries
|
||||
.map { it.key.navTarget }
|
||||
.lastOrNull(predicate)
|
||||
if (node != null && !continuation.isCompleted) {
|
||||
continuation.resume(Unit)
|
||||
cancel()
|
||||
}
|
||||
}
|
||||
}.invokeOnCompletion {
|
||||
continuation.cancel()
|
||||
}
|
||||
}
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
fun interface Presenter<State> {
|
||||
@Composable
|
||||
fun present(): State
|
||||
}
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture.animation
|
||||
|
||||
import androidx.compose.animation.core.Spring
|
||||
import androidx.compose.animation.core.spring
|
||||
import androidx.compose.runtime.Composable
|
||||
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider
|
||||
|
||||
@Composable
|
||||
fun <NavTarget> rememberDefaultTransitionHandler(): ModifierTransitionHandler<NavTarget, BackStack.State> {
|
||||
return rememberBackstackSlider(
|
||||
transitionSpec = { spring(stiffness = Spring.StiffnessMediumLow) },
|
||||
)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* 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.libraries.architecture.appyx
|
||||
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
|
||||
fun <T : Any> BackStack<T>.canPop(): Boolean {
|
||||
val elements = elements.value
|
||||
return elements.any { it.targetState == BackStack.State.ACTIVE } &&
|
||||
elements.any { it.targetState == BackStack.State.STASHED }
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* 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.libraries.architecture.appyx
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import androidx.compose.animation.core.Transition
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.navigation.transition.ModifierTransitionHandler
|
||||
import com.bumble.appyx.core.navigation.transition.TransitionDescriptor
|
||||
|
||||
/**
|
||||
* A [ModifierTransitionHandler] that delegates the creation of the modifier to another handler
|
||||
* based on the [NavTarget]. The idea is to allow different transitions for different [NavTarget]s.
|
||||
*/
|
||||
class DelegateTransitionHandler<NavTarget, State>(
|
||||
private val handlerProvider: (NavTarget) -> ModifierTransitionHandler<NavTarget, State>,
|
||||
) : ModifierTransitionHandler<NavTarget, State>() {
|
||||
@SuppressLint("ModifierFactoryExtensionFunction")
|
||||
override fun createModifier(modifier: Modifier, transition: Transition<State>, descriptor: TransitionDescriptor<NavTarget, State>): Modifier {
|
||||
return handlerProvider(descriptor.element).createModifier(modifier, transition, descriptor)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun <NavTarget, State> rememberDelegateTransitionHandler(
|
||||
handlerProvider: (NavTarget) -> ModifierTransitionHandler<NavTarget, State>,
|
||||
): ModifierTransitionHandler<NavTarget, State> =
|
||||
remember(handlerProvider) { DelegateTransitionHandler(handlerProvider = handlerProvider) }
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
@file:OptIn(InternalComposeApi::class)
|
||||
|
||||
package io.element.android.libraries.architecture.appyx
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import app.cash.molecule.AndroidUiDispatcher
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.launchMolecule
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
fun <State> Node.launchMolecule(body: @Composable () -> State): StateFlow<State> {
|
||||
val scope = CoroutineScope(lifecycleScope.coroutineContext + AndroidUiDispatcher.Main)
|
||||
return scope.launchMolecule(mode = RecompositionMode.ContextClock) {
|
||||
currentComposer.startProviders(
|
||||
values = arrayOf(LocalLifecycleOwner provides this),
|
||||
)
|
||||
val state = body()
|
||||
currentComposer.endProviders()
|
||||
state
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture.coverage
|
||||
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
@Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS)
|
||||
annotation class ExcludeFromCoverage
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture.overlay
|
||||
|
||||
import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BaseBackPressHandlerStrategy
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.BackStackElements
|
||||
import io.element.android.libraries.architecture.overlay.operation.Hide
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class HideOverlayBackPressHandler<NavTarget : Any> :
|
||||
BaseBackPressHandlerStrategy<NavTarget, BackStack.State>() {
|
||||
override val canHandleBackPressFlow: Flow<Boolean> by lazy {
|
||||
navModel.elements.map(::areThereElements)
|
||||
}
|
||||
|
||||
private fun areThereElements(elements: BackStackElements<NavTarget>) =
|
||||
elements.isNotEmpty()
|
||||
|
||||
override fun onBackPressed() {
|
||||
navModel.accept(Hide())
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture.overlay
|
||||
|
||||
import com.bumble.appyx.core.navigation.BaseNavModel
|
||||
import com.bumble.appyx.core.navigation.NavElements
|
||||
import com.bumble.appyx.core.navigation.backpresshandlerstrategies.BackPressHandlerStrategy
|
||||
import com.bumble.appyx.core.navigation.onscreen.OnScreenStateResolver
|
||||
import com.bumble.appyx.core.navigation.operationstrategies.ExecuteImmediately
|
||||
import com.bumble.appyx.core.navigation.operationstrategies.OperationStrategy
|
||||
import com.bumble.appyx.core.state.SavedStateMap
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.BackStackOnScreenResolver
|
||||
|
||||
class Overlay<NavTarget : Any>(
|
||||
savedStateMap: SavedStateMap?,
|
||||
key: String = requireNotNull(Overlay::class.qualifiedName),
|
||||
backPressHandler: BackPressHandlerStrategy<NavTarget, BackStack.State> = HideOverlayBackPressHandler(),
|
||||
operationStrategy: OperationStrategy<NavTarget, BackStack.State> = ExecuteImmediately(),
|
||||
screenResolver: OnScreenStateResolver<BackStack.State> = BackStackOnScreenResolver,
|
||||
) : BaseNavModel<NavTarget, BackStack.State>(
|
||||
backPressHandler = backPressHandler,
|
||||
screenResolver = screenResolver,
|
||||
operationStrategy = operationStrategy,
|
||||
finalState = BackStack.State.DESTROYED,
|
||||
savedStateMap = savedStateMap,
|
||||
key = key,
|
||||
) {
|
||||
override val initialElements: NavElements<NavTarget, BackStack.State>
|
||||
get() = emptyList()
|
||||
}
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture.overlay.operation
|
||||
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.BackStackElements
|
||||
import com.bumble.appyx.navmodel.backstack.activeIndex
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@Parcelize
|
||||
class Hide<T : Any> : OverlayOperation<T> {
|
||||
override fun isApplicable(elements: BackStackElements<T>): Boolean =
|
||||
elements.any { it.targetState == BackStack.State.ACTIVE }
|
||||
|
||||
override fun invoke(
|
||||
elements: BackStackElements<T>
|
||||
): BackStackElements<T> {
|
||||
val hideIndex = elements.activeIndex
|
||||
require(hideIndex != -1) { "Nothing to hide, state=$elements" }
|
||||
return elements.mapIndexed { index, element ->
|
||||
when (index) {
|
||||
hideIndex -> element.transitionTo(
|
||||
newTargetState = BackStack.State.DESTROYED,
|
||||
operation = this
|
||||
)
|
||||
else -> element
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun equals(other: Any?): Boolean = this.javaClass == other?.javaClass
|
||||
|
||||
override fun hashCode(): Int = this.javaClass.hashCode()
|
||||
}
|
||||
|
||||
fun <T : Any> Overlay<T>.hide() {
|
||||
accept(Hide())
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture.overlay.operation
|
||||
|
||||
import com.bumble.appyx.core.navigation.Operation
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
|
||||
interface OverlayOperation<T> : Operation<T, BackStack.State>
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture.overlay.operation
|
||||
|
||||
import com.bumble.appyx.core.navigation.NavKey
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.BackStackElement
|
||||
import com.bumble.appyx.navmodel.backstack.BackStackElements
|
||||
import com.bumble.appyx.navmodel.backstack.activeElement
|
||||
import io.element.android.libraries.architecture.overlay.Overlay
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import kotlinx.parcelize.RawValue
|
||||
|
||||
@Parcelize
|
||||
data class Show<T : Any>(
|
||||
private val element: @RawValue T
|
||||
) : OverlayOperation<T> {
|
||||
override fun isApplicable(elements: BackStackElements<T>): Boolean =
|
||||
element != elements.activeElement
|
||||
|
||||
override fun invoke(elements: BackStackElements<T>): BackStackElements<T> = listOf(
|
||||
BackStackElement(
|
||||
key = NavKey(element),
|
||||
fromState = BackStack.State.CREATED,
|
||||
targetState = BackStack.State.ACTIVE,
|
||||
operation = this
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
fun <T : Any> Overlay<T>.show(element: T) {
|
||||
accept(Show(element))
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
/*
|
||||
* 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.libraries.architecture
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import kotlinx.coroutines.TimeoutCancellationException
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class AsyncActionTest {
|
||||
@Test
|
||||
fun `updates state on timeout`() = runTest {
|
||||
val state: MutableState<AsyncAction<Int>> = mutableStateOf(AsyncAction.Uninitialized)
|
||||
val timeoutMillis = 500L
|
||||
val operationTimeMillis = 1000L
|
||||
|
||||
try {
|
||||
runUpdatingState(state = state) {
|
||||
withTimeout(timeoutMillis.milliseconds) {
|
||||
delay(operationTimeMillis)
|
||||
}
|
||||
Result.success(0)
|
||||
}
|
||||
fail("Expected TimeoutCancellationException, but nothing was thrown")
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
assertTrue(state.value.isFailure())
|
||||
assertSame(e, state.value.errorOrNull())
|
||||
}
|
||||
}
|
||||
}
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.libraries.architecture
|
||||
|
||||
import androidx.compose.runtime.MutableState
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class AsyncDataKtTest {
|
||||
@Test
|
||||
fun `updates state when block returns success`() = runTest {
|
||||
val state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
|
||||
|
||||
val result = runUpdatingState(state) {
|
||||
delay(1)
|
||||
Result.success(1)
|
||||
}
|
||||
|
||||
assertThat(result.isSuccess).isTrue()
|
||||
assertThat(result.getOrNull()).isEqualTo(1)
|
||||
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Success(1))
|
||||
state.assertNoMoreValues()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates state when block returns failure`() = runTest {
|
||||
val state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
|
||||
|
||||
val result = runUpdatingState(state) {
|
||||
delay(1)
|
||||
Result.failure(MyException("hello"))
|
||||
}
|
||||
|
||||
assertThat(result.isFailure).isTrue()
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(MyException("hello"))
|
||||
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Failure<Int>(MyException("hello")))
|
||||
state.assertNoMoreValues()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updates state when block returns failure transforming the error`() = runTest {
|
||||
val state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
|
||||
|
||||
val result = runUpdatingState(state, { MyException(it.message + " world") }) {
|
||||
delay(1)
|
||||
Result.failure(MyException("hello"))
|
||||
}
|
||||
|
||||
assertThat(result.isFailure).isTrue()
|
||||
assertThat(result.exceptionOrNull()).isEqualTo(MyException("hello world"))
|
||||
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized)
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null))
|
||||
assertThat(state.popFirst()).isEqualTo(AsyncData.Failure<Int>(MyException("hello world")))
|
||||
state.assertNoMoreValues()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A fake [MutableState] that allows to record all the states that were set.
|
||||
*/
|
||||
private class TestableMutableState<T>(
|
||||
value: T
|
||||
) : MutableState<T> {
|
||||
private val deque = ArrayDeque(listOf(value))
|
||||
|
||||
override var value: T
|
||||
get() = deque.last()
|
||||
set(value) {
|
||||
deque.addLast(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the states that were set in the order they were set.
|
||||
*/
|
||||
fun popFirst(): T = deque.removeFirst()
|
||||
|
||||
fun assertNoMoreValues() {
|
||||
assertThat(deque).isEmpty()
|
||||
}
|
||||
|
||||
override operator fun component1(): T = value
|
||||
|
||||
override operator fun component2(): (T) -> Unit = { value = it }
|
||||
}
|
||||
|
||||
/**
|
||||
* An exception that is also a data class so we can compare it using equals.
|
||||
*/
|
||||
private data class MyException(val myMessage: String) : Exception(myMessage)
|
||||
Reference in New Issue
Block a user