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
+1
View File
@@ -0,0 +1 @@
/build
+31
View File
@@ -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)
}
@@ -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
}
@@ -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)
}
)
}
@@ -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
}
}
@@ -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()
}
}
@@ -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}")
}
@@ -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
}
@@ -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") },
)
}
@@ -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" }
}
@@ -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<*>>
}
@@ -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" }
}
@@ -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>)
@@ -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()
}
}
@@ -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
}
@@ -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) },
)
}
@@ -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 }
}
@@ -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) }
@@ -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
}
}
@@ -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
@@ -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())
}
}
@@ -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()
}
@@ -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())
}
@@ -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>
@@ -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))
}
@@ -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())
}
}
}
@@ -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)