Skip to content
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
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import net.thunderbird.core.outcome.Outcome
import net.thunderbird.feature.account.AccountId
import net.thunderbird.feature.mail.folder.api.FolderServerId
import net.thunderbird.feature.mail.folder.api.RemoteFolder
import net.thunderbird.feature.mail.message.list.preferences.MessageListPreferences

interface DomainContract {
interface UseCase {
Expand All @@ -31,6 +32,10 @@ interface DomainContract {
fun interface BuildSwipeActions {
operator fun invoke(): StateFlow<Map<AccountId, SwipeActions>>
}

fun interface GetMessageListPreferences {
operator fun invoke(): Flow<MessageListPreferences>
}
}
}

Expand Down
Loading
Loading