diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt index 71bb2ae31c..44bff4f5ab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/AndroidLocalMediaActions.kt @@ -25,6 +25,9 @@ import android.os.Build import android.os.Environment import android.provider.MediaStore import androidx.annotation.RequiresApi +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.platform.LocalContext import androidx.core.content.FileProvider import androidx.core.net.toFile import com.squareup.anvil.annotations.ContributesBinding @@ -46,6 +49,19 @@ class AndroidLocalMediaActions @Inject constructor( private val buildMeta: BuildMeta, ) : LocalMediaActions { + private var activityContext: Context? = null + + @Composable + override fun Configure() { + val context = LocalContext.current + return DisposableEffect(Unit) { + activityContext = context + onDispose { + activityContext = null + } + } + } + override suspend fun saveOnDisk(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { @@ -61,7 +77,7 @@ class AndroidLocalMediaActions @Inject constructor( } } - override suspend fun share(activityContext: Context, localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + override suspend fun share(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { val shareableUri = localMedia.toShareableUri() @@ -71,7 +87,7 @@ class AndroidLocalMediaActions @Inject constructor( .setTypeAndNormalize(localMedia.info.mimeType) withContext(coroutineDispatchers.main) { val intent = Intent.createChooser(shareMediaIntent, null) - activityContext.startActivity(intent) + activityContext!!.startActivity(intent) } }.onSuccess { Timber.v("Share media succeed") @@ -80,14 +96,14 @@ class AndroidLocalMediaActions @Inject constructor( } } - override suspend fun open(activityContext: Context, localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { + override suspend fun open(localMedia: LocalMedia): Result = withContext(coroutineDispatchers.io) { require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) runCatching { val openMediaIntent = Intent(Intent.ACTION_VIEW) .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType) withContext(coroutineDispatchers.main) { - activityContext.startActivity(openMediaIntent) + activityContext!!.startActivity(openMediaIntent) } }.onSuccess { Timber.v("Open media succeed") diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt index 23c029c09f..f35af36057 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaActions.kt @@ -16,9 +16,13 @@ package io.element.android.features.messages.impl.media.local -import android.content.Context +import androidx.compose.runtime.Composable interface LocalMediaActions { + + @Composable + fun Configure() + /** * Will save the current media to the Downloads directory. * The [LocalMedia.uri] needs to have a file scheme. @@ -29,12 +33,12 @@ interface LocalMediaActions { * Will try to find a suitable application to share the media with. * The [LocalMedia.uri] needs to have a file scheme. */ - suspend fun share(activityContext: Context, localMedia: LocalMedia): Result + suspend fun share(localMedia: LocalMedia): Result /** * Will try to find a suitable application to open the media with. * The [LocalMedia.uri] needs to have a file scheme. */ - suspend fun open(activityContext: Context, localMedia: LocalMedia): Result + suspend fun open(localMedia: LocalMedia): Result } 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 7aa2c0bd9b..f762caf329 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 @@ -17,7 +17,6 @@ package io.element.android.features.messages.impl.media.viewer import android.content.ActivityNotFoundException -import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.MutableState @@ -26,7 +25,6 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue -import androidx.compose.ui.platform.LocalContext import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -50,7 +48,7 @@ class MediaViewerPresenter @AssistedInject constructor( @Assisted private val inputs: MediaViewerNode.Inputs, private val localMediaFactory: LocalMediaFactory, private val mediaLoader: MatrixMediaLoader, - private val mediaActionsHandler: LocalMediaActions, + private val localMediaActions: LocalMediaActions, private val snackbarDispatcher: SnackbarDispatcher, ) : Presenter { @@ -69,8 +67,8 @@ class MediaViewerPresenter @AssistedInject constructor( val localMedia: MutableState> = remember { mutableStateOf(Async.Uninitialized) } - val context = LocalContext.current val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) + localMediaActions.Configure() DisposableEffect(loadMediaTrigger) { coroutineScope.downloadMedia(mediaFile, localMedia) onDispose { @@ -83,8 +81,8 @@ class MediaViewerPresenter @AssistedInject constructor( MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) - MediaViewerEvents.Share -> coroutineScope.share(context, localMedia.value) - MediaViewerEvents.OpenWith -> coroutineScope.open(context, localMedia.value) + MediaViewerEvents.Share -> coroutineScope.share(localMedia.value) + MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value) } } @@ -121,7 +119,7 @@ class MediaViewerPresenter @AssistedInject constructor( private fun CoroutineScope.saveOnDisk(localMedia: Async) = launch { when (localMedia) { is Async.Success -> { - mediaActionsHandler.saveOnDisk(localMedia.state) + localMediaActions.saveOnDisk(localMedia.state) .onSuccess { val snackbarMessage = SnackbarMessage(StringR.string.common_file_saved_on_disk_android) snackbarDispatcher.post(snackbarMessage) @@ -131,10 +129,10 @@ class MediaViewerPresenter @AssistedInject constructor( } } - private fun CoroutineScope.share(activityContext: Context, localMedia: Async) = launch { + private fun CoroutineScope.share(localMedia: Async) = launch { when (localMedia) { is Async.Success -> { - mediaActionsHandler.share(activityContext, localMedia.state) + localMediaActions.share(localMedia.state) .onFailure { val snackbarMessage = SnackbarMessage(openShareError(it)) snackbarDispatcher.post(snackbarMessage) @@ -144,10 +142,10 @@ class MediaViewerPresenter @AssistedInject constructor( } } - private fun CoroutineScope.open(activityContext: Context, localMedia: Async) = launch { + private fun CoroutineScope.open(localMedia: Async) = launch { when (localMedia) { is Async.Success -> { - mediaActionsHandler.open(activityContext, localMedia.state) + localMediaActions.open(localMedia.state) .onFailure { val snackbarMessage = SnackbarMessage(openShareError(it)) snackbarDispatcher.post(snackbarMessage) 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 new file mode 100644 index 0000000000..6d4c0a0ce0 --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaActions.kt @@ -0,0 +1,41 @@ +/* + * 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.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 + +class FakeLocalMediaActions: LocalMediaActions { + + @Composable + override fun Configure() { + //NOOP + } + + override suspend fun saveOnDisk(localMedia: LocalMedia): Result { + return Result.success(Unit) + } + + override suspend fun share(localMedia: LocalMedia): Result { + return Result.success(Unit) + } + + override suspend fun open(localMedia: LocalMedia): Result { + return Result.success(Unit) + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt index 1f7dda5f34..b13b2bc509 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/media/FakeLocalMediaFactory.kt @@ -27,7 +27,7 @@ class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory var fallbackMimeType: String = MimeTypes.OctetStream - override fun createFromUri(uri: Uri, mimeType: String?, name: String?): LocalMedia { + override fun createFromUri(uri: Uri, mimeType: String?, name: String?, formattedFileSize: String?): LocalMedia { return aLocalMedia(uri, mimeType ?: fallbackMimeType) } } 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 1ef8097d4a..145ca3d486 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 @@ -24,11 +24,14 @@ import app.cash.molecule.RecompositionClock import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat +import io.element.android.features.messages.impl.media.local.MediaInfo import io.element.android.features.messages.impl.media.viewer.MediaViewerEvents import io.element.android.features.messages.impl.media.viewer.MediaViewerNode import io.element.android.features.messages.impl.media.viewer.MediaViewerPresenter +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 @@ -54,7 +57,7 @@ class MediaViewerPresenterTest { }.test { val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) + 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) @@ -74,7 +77,7 @@ class MediaViewerPresenterTest { mediaLoader.shouldFail = true val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) - assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) + 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) @@ -97,13 +100,17 @@ class MediaViewerPresenterTest { private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter { return MediaViewerPresenter( inputs = MediaViewerNode.Inputs( - name = TESTED_MEDIA_NAME, + mediaInfo = MediaInfo(name = TESTED_MEDIA_NAME, + mimeType = mimeType, + formattedFileSize = "14MB" + ), mediaSource = aMediaSource(), - mimeType = mimeType, thumbnailSource = null ), localMediaFactory = localMediaFactory, - mediaLoader = mediaLoader + mediaLoader = mediaLoader, + localMediaActions = FakeLocalMediaActions(), + snackbarDispatcher = SnackbarDispatcher() ) } }