Media viewer: start adding save on disk action

This commit is contained in:
ganfra 2023-06-01 22:01:05 +02:00
parent be45feac43
commit c0470d4bb5
6 changed files with 168 additions and 2 deletions

View file

@ -0,0 +1,94 @@
/*
* 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.impl.media.local
import android.content.ContentResolver
import android.content.ContentValues
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileOutputStream
import java.io.InputStream
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class AndroidLocalMediaActionsHandler @Inject constructor(
@ApplicationContext private val context: Context,
private val coroutineDispatchers: CoroutineDispatchers,
) : LocalMediaActionsHandler {
override suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
saveOnDiskUsingMediaStore(localMedia)
} else {
saveOnDiskUsingExternalStorageApi(localMedia)
}
}
}
override suspend fun share(localMedia: LocalMedia): Result<Unit> {
TODO("Not yet implemented")
}
@RequiresApi(Build.VERSION_CODES.Q)
private fun saveOnDiskUsingMediaStore(localMedia: LocalMedia) {
val contentValues = ContentValues().apply {
put(MediaStore.MediaColumns.DISPLAY_NAME, localMedia.name)
put(MediaStore.MediaColumns.MIME_TYPE, localMedia.mimeType)
put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS)
}
val resolver = context.contentResolver
val uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
if (uri != null) {
localMedia.openStream(resolver)?.use { input ->
resolver.openOutputStream(uri).use { output ->
input.copyTo(output!!, DEFAULT_BUFFER_SIZE)
}
}
}
}
private fun saveOnDiskUsingExternalStorageApi(localMedia: LocalMedia) {
val target = File(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
localMedia.name ?: ""
)
localMedia.openStream(context.contentResolver)?.use { input ->
FileOutputStream(target).use { output ->
input.copyTo(output)
}
}
}
private fun LocalMedia.openStream(contentResolver: ContentResolver): InputStream? {
return when (val model = model) {
is File -> model.inputStream()
is Uri -> contentResolver.openInputStream(model)
else -> null
}
}
}

View file

@ -0,0 +1,23 @@
/*
* 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.impl.media.local
interface LocalMediaActionsHandler {
suspend fun saveOnDisk(localMedia: LocalMedia): Result<Unit>
suspend fun share(localMedia: LocalMedia): Result<Unit>
}

View file

@ -17,6 +17,7 @@
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
sealed interface MediaViewerEvents { sealed interface MediaViewerEvents {
object SaveOnDisk: MediaViewerEvents
object RetryLoading : MediaViewerEvents object RetryLoading : MediaViewerEvents
object ClearLoadingError : MediaViewerEvents object ClearLoadingError : MediaViewerEvents
} }

View file

@ -54,7 +54,8 @@ class MediaViewerNode @AssistedInject constructor(
val state = presenter.present() val state = presenter.present()
MediaViewerView( MediaViewerView(
state = state, state = state,
modifier = modifier modifier = modifier,
onBackPressed = this::navigateUp
) )
} }
} }

View file

@ -28,6 +28,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.messages.impl.media.local.LocalMedia import io.element.android.features.messages.impl.media.local.LocalMedia
import io.element.android.features.messages.impl.media.local.LocalMediaActionsHandler
import io.element.android.features.messages.impl.media.local.LocalMediaFactory import io.element.android.features.messages.impl.media.local.LocalMediaFactory
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
@ -40,6 +41,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: LocalMediaActionsHandler,
) : Presenter<MediaViewerState> { ) : Presenter<MediaViewerState> {
@AssistedFactory @AssistedFactory
@ -68,6 +70,7 @@ class MediaViewerPresenter @AssistedInject constructor(
when (mediaViewerEvents) { when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++ MediaViewerEvents.RetryLoading -> loadMediaTrigger++
MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized MediaViewerEvents.ClearLoadingError -> localMedia.value = Async.Uninitialized
MediaViewerEvents.SaveOnDisk -> coroutineScope.saveOnDisk(localMedia.value)
} }
} }
@ -93,4 +96,12 @@ class MediaViewerPresenter @AssistedInject constructor(
localMedia.value = Async.Failure(it) localMedia.value = Async.Failure(it)
} }
} }
private fun CoroutineScope.saveOnDisk(value: Async<LocalMedia>) = launch {
when (value) {
is Async.Success -> mediaActionsHandler.saveOnDisk(value.state)
else -> Unit
}
}
} }

View file

@ -14,6 +14,7 @@
* limitations under the License. * limitations under the License.
*/ */
@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.messages.impl.media.viewer package io.element.android.features.messages.impl.media.viewer
@ -23,6 +24,10 @@ import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Download
import androidx.compose.material.icons.filled.Save
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -39,19 +44,25 @@ import coil.compose.AsyncImage
import io.element.android.features.messages.impl.media.local.LocalMediaView import io.element.android.features.messages.impl.media.local.LocalMediaView
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.architecture.isLoading
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
import io.element.android.libraries.designsystem.modifiers.roundedBackground import io.element.android.libraries.designsystem.modifiers.roundedBackground
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.R.*
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@Composable @Composable
fun MediaViewerView( fun MediaViewerView(
state: MediaViewerState, state: MediaViewerState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
@ -85,7 +96,11 @@ fun MediaViewerView(
showThumbnail = false showThumbnail = false
} }
Scaffold(modifier) { Scaffold(modifier,
topBar = {
MediaViewerTopBar(onBackPressed, state.eventSink)
}
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -113,6 +128,26 @@ fun MediaViewerView(
} }
} }
@Composable
private fun MediaViewerTopBar(
onBackPressed: () -> Unit,
eventSink: (MediaViewerEvents) -> Unit,
) {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackPressed) },
actions = {
IconButton(
onClick = {
eventSink(MediaViewerEvents.SaveOnDisk)
},
) {
Icon(imageVector = Icons.Default.Download, contentDescription = stringResource(id = string.action_save))
}
}
)
}
@Composable @Composable
private fun ThumbnailView( private fun ThumbnailView(
thumbnailSource: MediaSource?, thumbnailSource: MediaSource?,
@ -175,5 +210,6 @@ fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class
private fun ContentToPreview(state: MediaViewerState) { private fun ContentToPreview(state: MediaViewerState) {
MediaViewerView( MediaViewerView(
state = state, state = state,
onBackPressed = {}
) )
} }