Merge branch 'develop' into feature/fga/invite_user_loader

This commit is contained in:
ganfra 2024-01-05 14:02:58 +01:00
commit 2a240d2496
496 changed files with 1390 additions and 1180 deletions

View file

@ -0,0 +1,162 @@
/*
* 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 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 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.
*/
data object Confirming : AsyncAction<Nothing>
/**
* 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 is Loading
fun isFailure(): Boolean = this is Failure
fun isSuccess(): Boolean = this is Success
}
suspend inline fun <T> MutableState<AsyncAction<T>>.runCatchingUpdatingState(
errorTransform: (Throwable) -> Throwable = { it },
block: () -> T,
): Result<T> = runUpdatingState(
state = this,
errorTransform = errorTransform,
resultBlock = {
runCatching {
block()
}
},
)
suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
state: MutableState<AsyncAction<T>>,
errorTransform: (Throwable) -> Throwable = { it },
): Result<T> = runUpdatingState(
state = state,
errorTransform = errorTransform,
resultBlock = {
runCatching {
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,
)
/**
* 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].
*/
@OptIn(ExperimentalContracts::class)
@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> {
contract {
callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
}
state.value = AsyncAction.Loading
return resultBlock().fold(
onSuccess = {
state.value = AsyncAction.Success(it)
Result.success(it)
},
onFailure = {
val error = errorTransform(it)
state.value = AsyncAction.Failure(
error = error,
)
Result.failure(error)
}
)
}

View file

@ -26,7 +26,7 @@ import kotlin.contracts.contract
* Sealed type that allows to model an asynchronous operation.
*/
@Stable
sealed interface Async<out T> {
sealed interface AsyncData<out T> {
/**
* Represents a failed operation.
@ -38,7 +38,7 @@ sealed interface Async<out T> {
data class Failure<out T>(
val error: Throwable,
val prevData: T? = null,
) : Async<T>
) : AsyncData<T>
/**
* Represents an operation that is currently ongoing.
@ -48,7 +48,7 @@ sealed interface Async<out T> {
*/
data class Loading<out T>(
val prevData: T? = null,
) : Async<T>
) : AsyncData<T>
/**
* Represents a successful operation.
@ -58,12 +58,12 @@ sealed interface Async<out T> {
*/
data class Success<out T>(
val data: T,
) : Async<T>
) : AsyncData<T>
/**
* Represents an uninitialized operation (i.e. yet to be run).
*/
data object Uninitialized : Async<Nothing>
data object Uninitialized : AsyncData<Nothing>
/**
* Returns the data returned by the operation, or null otherwise.
@ -94,7 +94,7 @@ sealed interface Async<out T> {
fun isUninitialized(): Boolean = this == Uninitialized
}
suspend inline fun <T> MutableState<Async<T>>.runCatchingUpdatingState(
suspend inline fun <T> MutableState<AsyncData<T>>.runCatchingUpdatingState(
errorTransform: (Throwable) -> Throwable = { it },
block: () -> T,
): Result<T> = runUpdatingState(
@ -108,7 +108,7 @@ suspend inline fun <T> MutableState<Async<T>>.runCatchingUpdatingState(
)
suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
state: MutableState<Async<T>>,
state: MutableState<AsyncData<T>>,
errorTransform: (Throwable) -> Throwable = { it },
): Result<T> = runUpdatingState(
state = state,
@ -120,7 +120,7 @@ suspend inline fun <T> (suspend () -> T).runCatchingUpdatingState(
},
)
suspend inline fun <T> MutableState<Async<T>>.runUpdatingState(
suspend inline fun <T> MutableState<AsyncData<T>>.runUpdatingState(
errorTransform: (Throwable) -> Throwable = { it },
resultBlock: () -> Result<T>,
): Result<T> = runUpdatingState(
@ -131,7 +131,7 @@ suspend inline fun <T> MutableState<Async<T>>.runUpdatingState(
/**
* Calls the specified [Result]-returning function [resultBlock]
* encapsulating its progress and return value into an [Async] while
* 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.
@ -143,7 +143,7 @@ suspend inline fun <T> MutableState<Async<T>>.runUpdatingState(
@OptIn(ExperimentalContracts::class)
@Suppress("REDUNDANT_INLINE_SUSPEND_FUNCTION_TYPE")
suspend inline fun <T> runUpdatingState(
state: MutableState<Async<T>>,
state: MutableState<AsyncData<T>>,
errorTransform: (Throwable) -> Throwable = { it },
resultBlock: suspend () -> Result<T>,
): Result<T> {
@ -151,15 +151,15 @@ suspend inline fun <T> runUpdatingState(
callsInPlace(resultBlock, InvocationKind.EXACTLY_ONCE)
}
val prevData = state.value.dataOrNull()
state.value = Async.Loading(prevData = prevData)
state.value = AsyncData.Loading(prevData = prevData)
return resultBlock().fold(
onSuccess = {
state.value = Async.Success(it)
state.value = AsyncData.Success(it)
Result.success(it)
},
onFailure = {
val error = errorTransform(it)
state.value = Async.Failure(
state.value = AsyncData.Failure(
error = error,
prevData = prevData,
)

View file

@ -22,10 +22,10 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.test.runTest
import org.junit.Test
class AsyncKtTest {
class AsyncDataKtTest {
@Test
fun `updates state when block returns success`() = runTest {
val state = TestableMutableState<Async<Int>>(Async.Uninitialized)
val state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
val result = runUpdatingState(state) {
delay(1)
@ -35,15 +35,15 @@ class AsyncKtTest {
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))
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<Async<Int>>(Async.Uninitialized)
val state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
val result = runUpdatingState(state) {
delay(1)
@ -53,15 +53,15 @@ class AsyncKtTest {
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")))
assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized)
assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null))
assertThat(state.popFirst()).isEqualTo(AsyncData.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 state = TestableMutableState<AsyncData<Int>>(AsyncData.Uninitialized)
val result = runUpdatingState(state, { MyThrowable(it.message + " world") }) {
delay(1)
@ -71,9 +71,9 @@ class AsyncKtTest {
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")))
assertThat(state.popFirst()).isEqualTo(AsyncData.Uninitialized)
assertThat(state.popFirst()).isEqualTo(AsyncData.Loading(null))
assertThat(state.popFirst()).isEqualTo(AsyncData.Failure<Int>(MyThrowable("hello world")))
state.assertNoMoreValues()
}
}

View file

@ -17,14 +17,15 @@
package io.element.android.libraries.designsystem.components.async
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncAction
open class AsyncProvider : PreviewParameterProvider<Async<Unit>> {
override val values: Sequence<Async<Unit>>
open class AsyncActionProvider : PreviewParameterProvider<AsyncAction<Unit>> {
override val values: Sequence<AsyncAction<Unit>>
get() = sequenceOf(
Async.Uninitialized,
Async.Loading(),
Async.Failure(Exception("An error occurred")),
Async.Success(Unit),
AsyncAction.Uninitialized,
AsyncAction.Confirming,
AsyncAction.Loading,
AsyncAction.Failure(Exception("An error occurred")),
AsyncAction.Success(Unit),
)
}

View file

@ -19,8 +19,9 @@ package io.element.android.libraries.designsystem.components.async
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialogDefaults
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@ -28,52 +29,28 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
/**
* Render an Async object.
* Render an AsyncAction object.
* - If Success, invoke the callback [onSuccess], only once.
* - If Failure, display a dialog with the error, which can be transformed, using [errorMessage]. When
* closed, [onErrorDismiss] will be invoked. If [onRetry] is not null, a retry button will be displayed.
* - When loading, display a loading dialog, if [showProgressDialog] is true, with on optional [progressText].
* - When loading, display a loading dialog using [progressDialog]. Pass empty lambda to disable.
*/
@Composable
fun <T> AsyncView(
async: Async<T>,
fun <T> AsyncActionView(
async: AsyncAction<T>,
onSuccess: (T) -> Unit,
onErrorDismiss: () -> Unit,
showProgressDialog: Boolean = true,
progressText: String? = null,
errorTitle: @Composable (Throwable) -> String = { ErrorDialogDefaults.title },
errorMessage: @Composable (Throwable) -> String = { it.message ?: it.toString() },
onRetry: (() -> Unit)? = null,
) {
AsyncView(
async = async,
onSuccess = onSuccess,
onErrorDismiss = onErrorDismiss,
progressDialog = {
if (showProgressDialog) {
AsyncViewDefaults.ProgressDialog(progressText)
}
},
errorTitle = errorTitle,
errorMessage = errorMessage,
onRetry = onRetry,
)
}
@Composable
fun <T> AsyncView(
async: Async<T>,
onSuccess: (T) -> Unit,
onErrorDismiss: () -> Unit,
progressDialog: @Composable () -> Unit = { AsyncViewDefaults.ProgressDialog() },
confirmationDialog: @Composable () -> Unit = { },
progressDialog: @Composable () -> Unit = { AsyncActionViewDefaults.ProgressDialog() },
errorTitle: @Composable (Throwable) -> String = { ErrorDialogDefaults.title },
errorMessage: @Composable (Throwable) -> String = { it.message ?: it.toString() },
onRetry: (() -> Unit)? = null,
) {
when (async) {
Async.Uninitialized -> Unit
is Async.Loading -> progressDialog()
is Async.Failure -> {
AsyncAction.Uninitialized -> Unit
AsyncAction.Confirming -> confirmationDialog()
is AsyncAction.Loading -> progressDialog()
is AsyncAction.Failure -> {
if (onRetry == null) {
ErrorDialog(
title = errorTitle(async.error),
@ -89,7 +66,7 @@ fun <T> AsyncView(
)
}
}
is Async.Success -> {
is AsyncAction.Success -> {
LaunchedEffect(async) {
onSuccess(async.data)
}
@ -97,7 +74,7 @@ fun <T> AsyncView(
}
}
object AsyncViewDefaults {
object AsyncActionViewDefaults {
@Composable
fun ProgressDialog(progressText: String? = null) {
ProgressDialog(
@ -108,12 +85,20 @@ object AsyncViewDefaults {
@PreviewsDayNight
@Composable
internal fun AsyncViewPreview(
@PreviewParameter(AsyncProvider::class) async: Async<Unit>,
internal fun AsyncActionViewPreview(
@PreviewParameter(AsyncActionProvider::class) async: AsyncAction<Unit>,
) = ElementPreview {
AsyncView(
AsyncActionView(
async = async,
onSuccess = {},
onErrorDismiss = {},
confirmationDialog = {
ConfirmationDialog(
title = "Confirmation",
content = "Are you sure?",
onSubmitClicked = {},
onDismiss = {},
)
},
)
}

View file

@ -63,47 +63,52 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
private val stateContentFormatter: StateContentFormatter,
) : RoomLastMessageFormatter {
companion object {
// Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105
private const val MAX_SAFE_LENGTH = 500
}
override fun format(event: EventTimelineItem, isDmRoom: Boolean): CharSequence? {
val isOutgoing = event.isOwn
val senderDisplayName = (event.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: event.sender.value
return when (val content = event.content) {
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
is MessageContent -> processMessageContents(content, senderDisplayName, isDmRoom)
RedactedContent -> {
val message = sp.getString(CommonStrings.common_message_removed)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
}
}
}
is StickerContent -> {
content.body
}
is UnableToDecryptContent -> {
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
is StickerContent -> {
content.body
}
}
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
}
is PollContent -> {
val message = sp.getString(CommonStrings.common_poll_summary, content.question)
prefixIfNeeded(message, senderDisplayName, isDmRoom)
}
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom)
}
}
is UnableToDecryptContent -> {
val message = sp.getString(CommonStrings.common_waiting_for_decryption_key)
if (!isDmRoom) {
prefix(message, senderDisplayName)
} else {
message
}
}
is RoomMembershipContent -> {
roomMembershipContentFormatter.format(content, senderDisplayName, isOutgoing)
}
is ProfileChangeContent -> {
profileChangeContentFormatter.format(content, senderDisplayName, isOutgoing)
}
is StateContent -> {
stateContentFormatter.format(content, senderDisplayName, isOutgoing, RenderingMode.RoomList)
}
is PollContent -> {
val message = sp.getString(CommonStrings.common_poll_summary, content.question)
prefixIfNeeded(message, senderDisplayName, isDmRoom)
}
is FailedToParseMessageLikeContent, is FailedToParseStateContent, is UnknownContent -> {
prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisplayName, isDmRoom)
}
}?.take(MAX_SAFE_LENGTH)
}
private fun processMessageContents(messageContent: MessageContent, senderDisplayName: String, isDmRoom: Boolean): CharSequence? {

View file

@ -39,6 +39,8 @@
<string name="state_event_room_name_changed_by_you">"Změnili jste název místnosti na: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s odstranil(a) název místnosti"</string>
<string name="state_event_room_name_removed_by_you">"Odstranili jste název místnosti"</string>
<string name="state_event_room_none">"%1$s neprovedl(a) žádné změny"</string>
<string name="state_event_room_none_by_you">"Neprovedli jste žádné změny"</string>
<string name="state_event_room_reject">"%1$s pozvánku odmítl(a)"</string>
<string name="state_event_room_reject_by_you">"Odmítli jste pozvání"</string>
<string name="state_event_room_remove">"%1$s odebral(a) %2$s"</string>

View file

@ -39,6 +39,8 @@
<string name="state_event_room_name_changed_by_you">"Vous avez changé le nom du salon en : %1$s"</string>
<string name="state_event_room_name_removed">"%1$s a supprimé le nom du salon"</string>
<string name="state_event_room_name_removed_by_you">"Vous avez supprimé le nom du salon"</string>
<string name="state_event_room_none">"%1$s na fait aucun changement visible"</string>
<string name="state_event_room_none_by_you">"Vous navez fait aucun changement visible"</string>
<string name="state_event_room_reject">"%1$s a rejeté linvitation"</string>
<string name="state_event_room_reject_by_you">"Vous avez refusé linvitation"</string>
<string name="state_event_room_remove">"%1$s a supprimé %2$s"</string>

View file

@ -39,6 +39,8 @@
<string name="state_event_room_name_changed_by_you">"Вы изменили название комнаты на: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s удалил название комнаты"</string>
<string name="state_event_room_name_removed_by_you">"Вы удалили название комнаты"</string>
<string name="state_event_room_none">"%1$s ничего не изменилось"</string>
<string name="state_event_room_none_by_you">"Вы не внесли никаких изменений"</string>
<string name="state_event_room_reject">"%1$s отклонил приглашение"</string>
<string name="state_event_room_reject_by_you">"Вы отклонили приглашение"</string>
<string name="state_event_room_remove">"%1$s удалил %2$s"</string>

View file

@ -39,6 +39,8 @@
<string name="state_event_room_name_changed_by_you">"Zmenili ste názov miestnosti na: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s odstránil/a názov miestnosti"</string>
<string name="state_event_room_name_removed_by_you">"Odstránili ste názov miestnosti"</string>
<string name="state_event_room_none">"%1$s nevykonal/a žiadne zmeny"</string>
<string name="state_event_room_none_by_you">"Nevykonali ste žiadne zmeny"</string>
<string name="state_event_room_reject">"%1$s odmietol/a pozvánku"</string>
<string name="state_event_room_reject_by_you">"Odmietli ste pozvánku"</string>
<string name="state_event_room_remove">"%1$s odstránil/a %2$s"</string>

View file

@ -32,7 +32,11 @@ import org.jsoup.nodes.Document
* @param prefix if not null, the prefix will be inserted at the beginning of the message.
*/
fun FormattedBody.toHtmlDocument(prefix: String? = null): Document? {
return takeIf { it.format == MessageFormat.HTML }?.body?.let { formattedBody ->
return takeIf { it.format == MessageFormat.HTML }?.body
// Trim whitespace at the end to avoid having wrong rendering of the message.
// We don't trim the start in case it's used as indentation.
?.trimEnd()
?.let { formattedBody ->
val dom = if (prefix != null) {
Jsoup.parse("$prefix $formattedBody")
} else {

View file

@ -29,7 +29,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@ -64,8 +64,8 @@ class MediaViewerPresenter @AssistedInject constructor(
val mediaFile: MutableState<MediaFile?> = remember {
mutableStateOf(null)
}
val localMedia: MutableState<Async<LocalMedia>> = remember {
mutableStateOf(Async.Uninitialized)
val localMedia: MutableState<AsyncData<LocalMedia>> = remember {
mutableStateOf(AsyncData.Uninitialized)
}
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
localMediaActions.Configure()
@ -79,7 +79,7 @@ class MediaViewerPresenter @AssistedInject constructor(
fun handleEvents(mediaViewerEvents: MediaViewerEvents) {
when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
MediaViewerEvents.ClearLoadingError -> localMedia.value = AsyncData.Uninitialized
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
MediaViewerEvents.Share -> coroutineScope.share(localMedia.value)
MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value)
@ -97,8 +97,8 @@ class MediaViewerPresenter @AssistedInject constructor(
)
}
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<Async<LocalMedia>>) = launch {
localMedia.value = Async.Loading()
private fun CoroutineScope.downloadMedia(mediaFile: MutableState<MediaFile?>, localMedia: MutableState<AsyncData<LocalMedia>>) = launch {
localMedia.value = AsyncData.Loading()
mediaLoader.downloadMediaFile(
source = inputs.mediaSource,
mimeType = inputs.mediaInfo.mimeType,
@ -114,15 +114,15 @@ class MediaViewerPresenter @AssistedInject constructor(
)
}
.onSuccess {
localMedia.value = Async.Success(it)
localMedia.value = AsyncData.Success(it)
}
.onFailure {
localMedia.value = Async.Failure(it)
localMedia.value = AsyncData.Failure(it)
}
}
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
private fun CoroutineScope.saveOnDisk(localMedia: AsyncData<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.saveOnDisk(localMedia.data)
.onSuccess {
val snackbarMessage = SnackbarMessage(CommonStrings.common_file_saved_on_disk_android)
@ -135,8 +135,8 @@ class MediaViewerPresenter @AssistedInject constructor(
} else Unit
}
private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
private fun CoroutineScope.share(localMedia: AsyncData<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.share(localMedia.data)
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))
@ -145,8 +145,8 @@ class MediaViewerPresenter @AssistedInject constructor(
} else Unit
}
private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
if (localMedia is Async.Success) {
private fun CoroutineScope.open(localMedia: AsyncData<LocalMedia>) = launch {
if (localMedia is AsyncData.Success) {
localMediaActions.open(localMedia.data)
.onFailure {
val snackbarMessage = SnackbarMessage(mediaActionsError(it))

View file

@ -16,7 +16,7 @@
package io.element.android.libraries.mediaviewer.api.viewer
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
@ -25,7 +25,7 @@ import io.element.android.libraries.mediaviewer.api.local.MediaInfo
data class MediaViewerState(
val mediaInfo: MediaInfo,
val thumbnailSource: MediaSource?,
val downloadedMedia: Async<LocalMedia>,
val downloadedMedia: AsyncData<LocalMedia>,
val snackbarMessage: SnackbarMessage?,
val canDownload: Boolean,
val canShare: Boolean,

View file

@ -18,7 +18,7 @@ package io.element.android.libraries.mediaviewer.api.viewer
import android.net.Uri
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.mediaviewer.api.local.LocalMedia
import io.element.android.libraries.mediaviewer.api.local.MediaInfo
import io.element.android.libraries.mediaviewer.api.local.aFileInfo
@ -31,48 +31,48 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
override val values: Sequence<MediaViewerState>
get() = sequenceOf(
aMediaViewerState(),
aMediaViewerState(Async.Loading()),
aMediaViewerState(Async.Failure(IllegalStateException("error"))),
aMediaViewerState(AsyncData.Loading()),
aMediaViewerState(AsyncData.Failure(IllegalStateException("error"))),
aMediaViewerState(
Async.Success(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageInfo())
),
anImageInfo(),
),
aMediaViewerState(
Async.Success(
AsyncData.Success(
LocalMedia(Uri.EMPTY, aVideoInfo())
),
aVideoInfo(),
),
aMediaViewerState(
Async.Success(
AsyncData.Success(
LocalMedia(Uri.EMPTY, aPdfInfo())
),
aPdfInfo(),
),
aMediaViewerState(
Async.Loading(),
AsyncData.Loading(),
aFileInfo(),
),
aMediaViewerState(
Async.Success(
AsyncData.Success(
LocalMedia(Uri.EMPTY, aFileInfo())
),
aFileInfo(),
),
aMediaViewerState(
Async.Loading(),
AsyncData.Loading(),
anAudioInfo(),
),
aMediaViewerState(
Async.Success(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anAudioInfo())
),
anAudioInfo(),
),
aMediaViewerState(
Async.Success(
AsyncData.Success(
LocalMedia(Uri.EMPTY, anImageInfo())
),
anImageInfo(),
@ -83,7 +83,7 @@ open class MediaViewerStateProvider : PreviewParameterProvider<MediaViewerState>
}
fun aMediaViewerState(
downloadedMedia: Async<LocalMedia> = Async.Uninitialized,
downloadedMedia: AsyncData<LocalMedia> = AsyncData.Uninitialized,
mediaInfo: MediaInfo = anImageInfo(),
canDownload: Boolean = true,
canShare: Boolean = true,

View file

@ -47,7 +47,7 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
@ -93,7 +93,7 @@ fun MediaViewerView(
modifier,
topBar = {
MediaViewerTopBar(
actionsEnabled = state.downloadedMedia is Async.Success,
actionsEnabled = state.downloadedMedia is AsyncData.Success,
mimeType = state.mediaInfo.mimeType,
onBackPressed = onBackPressed,
canDownload = state.canDownload,
@ -121,7 +121,7 @@ fun MediaViewerView(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
if (state.downloadedMedia is Async.Failure) {
if (state.downloadedMedia is AsyncData.Failure) {
ErrorView(
errorMessage = stringResource(id = CommonStrings.error_unknown),
onRetry = ::onRetry,
@ -144,7 +144,7 @@ fun MediaViewerView(
}
@Composable
private fun rememberShowProgress(downloadedMedia: Async<LocalMedia>): Boolean {
private fun rememberShowProgress(downloadedMedia: AsyncData<LocalMedia>): Boolean {
var showProgress by remember {
mutableStateOf(false)
}

View file

@ -23,7 +23,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource
@ -60,13 +60,13 @@ class MediaViewerPresenterTest {
presenter.present()
}.test {
var state = awaitItem()
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
state = awaitItem()
val successData = state.downloadedMedia.dataOrNull()
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(successData).isNotNull()
}
}
@ -81,15 +81,15 @@ class MediaViewerPresenterTest {
presenter.present()
}.test {
var state = awaitItem()
assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(state.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java)
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
// no state changes while media is loading
state.eventSink(MediaViewerEvents.OpenWith)
state.eventSink(MediaViewerEvents.Share)
state.eventSink(MediaViewerEvents.SaveOnDisk)
state = awaitItem()
assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(state.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
// Should succeed without change of state
state.eventSink(MediaViewerEvents.OpenWith)
// Should succeed without change of state
@ -128,21 +128,21 @@ class MediaViewerPresenterTest {
}.test {
mediaLoader.shouldFail = true
val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized)
assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO)
val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val failureState = awaitItem()
assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java)
assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java)
mediaLoader.shouldFail = false
failureState.eventSink(MediaViewerEvents.RetryLoading)
//There is one recomposition because of the retry mechanism
skipItems(1)
val retryLoadingState = awaitItem()
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
assertThat(retryLoadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java)
val successState = awaitItem()
val successData = successState.downloadedMedia.dataOrNull()
assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java)
assertThat(successState.downloadedMedia).isInstanceOf(AsyncData.Success::class.java)
assertThat(successData).isNotNull()
}
}