diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt index 1efb86d25b..816e3c5acb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/viewer/MediaViewerPresenter.kt @@ -123,6 +123,10 @@ class MediaViewerPresenter @AssistedInject constructor( val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) snackbarDispatcher.post(snackbarMessage) } + .onFailure { + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) + snackbarDispatcher.post(snackbarMessage) + } } else -> Unit } @@ -133,7 +137,7 @@ class MediaViewerPresenter @AssistedInject constructor( is Async.Success -> { localMediaActions.share(localMedia.state) .onFailure { - val snackbarMessage = SnackbarMessage(openShareError(it)) + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) snackbarDispatcher.post(snackbarMessage) } } @@ -146,7 +150,7 @@ class MediaViewerPresenter @AssistedInject constructor( is Async.Success -> { localMediaActions.open(localMedia.state) .onFailure { - val snackbarMessage = SnackbarMessage(openShareError(it)) + val snackbarMessage = SnackbarMessage(mediaActionsError(it)) snackbarDispatcher.post(snackbarMessage) } } @@ -154,7 +158,7 @@ class MediaViewerPresenter @AssistedInject constructor( } } - private fun openShareError(throwable: Throwable): Int { + private fun mediaActionsError(throwable: Throwable): Int { return if (throwable is ActivityNotFoundException) { UtilsR.string.error_no_compatible_app_found } else { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt index 6d4c0a0ce0..25a62e439a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt @@ -19,23 +19,39 @@ package io.element.android.features.messages.media import androidx.compose.runtime.Composable import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMediaActions +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import kotlinx.coroutines.withContext -class FakeLocalMediaActions: LocalMediaActions { +class FakeLocalMediaActions(private val coroutineDispatchers: CoroutineDispatchers) : LocalMediaActions { + + var shouldFail = false @Composable override fun Configure() { //NOOP } - override suspend fun saveOnDisk(localMedia: LocalMedia): Result { - return Result.success(Unit) + override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } } - override suspend fun share(localMedia: LocalMedia): Result { - return Result.success(Unit) + override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } } - override suspend fun open(localMedia: LocalMedia): Result { - return Result.success(Unit) + override suspend fun open(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + if (shouldFail) { + Result.failure(RuntimeException()) + } else { + Result.success(Unit) + } } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt index 79c41e1cde..86c3f3a7a4 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/viewer/MediaViewerPresenterTest.kt @@ -19,7 +19,6 @@ package io.element.android.features.messages.media.viewer import android.net.Uri -import androidx.media3.common.MimeTypes import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test @@ -32,55 +31,110 @@ import io.element.android.features.messages.media.FakeLocalMediaActions import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.utils.SnackbarDispatcher -import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource +import io.element.android.tests.testutils.testCoroutineDispatchers import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Test -private const val TESTED_MIME_TYPE = MimeTypes.IMAGE_JPEG -private const val TESTED_MEDIA_NAME = "MediaName" +private val TESTED_MEDIA_INFO = MediaInfo( + name = "", + mimeType = "", + formattedFileSize = "" +) class MediaViewerPresenterTest { private val mockMediaUri: Uri = mockk("localMediaUri") private val localMediaFactory = FakeLocalMediaFactory(mockMediaUri) - private val mediaLoader = FakeMediaLoader() @Test fun `present - download media success scenario`() = runTest { - val presenter = aMediaViewerPresenter() + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME) - val loadingState = awaitItem() - assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) - testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1) - val successState = awaitItem() - val successData = successState.downloadedMedia.dataOrNull() - assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java) + var state = awaitItem() + assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized) + assertThat(state.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.Loading::class.java) + state = awaitItem() + val successData = state.downloadedMedia.dataOrNull() + assertThat(state.downloadedMedia).isInstanceOf(Async.Success::class.java) assertThat(successData).isNotNull() } } + @Test + fun `present - check all actions `() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) + moleculeFlow(RecompositionClock.Immediate) { + presenter.present() + }.test { + var state = awaitItem() + assertThat(state.downloadedMedia).isEqualTo(Async.Uninitialized) + state = awaitItem() + assertThat(state.downloadedMedia).isInstanceOf(Async.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) + // Should succeed without change of state + state.eventSink(MediaViewerEvents.OpenWith) + // Should succeed without change of state + state.eventSink(MediaViewerEvents.Share) + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + + // Check failures + mediaActions.shouldFail = true + state.eventSink(MediaViewerEvents.OpenWith) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + state.eventSink(MediaViewerEvents.Share) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + state.eventSink(MediaViewerEvents.SaveOnDisk) + state = awaitItem() + assertThat(state.snackbarMessage).isNotNull() + state = awaitItem() + assertThat(state.snackbarMessage).isNull() + } + } + @Test fun `present - download media failure then retry with success scenario`() = runTest { - val presenter = aMediaViewerPresenter() + val coroutineDispatchers = testCoroutineDispatchers(testScheduler, useUnconfinedTestDispatcher = false) + val mediaLoader = FakeMediaLoader(coroutineDispatchers) + val mediaActions = FakeLocalMediaActions(coroutineDispatchers) + val presenter = aMediaViewerPresenter(mediaLoader, mediaActions) moleculeFlow(RecompositionClock.Immediate) { presenter.present() }.test { mediaLoader.shouldFail = true val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME) + assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) val loadingState = awaitItem() assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) - testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) val failureState = awaitItem() assertThat(failureState.downloadedMedia).isInstanceOf(Async.Failure::class.java) mediaLoader.shouldFail = false @@ -89,7 +143,6 @@ class MediaViewerPresenterTest { skipItems(1) val retryLoadingState = awaitItem() assertThat(retryLoadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) - testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) val successState = awaitItem() val successData = successState.downloadedMedia.dataOrNull() assertThat(successState.downloadedMedia).isInstanceOf(Async.Success::class.java) @@ -97,19 +150,19 @@ class MediaViewerPresenterTest { } } - private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter { + private fun aMediaViewerPresenter( + mediaLoader: FakeMediaLoader, + localMediaActions: FakeLocalMediaActions, + ): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerNode.Inputs( - mediaInfo = MediaInfo(name = TESTED_MEDIA_NAME, - mimeType = mimeType, - formattedFileSize = "14MB" - ), + mediaInfo = TESTED_MEDIA_INFO, mediaSource = aMediaSource(), thumbnailSource = null ), localMediaFactory = localMediaFactory, mediaLoader = mediaLoader, - localMediaActions = FakeLocalMediaActions(), + localMediaActions = localMediaActions, snackbarDispatcher = SnackbarDispatcher() ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt index 9a31b92182..35b81ff324 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt @@ -26,7 +26,6 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.res.stringResource -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -60,7 +59,7 @@ fun handleSnackbarMessage( val snackbarMessage by snackbarDispatcher.snackbarMessage.collectAsState(initial = null) LaunchedEffect(snackbarMessage) { if (snackbarMessage != null) { - launch(Dispatchers.Main) { + launch { snackbarDispatcher.clear() } } diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts index 6d9ca1eb8e..4e8893aab6 100644 --- a/libraries/matrix/test/build.gradle.kts +++ b/libraries/matrix/test/build.gradle.kts @@ -26,4 +26,6 @@ dependencies { api(projects.libraries.core) api(projects.libraries.matrix.api) api(libs.coroutines.core) + implementation(libs.coroutines.test) + implementation(projects.tests.testutils) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 85c3555844..79a8186e1a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -16,6 +16,7 @@ package io.element.android.libraries.matrix.test +import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.SessionId @@ -36,15 +37,18 @@ import io.element.android.libraries.matrix.test.pushers.FakePushersService import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.test.StandardTestDispatcher class FakeMatrixClient( override val sessionId: SessionId = A_SESSION_ID, + private val coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), private val userDisplayName: Result = Result.success(A_USER_NAME), private val userAvatarURLString: Result = Result.success(AN_AVATAR_URL), override val roomSummaryDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), override val invitesDataSource: RoomSummaryDataSource = FakeRoomSummaryDataSource(), - override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), + override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(coroutineDispatchers), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val pushersService: FakePushersService = FakePushersService(), private val notificationService: FakeNotificationService = FakeNotificationService(), diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt index 508e6d5da4..4282860c99 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt @@ -16,40 +16,40 @@ package io.element.android.libraries.matrix.test.media +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.coroutines.coroutineContext -class FakeMediaLoader : MatrixMediaLoader { +class FakeMediaLoader(private val coroutineDispatchers: CoroutineDispatchers) : MatrixMediaLoader { var shouldFail = false - override suspend fun loadMediaContent(source: MediaSource): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun loadMediaContent(source: MediaSource): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(ByteArray(0)) + Result.success(ByteArray(0)) } } - override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun loadMediaThumbnail(source: MediaSource, width: Long, height: Long): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(ByteArray(0)) + Result.success(ByteArray(0)) } } - override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result { - delay(FAKE_DELAY_IN_MS) - return if (shouldFail) { + override suspend fun downloadMediaFile(source: MediaSource, mimeType: String?, body: String?): Result = withContext(coroutineDispatchers.io){ + if (shouldFail) { Result.failure(RuntimeException()) } else { - return Result.success(FakeMediaFile("")) + Result.success(FakeMediaFile("")) } } } diff --git a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt index 12481b81e1..62602a475b 100644 --- a/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt +++ b/samples/minimal/src/main/kotlin/io/element/android/samples/minimal/MainActivity.kt @@ -40,6 +40,7 @@ class MainActivity : ComponentActivity() { val baseDirectory = File(applicationContext.filesDir, "sessions") RustMatrixAuthenticationService( + context = applicationContext, baseDirectory = baseDirectory, coroutineScope = Singleton.appScope, coroutineDispatchers = Singleton.coroutineDispatchers, diff --git a/tests/testutils/build.gradle.kts b/tests/testutils/build.gradle.kts index fda44f46d1..d7c17c7895 100644 --- a/tests/testutils/build.gradle.kts +++ b/tests/testutils/build.gradle.kts @@ -28,12 +28,6 @@ android { dependencies { implementation(libs.test.junit) - implementation(libs.test.mockk) - implementation(libs.test.truth) - implementation(libs.test.turbine) implementation(libs.coroutines.test) - implementation(projects.libraries.matrix.test) - implementation(projects.services.appnavstate.test) - implementation(projects.services.appnavstate.test) implementation(projects.libraries.core) }