Media viewer: start adding save on disk action

This commit is contained in:
ganfra 2023-06-01 22:01:05 +02:00
parent 470ad9f968
commit bc35db3ffd
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
sealed interface MediaViewerEvents {
object SaveOnDisk: MediaViewerEvents
object RetryLoading : MediaViewerEvents
object ClearLoadingError : MediaViewerEvents
}

View file

@ -54,7 +54,8 @@ class MediaViewerNode @AssistedInject constructor(
val state = presenter.present()
MediaViewerView(
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.AssistedInject
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.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
@ -40,6 +41,7 @@ class MediaViewerPresenter @AssistedInject constructor(
@Assisted private val inputs: MediaViewerNode.Inputs,
private val localMediaFactory: LocalMediaFactory,
private val mediaLoader: MatrixMediaLoader,
private val mediaActionsHandler: LocalMediaActionsHandler,
) : Presenter<MediaViewerState> {
@AssistedFactory
@ -68,6 +70,7 @@ class MediaViewerPresenter @AssistedInject constructor(
when (mediaViewerEvents) {
MediaViewerEvents.RetryLoading -> loadMediaTrigger++
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)
}
}
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.
*/
@file:OptIn(ExperimentalMaterial3Api::class)
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.fillMaxSize
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.LaunchedEffect
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.libraries.architecture.Async
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.modifiers.roundedBackground
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.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.TopAppBar
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.ui.strings.R.*
import kotlinx.coroutines.delay
import io.element.android.libraries.ui.strings.R as StringR
@Composable
fun MediaViewerView(
state: MediaViewerState,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
@ -85,7 +96,11 @@ fun MediaViewerView(
showThumbnail = false
}
Scaffold(modifier) {
Scaffold(modifier,
topBar = {
MediaViewerTopBar(onBackPressed, state.eventSink)
}
) {
Box(
modifier = Modifier
.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
private fun ThumbnailView(
thumbnailSource: MediaSource?,
@ -175,5 +210,6 @@ fun MediaViewerViewDarkPreview(@PreviewParameter(MediaViewerStateProvider::class
private fun ContentToPreview(state: MediaViewerState) {
MediaViewerView(
state = state,
onBackPressed = {}
)
}