Media: rework how we get the activity context (and fix test compilation)

This commit is contained in:
ganfra 2023-06-05 23:39:48 +02:00
parent 61fc57d3ef
commit 4a71aab95e
6 changed files with 90 additions and 24 deletions

View file

@ -25,6 +25,9 @@ import android.os.Build
import android.os.Environment import android.os.Environment
import android.provider.MediaStore import android.provider.MediaStore
import androidx.annotation.RequiresApi 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.content.FileProvider
import androidx.core.net.toFile import androidx.core.net.toFile
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
@ -46,6 +49,19 @@ class AndroidLocalMediaActions @Inject constructor(
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : LocalMediaActions { ) : 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<Unit> = withContext(coroutineDispatchers.io) { override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatching { runCatching {
@ -61,7 +77,7 @@ class AndroidLocalMediaActions @Inject constructor(
} }
} }
override suspend fun share(activityContext: Context, localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun share(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatching { runCatching {
val shareableUri = localMedia.toShareableUri() val shareableUri = localMedia.toShareableUri()
@ -71,7 +87,7 @@ class AndroidLocalMediaActions @Inject constructor(
.setTypeAndNormalize(localMedia.info.mimeType) .setTypeAndNormalize(localMedia.info.mimeType)
withContext(coroutineDispatchers.main) { withContext(coroutineDispatchers.main) {
val intent = Intent.createChooser(shareMediaIntent, null) val intent = Intent.createChooser(shareMediaIntent, null)
activityContext.startActivity(intent) activityContext!!.startActivity(intent)
} }
}.onSuccess { }.onSuccess {
Timber.v("Share media succeed") Timber.v("Share media succeed")
@ -80,14 +96,14 @@ class AndroidLocalMediaActions @Inject constructor(
} }
} }
override suspend fun open(activityContext: Context, localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) { override suspend fun open(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE) require(localMedia.uri.scheme == ContentResolver.SCHEME_FILE)
runCatching { runCatching {
val openMediaIntent = Intent(Intent.ACTION_VIEW) val openMediaIntent = Intent(Intent.ACTION_VIEW)
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) .setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
.setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType) .setDataAndType(localMedia.toShareableUri(), localMedia.info.mimeType)
withContext(coroutineDispatchers.main) { withContext(coroutineDispatchers.main) {
activityContext.startActivity(openMediaIntent) activityContext!!.startActivity(openMediaIntent)
} }
}.onSuccess { }.onSuccess {
Timber.v("Open media succeed") Timber.v("Open media succeed")

View file

@ -16,9 +16,13 @@
package io.element.android.features.messages.impl.media.local package io.element.android.features.messages.impl.media.local
import android.content.Context import androidx.compose.runtime.Composable
interface LocalMediaActions { interface LocalMediaActions {
@Composable
fun Configure()
/** /**
* Will save the current media to the Downloads directory. * Will save the current media to the Downloads directory.
* The [LocalMedia.uri] needs to have a file scheme. * 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. * Will try to find a suitable application to share the media with.
* The [LocalMedia.uri] needs to have a file scheme. * The [LocalMedia.uri] needs to have a file scheme.
*/ */
suspend fun share(activityContext: Context, localMedia: LocalMedia): Result<Unit> suspend fun share(localMedia: LocalMedia): Result<Unit>
/** /**
* Will try to find a suitable application to open the media with. * Will try to find a suitable application to open the media with.
* The [LocalMedia.uri] needs to have a file scheme. * The [LocalMedia.uri] needs to have a file scheme.
*/ */
suspend fun open(activityContext: Context, localMedia: LocalMedia): Result<Unit> suspend fun open(localMedia: LocalMedia): Result<Unit>
} }

View file

@ -17,7 +17,6 @@
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
@ -26,7 +25,6 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
@ -50,7 +48,7 @@ class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs, @Assisted private val inputs: MediaViewerNode.Inputs,
private val localMediaFactory: LocalMediaFactory, private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader, private val mediaLoader: MatrixMediaLoader,
private val mediaActionsHandler: LocalMediaActions, private val localMediaActions: LocalMediaActions,
private val snackbarDispatcher: SnackbarDispatcher, private val snackbarDispatcher: SnackbarDispatcher,
) : Presenter<MediaViewerState> { ) : Presenter<MediaViewerState> {
@ -69,8 +67,8 @@ class MediaViewerPresenter @AssistedInject constructor(
val localMedia: MutableState<Async<LocalMedia>> = remember { val localMedia: MutableState<Async<LocalMedia>> = remember {
mutableStateOf(Async.Uninitialized) mutableStateOf(Async.Uninitialized)
} }
val context = LocalContext.current
val snackbarMessage = handleSnackbarMessage(snackbarDispatcher) val snackbarMessage = handleSnackbarMessage(snackbarDispatcher)
localMediaActions.Configure()
DisposableEffect(loadMediaTrigger) { DisposableEffect(loadMediaTrigger) {
coroutineScope.downloadMedia(mediaFile, localMedia) coroutineScope.downloadMedia(mediaFile, localMedia)
onDispose { onDispose {
@ -83,8 +81,8 @@ class MediaViewerPresenter @AssistedInject constructor(
MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value) MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
MediaViewerEvents.Share -> coroutineScope.share(context, localMedia.value) MediaViewerEvents.Share -> coroutineScope.share(localMedia.value)
MediaViewerEvents.OpenWith -> coroutineScope.open(context, localMedia.value) MediaViewerEvents.OpenWith -> coroutineScope.open(localMedia.value)
} }
} }
@ -121,7 +119,7 @@ class MediaViewerPresenter @AssistedInject constructor(
private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.saveOnDisk(localMedia: Async<LocalMedia>) = launch {
when (localMedia) { when (localMedia) {
is Async.Success -> { is Async.Success -> {
mediaActionsHandler.saveOnDisk(localMedia.state) localMediaActions.saveOnDisk(localMedia.state)
.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,10 +129,10 @@ class MediaViewerPresenter @AssistedInject constructor(
} }
} }
private fun CoroutineScope.share(activityContext: Context, localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.share(localMedia: Async<LocalMedia>) = launch {
when (localMedia) { when (localMedia) {
is Async.Success -> { is Async.Success -> {
mediaActionsHandler.share(activityContext, localMedia.state) localMediaActions.share(localMedia.state)
.onFailure { .onFailure {
val snackbarMessage = SnackbarMessage(openShareError(it)) val snackbarMessage = SnackbarMessage(openShareError(it))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)
@ -144,10 +142,10 @@ class MediaViewerPresenter @AssistedInject constructor(
} }
} }
private fun CoroutineScope.open(activityContext: Context, localMedia: Async<LocalMedia>) = launch { private fun CoroutineScope.open(localMedia: Async<LocalMedia>) = launch {
when (localMedia) { when (localMedia) {
is Async.Success -> { is Async.Success -> {
mediaActionsHandler.open(activityContext, localMedia.state) localMediaActions.open(localMedia.state)
.onFailure { .onFailure {
val snackbarMessage = SnackbarMessage(openShareError(it)) val snackbarMessage = SnackbarMessage(openShareError(it))
snackbarDispatcher.post(snackbarMessage) snackbarDispatcher.post(snackbarMessage)

View file

@ -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<Unit> {
return Result.success(Unit)
}
override suspend fun share(localMedia: LocalMedia): Result<Unit> {
return Result.success(Unit)
}
override suspend fun open(localMedia: LocalMedia): Result<Unit> {
return Result.success(Unit)
}
}

View file

@ -27,7 +27,7 @@ class FakeLocalMediaFactory(private val localMediaUri: Uri) : LocalMediaFactory
var fallbackMimeType: String = MimeTypes.OctetStream 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) return aLocalMedia(uri, mimeType ?: fallbackMimeType)
} }
} }

View file

@ -24,11 +24,14 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat 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.MediaViewerEvents
import io.element.android.features.messages.impl.media.viewer.MediaViewerNode 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.impl.media.viewer.MediaViewerPresenter
import io.element.android.features.messages.media.FakeLocalMediaActions
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.architecture.Async 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.FAKE_DELAY_IN_MS
import io.element.android.libraries.matrix.test.media.FakeMediaLoader import io.element.android.libraries.matrix.test.media.FakeMediaLoader
import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.matrix.test.media.aMediaSource
@ -54,7 +57,7 @@ class MediaViewerPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1) testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS + 1)
@ -74,7 +77,7 @@ class MediaViewerPresenterTest {
mediaLoader.shouldFail = true mediaLoader.shouldFail = true
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized) assertThat(initialState.downloadedMedia).isEqualTo(Async.Uninitialized)
assertThat(initialState.name).isEqualTo(TESTED_MEDIA_NAME) assertThat(initialState.mediaInfo.name).isEqualTo(TESTED_MEDIA_NAME)
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java) assertThat(loadingState.downloadedMedia).isInstanceOf(Async.Loading::class.java)
testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS) testScheduler.advanceTimeBy(FAKE_DELAY_IN_MS)
@ -97,13 +100,17 @@ class MediaViewerPresenterTest {
private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter { private fun aMediaViewerPresenter(mimeType: String = TESTED_MIME_TYPE): MediaViewerPresenter {
return MediaViewerPresenter( return MediaViewerPresenter(
inputs = MediaViewerNode.Inputs( inputs = MediaViewerNode.Inputs(
name = TESTED_MEDIA_NAME, mediaInfo = MediaInfo(name = TESTED_MEDIA_NAME,
mimeType = mimeType,
formattedFileSize = "14MB"
),
mediaSource = aMediaSource(), mediaSource = aMediaSource(),
mimeType = mimeType,
thumbnailSource = null thumbnailSource = null
), ),
localMediaFactory = localMediaFactory, localMediaFactory = localMediaFactory,
mediaLoader = mediaLoader mediaLoader = mediaLoader,
localMediaActions = FakeLocalMediaActions(),
snackbarDispatcher = SnackbarDispatcher()
) )
} }
} }