Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,7 @@ internal class DefaultStateMachine<TState : Any, TEvent : Any>(
init {
scope.launch {
// delay onEnter initialization so the viewModels are ready to receive the state
println("Executed init. $stateRegistrar")
delay(500.milliseconds)
println("Executed? $stateRegistrar")
stateRegistrar[initialState::class]?.listeners?.onEnter?.invoke(null, null, currentStateSnapshot)
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
package net.thunderbird.core.common.state.sideeffect

import kotlinx.coroutines.CoroutineScope
import net.thunderbird.core.logging.Logger

/**
* A base class for handling side effects that arise from state transitions in the message list UI.
*
* Each implementation of this class is responsible for a specific side effect, such as loading more messages,
* marking messages as read, or showing notifications. The handler decides whether to act based on the
* incoming event and the resulting state change.
*
* @property logger A [Logger] for logging the handler's operations.
* @property dispatch A function to send new [TEvent]s back to the UI's event loop.
*/
abstract class StateSideEffectHandler<TState : Any, TEvent : Any>(
private val logger: Logger,
protected val dispatch: suspend (TEvent) -> Unit,
) {
/**
* Determines whether this side effect handler should be triggered.
*
* This function is called for every state change to check if the conditions for executing
* this specific side effect are met.
*
* @param event The [TEvent] that triggered the state change.
* @param newState The new [TState] after the event was processed.
* @return `true` if this handler should execute its `handle` method, `false` otherwise.
*/
abstract fun accept(event: TEvent, newState: TState): Boolean

/**
* Handles the side effect based on the state transition.
*
* This function is invoked when the `accept` method returns `true`, indicating that this handler
* should process the state change. It's responsible for executing the actual side effect,
* such as dispatching a new event, logging, or interacting with other system components.
*
* @param oldState The state before the event was processed.
* @param newState The new state after the event has been processed.
*/
abstract suspend fun handle(oldState: TState, newState: TState)

/**
* Handles a state change by checking if this handler should react to the [event] and the [newState].
* If it should, it calls the [handle] method to perform the side effect.
*
* @param event The event that triggered the state change.
* @param oldState The state before the event was processed.
* @param newState The state after the event was processed.
*/
suspend fun handle(event: TEvent, oldState: TState, newState: TState) {
logger.verbose { "handle() called with: event = $event, oldState = $oldState, newState = $newState" }
if (accept(event, newState)) {
handle(oldState, newState)
}
}

fun interface Factory<TState : Any, TEvent : Any> {
fun create(scope: CoroutineScope, dispatch: suspend (TEvent) -> Unit): StateSideEffectHandler<TState, TEvent>
}
}
3 changes: 3 additions & 0 deletions core/ui/compose/common/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -14,5 +14,8 @@ android {
}

dependencies {
implementation(projects.core.common)
implementation(projects.core.logging.api)
testImplementation(projects.core.logging.testing)
testImplementation(projects.core.ui.compose.testing)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package net.thunderbird.core.ui.compose.common.mvi

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.k9mail.core.ui.compose.common.mvi.UnidirectionalViewModel
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch
import net.thunderbird.core.common.state.StateMachine
import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler
import net.thunderbird.core.logging.Logger

/**
* An abstract base ViewModel that implements [UnidirectionalViewModel] and provides a
* MVI (Model-View-Intent) architecture based on a [StateMachine].
*
* This class serves as a bridge between the UI (View) and the business logic (Model),
* which is encapsulated within a [StateMachine]. It receives user actions as [events][TEvent],
* processes them through the state machine, and exposes the resulting [state][TState]
* and one-time [UI side effects][TUiSideEffect] to the UI.
*
* @param TState The type that represents the state of the ViewModel. For example, the
* UI state of a screen.
* @param TEvent The type that represents user actions that can be handled by the ViewModel.
* For example, a button click.
* @param TUiSideEffect The type that represents one-time side effects that can occur
* in response to state changes. For example, a navigation event or showing a toast message.
* @param logger The [Logger] instance for logging events and state changes.
* @param sideEffectHandlersFactories A list of factories for creating [StateSideEffectHandler]s.
* These handlers can be used to trigger side effects in response to state transitions.
*/
abstract class StateMachineViewModel<TState : Any, TEvent : Any, TUiSideEffect>(
protected val logger: Logger,
sideEffectHandlersFactories: List<StateSideEffectHandler.Factory<TState, TEvent>> = emptyList(),
) :
ViewModel(),
UnidirectionalViewModel<TState, TEvent, TUiSideEffect> {

/**
* The state machine responsible for managing the state transitions.
*
* It processes events and updates the [state] accordingly. Subclasses must provide
* an implementation of this [net.thunderbird.core.common.state.StateMachine].
*/
protected abstract val stateMachine: StateMachine<TState, TEvent>

override val state: StateFlow<TState> get() = stateMachine.currentState

private val _effect = MutableSharedFlow<TUiSideEffect>()
override val effect: SharedFlow<TUiSideEffect> = _effect.asSharedFlow()
private val sideEffectHandlers = sideEffectHandlersFactories.map { it.create(viewModelScope, ::event) }

private val handledOneTimeEvents = mutableSetOf<TEvent>()

/**
* Emits a side effect to the UI.
*
* Side effects are events that should be consumed by the UI only once, such as showing
* a toast message, navigating to another screen, or triggering a one-time animation.
* This function should be called from within the ViewModel to signal such an event to
* the UI layer, which collects the `effect` flow.
*
* @param effect The [TUiSideEffect] to emit.
*/
protected fun emitEffect(effect: TUiSideEffect) {
viewModelScope.launch {
_effect.emit(effect)
}
}

/**
* Ensures that one-time events are only handled once.
*
* When you can't ensure that an event is only sent once, but you want the event to only
* be handled once, call this method. It will ensure [block] is only executed the first
* time this function is called. Subsequent calls with an [event] argument equal to that
* of a previous invocation will not execute [block].
*
* Multiple one-time events are supported.
*/
protected fun handleOneTimeEvent(event: TEvent, block: () -> Unit) {
if (event !in handledOneTimeEvents) {
handledOneTimeEvents.add(event)
block()
}
}

/**
* Processes an event by launching a coroutine in the [viewModelScope] to delegate it to
* the [stateMachine].
*
* This function is the entry point for all user actions or other events that can modify
* the ViewModel's state.
*
* It ensures that events are processed asynchronously off the main thread.
*
* @param event The [TEvent] to be processed.
*/
final override fun event(event: TEvent) {
viewModelScope.launch {
val currentState = stateMachine.currentStateSnapshot
val newState = stateMachine.process(event)
if (newState != currentState) {
logger.verbose { "event(${event::class.simpleName}): state update." }
sideEffectHandlers
.filter { it.accept(event, newState) }
.forEach { it.handle(event, oldState = currentState, newState) }
}
stateMachine.process(event)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
package net.thunderbird.core.ui.compose.common.mvi

import app.cash.turbine.test
import assertk.assertThat
import assertk.assertions.isEqualTo
import assertk.assertions.isFalse
import assertk.assertions.isTrue
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.test.runTest
import net.thunderbird.core.common.state.StateMachine
import net.thunderbird.core.common.state.sideeffect.StateSideEffectHandler
import net.thunderbird.core.logging.Logger
import net.thunderbird.core.logging.testing.TestLogger
import net.thunderbird.core.testing.coroutines.MainDispatcherRule
import org.junit.Rule
import org.junit.Test

class StateMachineViewModelTest {

@get:Rule
val mainDispatcherRule = MainDispatcherRule()

@Test
fun `should emit initial state`() = runTest {
val initialState = "Initial state"
val viewModel = TestStateMachineViewModel(
initialState = initialState,
)

viewModel.state.test {
assertThat(awaitItem()).isEqualTo(initialState)
}
}

@Test
fun `should update state`() = runTest {
val viewModel = TestStateMachineViewModel(
initialState = "Initial state",
)

viewModel.state.test {
assertThat(awaitItem()).isEqualTo("Initial state")

viewModel.event("Test event")

assertThat(awaitItem()).isEqualTo("Test event")

viewModel.event("Another test event")

assertThat(awaitItem()).isEqualTo("Another test event")
}
}

@Test
fun `should emit effects`() = runTest {
val viewModel = TestStateMachineViewModel(
initialState = "Initial state",
)

viewModel.effect.test {
viewModel.callEmitEffect("Test effect")

assertThat(awaitItem()).isEqualTo("Test effect")

viewModel.callEmitEffect("Another test effect")

assertThat(awaitItem()).isEqualTo("Another test effect")
}
}

@Test
fun `handleOneTimeEvent() should execute block`() = runTest {
val viewModel = TestStateMachineViewModel(
initialState = "Initial state",
)
var eventHandled = false

viewModel.callHandleOneTimeEvent(event = "event") {
eventHandled = true
}

assertThat(eventHandled).isTrue()
}

@Test
fun `handleOneTimeEvent() should execute block only once`() = runTest {
val viewModel = TestStateMachineViewModel(
initialState = "Initial state",
)
var eventHandledCount = 0

repeat(2) {
viewModel.callHandleOneTimeEvent(event = "event") {
eventHandledCount++
}
}

assertThat(eventHandledCount).isEqualTo(1)
}

@Test
fun `handleOneTimeEvent() should support multiple one-time events`() = runTest {
val viewModel = TestStateMachineViewModel(
initialState = "Initial state",
)
var eventOneHandled = false
var eventTwoHandled = false

viewModel.callHandleOneTimeEvent(event = "eventOne") {
eventOneHandled = true
}

assertThat(eventOneHandled).isTrue()
assertThat(eventTwoHandled).isFalse()

viewModel.callHandleOneTimeEvent(event = "eventTwo") {
eventTwoHandled = true
}

assertThat(eventOneHandled).isTrue()
assertThat(eventTwoHandled).isTrue()
}

@Test
fun `should trigger side effect handlers on state change`() = runTest {
var sideEffectTriggered = false
val sideEffectHandler = object : StateSideEffectHandler<String, String>(TestLogger(), {}) {
override fun accept(event: String, newState: String): Boolean = true
override suspend fun handle(oldState: String, newState: String) {
sideEffectTriggered = true
}
}
val factory = StateSideEffectHandler.Factory { _, _ -> sideEffectHandler }

val viewModel = TestStateMachineViewModel(
initialState = "Initial state",
sideEffectHandlersFactories = listOf(factory),
)

viewModel.event("Test event")
viewModel.state.test {
val state = awaitItem()
assertThat(state).isEqualTo("Test event")
assertThat(sideEffectTriggered).isTrue()
}
}

private class TestStateMachineViewModel(
initialState: String,
logger: Logger = TestLogger(),
override val stateMachine: StateMachine<String, String> = FakeStateMachine(initialState),
sideEffectHandlersFactories: List<StateSideEffectHandler.Factory<String, String>> = emptyList(),
) : StateMachineViewModel<String, String, String>(logger, sideEffectHandlersFactories) {

fun callEmitEffect(effect: String) {
emitEffect(effect)
}

fun callHandleOneTimeEvent(event: String, block: () -> Unit) {
handleOneTimeEvent(event, block)
}
}

private class FakeStateMachine(initialState: String) : StateMachine<String, String> {
private val _currentState = MutableStateFlow(initialState)
override val currentState: StateFlow<String> = _currentState.asStateFlow()

override suspend fun process(event: String): String {
_currentState.value = event
return _currentState.value
}
}
}
1 change: 1 addition & 0 deletions feature/mail/message/list/api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies {

implementation(projects.core.common)
implementation(projects.core.featureflag)
implementation(projects.core.logging.api)
implementation(projects.core.preference.api)
implementation(projects.core.ui.compose.common)
implementation(projects.feature.account.api)
Expand Down
Loading
Loading