Async API improvements "v2" (#672)

* Async API improvements "v2"

**NB: This PR actually changes only 3 files in `libraries/architecture/`. All the other changes are automated refactors to fix the calling code.**

This is a proposal for improvements to our `Async` type as discussed in: https://github.com/vector-im/element-x-android/pull/598/files#r1230664392 and in other chats.

Please bear in mind it is just a proposal, I'd love to hear your feedback about it, especially when it comes to naming: I've tried to make parameter and function names use a terminology similar to what we find in the Kotlin stdlib and its `Result` type.

I'm inclined to like more the non-extension flavours of the new `run*` APIs, though I'd also like your feedback about what API shape you prefer.

### Summary of the changes:
#### Functional
- Adds `exceptionOrNull()` API to complement the existing `dataOrNull()` API.
- Adds `isFailure()`, `isLoading()`, `isSuccess()` and `isUninitialized()` courtesy APIs.
- Renames `executeResult()` to `runUpdatingState()`:
	- Becomes the base API to which all the other similarly named APIs call into.
	- Makes it inline.
	- Adds contract.
	- Passes over any `prevData` to newre Async states.
	- Passes through the `block`s return value.
	- Adds unit tests.
- Renames `execute` to `runCatchingUpdatingState()` and makes it just call into `runUpdatingState()`
- Adds extension function overloads to the `run*` functions to accept `MutableState` as receiver

#### Cosmetics
- Reorders classes and methods in alphabetic order.
- Reorder parameter names to mimic conventions in Kotlin stdlib.
- Adds docstrings where useful.

* Use `fold()`

* rename pop to popFirst

* Add docstrings

* Please Detekt

* Rename exception to error.

* Please detekt

* Update existing usages.
This commit is contained in:
Marco Romano 2023-06-27 13:37:23 +02:00 committed by GitHub
parent fd1352de5f
commit 2a82e1b4e4
25 changed files with 286 additions and 75 deletions

View file

@ -30,7 +30,7 @@ import io.element.android.features.createroom.impl.CreateRoomConfig
import io.element.android.features.createroom.impl.CreateRoomDataStore import io.element.android.features.createroom.impl.CreateRoomDataStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -131,7 +131,7 @@ class ConfigureRoomPresenter @Inject constructor(
dataStore.clearCachedData() dataStore.clearCachedData()
analyticsService.capture(CreatedRoom(isDM = false)) analyticsService.capture(CreatedRoom(isDM = false))
} }
}.execute(createRoomAction) }.runCatchingUpdatingState(createRoomAction)
} }
private suspend fun uploadAvatar(avatarUri: Uri): String { private suspend fun uploadAvatar(avatarUri: Uri): String {

View file

@ -84,7 +84,7 @@ fun ConfigureRoomView(
if (state.createRoomAction is Async.Success) { if (state.createRoomAction is Async.Success) {
LaunchedEffect(state.createRoomAction) { LaunchedEffect(state.createRoomAction) {
onRoomCreated(state.createRoomAction.state) onRoomCreated(state.createRoomAction.data)
} }
} }

View file

@ -28,7 +28,7 @@ import io.element.android.features.createroom.impl.userlist.UserListPresenter
import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs import io.element.android.features.createroom.impl.userlist.UserListPresenterArgs
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -95,6 +95,6 @@ class CreateRoomRootPresenter @Inject constructor(
suspend { suspend {
matrixClient.createDM(user.userId).getOrThrow() matrixClient.createDM(user.userId).getOrThrow()
.also { analyticsService.capture(CreatedRoom(isDM = true)) } .also { analyticsService.capture(CreatedRoom(isDM = true)) }
}.execute(startDmAction) }.runCatchingUpdatingState(startDmAction)
} }
} }

View file

@ -69,7 +69,7 @@ fun CreateRoomRootView(
) { ) {
if (state.startDmAction is Async.Success) { if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) { LaunchedEffect(state.startDmAction) {
onOpenDM(state.startDmAction.state) onOpenDM(state.startDmAction.data)
} }
} }

View file

@ -30,7 +30,7 @@ import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
import io.element.android.features.invitelist.impl.model.InviteSender import io.element.android.features.invitelist.impl.model.InviteSender
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -135,7 +135,7 @@ class InviteListPresenter @Inject constructor(
it.acceptInvitation().getOrThrow() it.acceptInvitation().getOrThrow()
} }
roomId roomId
}.execute(acceptedAction) }.runCatchingUpdatingState(acceptedAction)
} }
private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.declineInvite(roomId: RoomId, declinedAction: MutableState<Async<Unit>>) = launch {
@ -143,7 +143,7 @@ class InviteListPresenter @Inject constructor(
client.getRoom(roomId)?.use { client.getRoom(roomId)?.use {
it.rejectInvitation().getOrThrow() it.rejectInvitation().getOrThrow()
} ?: Unit } ?: Unit
}.execute(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }
private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run { private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {

View file

@ -58,7 +58,7 @@ fun InviteListView(
) { ) {
if (state.acceptedAction is Async.Success) { if (state.acceptedAction is Async.Success) {
LaunchedEffect(state.acceptedAction) { LaunchedEffect(state.acceptedAction) {
onInviteAccepted(state.acceptedAction.state) onInviteAccepted(state.acceptedAction.data)
} }
} }

View file

@ -26,7 +26,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -71,6 +71,6 @@ class ChangeServerPresenter @Inject constructor(
// Valid, remember user choice // Valid, remember user choice
accountProviderDataSource.userSelection(data) accountProviderDataSource.userSelection(data)
}.getOrThrow() }.getOrThrow()
}.execute(changeServerAction, errorMapping = ChangeServerError::from) }.runCatchingUpdatingState(changeServerAction, errorTransform = ChangeServerError::from)
} }
} }

View file

@ -30,7 +30,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat
import io.element.android.features.login.impl.error.ChangeServerError import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -95,6 +95,6 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
throw IllegalStateException("Unsupported login flow") throw IllegalStateException("Unsupported login flow")
} }
}.getOrThrow() }.getOrThrow()
}.execute(loginFlowAction, errorMapping = ChangeServerError::from) }.runCatchingUpdatingState(loginFlowAction, errorTransform = ChangeServerError::from)
} }
} }

View file

@ -131,7 +131,7 @@ fun ConfirmAccountProviderView(
} }
is Async.Loading -> Unit // The Continue button shows the loading state is Async.Loading -> Unit // The Continue button shows the loading state
is Async.Success -> { is Async.Success -> {
when (val loginFlowState = state.loginFlow.state) { when (val loginFlowState = state.loginFlow.data) {
is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails) is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails)
LoginFlow.PasswordLogin -> onLoginPasswordNeeded() LoginFlow.PasswordLogin -> onLoginPasswordNeeded()
} }

View file

@ -171,7 +171,7 @@ fun SearchAccountProviderView(
} }
} }
is Async.Success -> { is Async.Success -> {
items(state.userInputResult.state) { homeserverData -> items(state.userInputResult.data) { homeserverData ->
val item = homeserverData.toAccountProvider() val item = homeserverData.toAccountProvider()
AccountProviderView( AccountProviderView(
item = item, item = item,

View file

@ -26,7 +26,7 @@ import io.element.android.features.logout.api.LogoutPreferenceEvents
import io.element.android.features.logout.api.LogoutPreferencePresenter import io.element.android.features.logout.api.LogoutPreferencePresenter
import io.element.android.features.logout.api.LogoutPreferenceState import io.element.android.features.logout.api.LogoutPreferenceState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -59,6 +59,6 @@ class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixCli
private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.logout(logoutAction: MutableState<Async<Unit>>) = launch {
suspend { suspend {
matrixClient.logout() matrixClient.logout()
}.execute(logoutAction) }.runCatchingUpdatingState(logoutAction)
} }
} }

View file

@ -27,7 +27,7 @@ import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -83,8 +83,8 @@ class AttachmentsPreviewPresenter @AssistedInject constructor(
mediaAttachment: Attachment.Media, mediaAttachment: Attachment.Media,
sendActionState: MutableState<Async<Unit>>, sendActionState: MutableState<Async<Unit>>,
) { ) {
suspend { sendActionState.runUpdatingState {
mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible) mediaSender.sendMedia(mediaAttachment.localMedia.uri, mediaAttachment.localMedia.info.mimeType, mediaAttachment.compressIfPossible)
}.executeResult(sendActionState) }
} }
} }

View file

@ -24,14 +24,12 @@ import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId

View file

@ -117,7 +117,7 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) { if (localMedia is Async.Success) {
localMediaActions.saveOnDisk(localMedia.state) localMediaActions.saveOnDisk(localMedia.data)
.onSuccess { .onSuccess {
val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android)
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
@ -131,7 +131,7 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) { if (localMedia is Async.Success) {
localMediaActions.share(localMedia.state) localMediaActions.share(localMedia.data)
.onFailure { .onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it)) val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
@ -141,7 +141,7 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) { if (localMedia is Async.Success) {
localMediaActions.open(localMedia.state) localMediaActions.open(localMedia.data)
.onFailure { .onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it)) val snackbarMessage = SnackbarMessage(mediaActionsError(it))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)

View file

@ -57,7 +57,6 @@ import io.element.android.features.messages.impl.media.local.LocalMediaView
import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.local.MediaInfo
import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState import io.element.android.features.messages.impl.media.local.rememberLocalMediaViewState
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog import io.element.android.libraries.designsystem.components.dialogs.RetryDialog

View file

@ -29,8 +29,7 @@ import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.executeResult import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@ -87,12 +86,12 @@ class ReportMessagePresenter @AssistedInject constructor(
blockUser: Boolean, blockUser: Boolean,
result: MutableState<Async<Unit>>, result: MutableState<Async<Unit>>,
) = launch { ) = launch {
suspend { result.runUpdatingState {
val userIdToBlock = userId.takeIf { blockUser } val userIdToBlock = userId.takeIf { blockUser }
room.reportContent(eventId, reason, userIdToBlock) room.reportContent(eventId, reason, userIdToBlock)
.onSuccess { .onSuccess {
snackbarDispatcher.post(SnackbarMessage(StringR.string.common_report_submitted)) snackbarDispatcher.post(SnackbarMessage(StringR.string.common_report_submitted))
} }
}.executeResult(result) }
} }
} }

View file

@ -29,7 +29,7 @@ import io.element.android.features.preferences.impl.tasks.ClearCacheUseCase
import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase import io.element.android.features.preferences.impl.tasks.ComputeCacheSizeUseCase
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.featureflag.api.Feature import io.element.android.libraries.featureflag.api.Feature
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -128,13 +128,13 @@ class DeveloperSettingsPresenter @Inject constructor(
private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<Async<String>>) = launch { private fun CoroutineScope.computeCacheSize(cacheSize: MutableState<Async<String>>) = launch {
suspend { suspend {
computeCacheSizeUseCase() computeCacheSizeUseCase()
}.execute(cacheSize) }.runCatchingUpdatingState(cacheSize)
} }
private fun CoroutineScope.clearCache(clearCacheAction: MutableState<Async<Unit>>) = launch { private fun CoroutineScope.clearCache(clearCacheAction: MutableState<Async<Unit>>) = launch {
suspend { suspend {
clearCacheUseCase() clearCacheUseCase()
}.execute(clearCacheAction) }.runCatchingUpdatingState(clearCacheAction)
} }
} }

View file

@ -23,8 +23,6 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.components.preferences.PreferenceView import io.element.android.libraries.designsystem.components.preferences.PreferenceView

View file

@ -31,7 +31,7 @@ import androidx.compose.runtime.setValue
import androidx.core.net.toUri import androidx.core.net.toUri
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.StateEventType
@ -154,7 +154,7 @@ class RoomDetailsEditPresenter @Inject constructor(
}) })
} }
if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow() if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow()
}.execute(action) }.runCatchingUpdatingState(action)
} }
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> { private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {

View file

@ -27,7 +27,7 @@ import androidx.compose.runtime.setValue
import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource import io.element.android.features.roomdetails.impl.members.RoomMemberListDataSource
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.execute import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
@ -147,7 +147,7 @@ class RoomInviteMembersPresenter @Inject constructor(
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
roomMemberListDataSource.search("").toImmutableList() roomMemberListDataSource.search("").toImmutableList()
} }
}.execute(roomMembers) }.runCatchingUpdatingState(roomMembers)
} }
} }

View file

@ -44,7 +44,6 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import io.element.android.features.roomdetails.impl.R import io.element.android.features.roomdetails.impl.R
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.isLoading
import io.element.android.libraries.designsystem.ElementTextStyles import io.element.android.libraries.designsystem.ElementTextStyles
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
@ -110,7 +109,7 @@ fun RoomMemberListView(
if (!state.isSearchActive) { if (!state.isSearchActive) {
if (state.roomMembers is Async.Success) { if (state.roomMembers is Async.Success) {
RoomMemberList( RoomMemberList(
roomMembers = state.roomMembers.state, roomMembers = state.roomMembers.data,
showMembersCount = true, showMembersCount = true,
onUserSelected = ::onUserSelected onUserSelected = ::onUserSelected
) )

View file

@ -55,8 +55,8 @@ class RoomMemberListPresenterTests {
val loadedState = awaitItem() val loadedState = awaitItem()
Truth.assertThat(loadedState.roomMembers).isInstanceOf(Async.Success::class.java) Truth.assertThat(loadedState.roomMembers).isInstanceOf(Async.Success::class.java)
Truth.assertThat((loadedState.roomMembers as Async.Success).state.invited).isEqualTo(listOf(aVictor(), aWalter())) Truth.assertThat((loadedState.roomMembers as Async.Success).data.invited).isEqualTo(listOf(aVictor(), aWalter()))
Truth.assertThat((loadedState.roomMembers as Async.Success).state.joined).isNotEmpty() Truth.assertThat((loadedState.roomMembers as Async.Success).data.joined).isNotEmpty()
} }
} }

View file

@ -26,4 +26,8 @@ dependencies {
api(libs.dagger) api(libs.dagger)
api(libs.appyx.core) api(libs.appyx.core)
api(libs.androidx.lifecycle.runtime) api(libs.androidx.lifecycle.runtime)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.test.truth)
} }

View file

@ -18,51 +18,152 @@ package io.element.android.libraries.architecture
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract
/**
* Sealed type that allows to model an asynchronous operation.
*/
@Stable @Stable
sealed interface Async<out T> { sealed interface Async<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,
) : Async<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,
) : Async<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,
) : Async<T>
/**
* Represents an uninitialized operation (i.e. yet to be run).
*/
object Uninitialized : Async<Nothing> object Uninitialized : Async<Nothing>
data class Loading<out T>(val prevState: T? = null) : Async<T>
data class Failure<out T>(val error: Throwable, val prevState: T? = null) : Async<T>
data class Success<out T>(val state: T) : Async<T>
fun dataOrNull(): T? { /**
return when (this) { * Returns the data returned by the operation, or null otherwise.
is Failure -> prevState *
is Loading -> prevState * Please note this method may return stale data if the operation is not [Success].
is Success -> state */
Uninitialized -> null 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
}
suspend inline fun <T> MutableState<Async<T>>.runCatchingUpdatingState(
errorTransform: (Throwable) -> Throwable = { it },
block: () -> T,
): Result<T> = runUpdatingState(
state = this,
errorTransform = errorTransform,
resultBlock = {
runCatching {
block()
} }
} },
} )
suspend inline fun <T> (suspend () -> T).execute( suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
state: MutableState<Async<T>>, state: MutableState<Async<T>>,
errorMapping: ((Throwable) -> Throwable) = { it }, errorTransform: (Throwable) -> Throwable = { it },
) { ): Result<T> = runUpdatingState(
try { state = state,
state.value = Async.Loading() errorTransform = errorTransform,
val result = this() resultBlock = {
state.value = Async.Success(result) runCatching {
} catch (error: Throwable) { this()
state.value = Async.Failure(errorMapping.invoke(error)) }
} },
} )
suspend inline fun <T> (suspend () -> Result<T>).executeResult(state: MutableState<Async<T>>) { suspend inline fun <T> MutableState<Async<T>>.runUpdatingState(
if (state.value !is Async.Success) { errorTransform: (Throwable) -> Throwable = { it },
state.value = Async.Loading() 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 [Async] 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].
*/
@OptIn(ExperimentalContracts::class)
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
suspend inline fun <T> runUpdatingState(
state: MutableState<Async<T>>,
errorTransform: (Throwable) -> Throwable = { it },
resultBlock: suspend () -> Result<T>,
): Result<T> {
contract {
callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
} }
this().fold( val prevData = state.value.dataOrNull()
state.value = Async.Loading(prevData = prevData)
return resultBlock().fold(
onSuccess = { onSuccess = {
state.value = Async.Success(it) state.value = Async.Success(it)
Result.success(it)
}, },
onFailure = { onFailure = {
state.value = Async.Failure(it) val error = errorTransform(it)
state.value = Async.Failure(
error = error,
prevData = prevData,
)
Result.failure(error)
} }
) )
} }
fun <T> Async<T>.isLoading(): Boolean {
return this is Async.Loading<T>
}

View file

@ -0,0 +1,113 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
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 AsyncKtTest {
@Test
fun `updates state when block returns success`() = runTest {
val state = TestableMutableState<Async<Int>>(Async.Uninitialized)
val result = runUpdatingState(state) {
delay(1)
Result.success(1)
}
assertThat(result.isSuccess).isTrue()
assertThat(result.getOrNull()).isEqualTo(1)
assertThat(state.popFirst()).isEqualTo(Async.Uninitialized)
assertThat(state.popFirst()).isEqualTo(Async.Loading(null))
assertThat(state.popFirst()).isEqualTo(Async.Success(1))
state.assertNoMoreValues()
}
@Test
fun `updates state when block returns failure`() = runTest {
val state = TestableMutableState<Async<Int>>(Async.Uninitialized)
val result = runUpdatingState(state) {
delay(1)
Result.failure(MyThrowable("hello"))
}
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isEqualTo(MyThrowable("hello"))
assertThat(state.popFirst()).isEqualTo(Async.Uninitialized)
assertThat(state.popFirst()).isEqualTo(Async.Loading(null))
assertThat(state.popFirst()).isEqualTo(Async.Failure<Int>(MyThrowable("hello")))
state.assertNoMoreValues()
}
@Test
fun `updates state when block returns failure transforming the error`() = runTest {
val state = TestableMutableState<Async<Int>>(Async.Uninitialized)
val result = runUpdatingState(state, { MyThrowable(it.message + " world") }) {
delay(1)
Result.failure(MyThrowable("hello"))
}
assertThat(result.isFailure).isTrue()
assertThat(result.exceptionOrNull()).isEqualTo(MyThrowable("hello world"))
assertThat(state.popFirst()).isEqualTo(Async.Uninitialized)
assertThat(state.popFirst()).isEqualTo(Async.Loading(null))
assertThat(state.popFirst()).isEqualTo(Async.Failure<Int>(MyThrowable("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<T>(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 MyThrowable(val myMessage: String) : Throwable(myMessage)