Media viewer: start adding save on disk action
This commit is contained in:
parent
470ad9f968
commit
bc35db3ffd
6 changed files with 168 additions and 2 deletions
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,7 +54,8 @@ class MediaViewerNode @AssistedInject constructor(
|
|||
val state = presenter.present()
|
||||
MediaViewerView(
|
||||
state = state,
|
||||
modifier = modifier
|
||||
modifier = modifier,
|
||||
onBackPressed = this::navigateUp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue