From 294f7f6fae6ae48cc88f53274ff1e205f6c1b68c Mon Sep 17 00:00:00 2001 From: Sylvain Berfini Date: Tue, 21 Nov 2023 17:16:16 +0100 Subject: [PATCH] Added export file to Android's MediaStore from FileViewer --- .../compatibility/Api28Compatibility.kt | 17 ++ .../compatibility/Api29Compatibility.kt | 23 +++ .../linphone/compatibility/Compatibility.kt | 13 ++ .../ui/main/viewer/viewmodel/FileViewModel.kt | 151 ++++++++++++++++++ .../main/java/org/linphone/utils/FileUtils.kt | 36 +++++ .../res/layout-land/file_viewer_fragment.xml | 5 +- .../main/res/layout/file_viewer_fragment.xml | 5 +- 7 files changed, 242 insertions(+), 8 deletions(-) diff --git a/app/src/main/java/org/linphone/compatibility/Api28Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api28Compatibility.kt index 7897de9ee..8175b495b 100644 --- a/app/src/main/java/org/linphone/compatibility/Api28Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api28Compatibility.kt @@ -21,6 +21,8 @@ package org.linphone.compatibility import android.app.Notification import android.app.Service +import android.net.Uri +import android.provider.MediaStore import org.linphone.core.tools.Log class Api28Compatibility { @@ -37,5 +39,20 @@ class Api28Compatibility { Log.e("$TAG Can't start service as foreground! $e") } } + + fun getMediaCollectionUri(isImage: Boolean, isVideo: Boolean, isAudio: Boolean): Uri { + return when { + isImage -> { + MediaStore.Images.Media.getContentUri("external") + } + isVideo -> { + MediaStore.Video.Media.getContentUri("external") + } + isAudio -> { + MediaStore.Audio.Media.getContentUri("external") + } + else -> Uri.EMPTY + } + } } } diff --git a/app/src/main/java/org/linphone/compatibility/Api29Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Api29Compatibility.kt index e0b654d84..de6159120 100644 --- a/app/src/main/java/org/linphone/compatibility/Api29Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Api29Compatibility.kt @@ -21,7 +21,9 @@ package org.linphone.compatibility import android.app.Notification import android.app.Service +import android.net.Uri import android.os.Build +import android.provider.MediaStore import androidx.annotation.RequiresApi import org.linphone.core.tools.Log @@ -46,5 +48,26 @@ class Api29Compatibility { Log.e("$TAG Can't start service as foreground! $e") } } + + fun getMediaCollectionUri(isImage: Boolean, isVideo: Boolean, isAudio: Boolean): Uri { + return when { + isImage -> { + MediaStore.Images.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY + ) + } + isVideo -> { + MediaStore.Video.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY + ) + } + isAudio -> { + MediaStore.Audio.Media.getContentUri( + MediaStore.VOLUME_EXTERNAL_PRIMARY + ) + } + else -> Uri.EMPTY + } + } } } diff --git a/app/src/main/java/org/linphone/compatibility/Compatibility.kt b/app/src/main/java/org/linphone/compatibility/Compatibility.kt index 9db7fb995..83c111a05 100644 --- a/app/src/main/java/org/linphone/compatibility/Compatibility.kt +++ b/app/src/main/java/org/linphone/compatibility/Compatibility.kt @@ -22,6 +22,7 @@ package org.linphone.compatibility import android.annotation.SuppressLint import android.app.Notification import android.app.Service +import android.net.Uri import android.view.View import org.linphone.mediastream.Version @@ -63,5 +64,17 @@ class Compatibility { Api31Compatibility.removeBlurRenderEffect(view) } } + + fun getMediaCollectionUri( + isImage: Boolean = false, + isVideo: Boolean = false, + isAudio: Boolean = false + ): Uri { + return if (Version.sdkAboveOrEqual(Version.API29_ANDROID_10)) { + Api29Compatibility.getMediaCollectionUri(isImage, isVideo, isAudio) + } else { + Api28Compatibility.getMediaCollectionUri(isImage, isVideo, isAudio) + } + } } } diff --git a/app/src/main/java/org/linphone/ui/main/viewer/viewmodel/FileViewModel.kt b/app/src/main/java/org/linphone/ui/main/viewer/viewmodel/FileViewModel.kt index e5b12259b..b509e10a5 100644 --- a/app/src/main/java/org/linphone/ui/main/viewer/viewmodel/FileViewModel.kt +++ b/app/src/main/java/org/linphone/ui/main/viewer/viewmodel/FileViewModel.kt @@ -1,8 +1,13 @@ package org.linphone.ui.main.viewer.viewmodel +import android.content.ContentValues +import android.content.Context import android.graphics.Bitmap import android.graphics.pdf.PdfRenderer +import android.net.Uri +import android.os.Environment import android.os.ParcelFileDescriptor +import android.provider.MediaStore import android.webkit.MimeTypeMap import android.widget.ImageView import androidx.annotation.UiThread @@ -13,7 +18,11 @@ import java.io.File import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.linphone.LinphoneApplication.Companion.coreContext +import org.linphone.R +import org.linphone.compatibility.Compatibility import org.linphone.core.tools.Log +import org.linphone.utils.AppUtils import org.linphone.utils.Event import org.linphone.utils.FileUtils @@ -45,6 +54,8 @@ class FileViewModel @UiThread constructor() : ViewModel() { // Below are required for PDF viewer private lateinit var pdfRenderer: PdfRenderer + private lateinit var filePath: String + var screenWidth: Int = 0 var screenHeight: Int = 0 // End of PDF viewer required variables @@ -58,6 +69,7 @@ class FileViewModel @UiThread constructor() : ViewModel() { @UiThread fun loadFile(file: String) { + filePath = file val name = FileUtils.getNameFromFilePath(file) fileName.value = name @@ -138,4 +150,143 @@ class FileViewModel @UiThread constructor() : ViewModel() { isVideoPlaying.value = playVideo toggleVideoPlayPauseEvent.value = Event(playVideo) } + + @UiThread + fun exportToMediaStore() { + if (::filePath.isInitialized) { + viewModelScope.launch { + withContext(Dispatchers.IO) { + Log.i("$TAG Export file [$filePath] to Android's MediaStore") + if (addContentToMediaStore(filePath)) { + Log.i("$TAG File [$filePath] has been successfully exported to MediaStore") + // TODO: show toast + } else { + Log.e("$TAG Failed to export file [$filePath] to MediaStore!") + // TODO: show toast + } + } + } + } else { + Log.e("$TAG Filepath wasn't initialized!") + } + } + + @UiThread + private suspend fun addContentToMediaStore( + path: String + ): Boolean { + if (path.isEmpty()) { + Log.e("$TAG No file path to export to MediaStore!") + return false + } + + val isImage = FileUtils.isExtensionImage(path) + val isVideo = FileUtils.isExtensionVideo(path) + val isAudio = FileUtils.isExtensionAudio(path) + + val directory = when { + isImage -> Environment.DIRECTORY_PICTURES + isVideo -> Environment.DIRECTORY_MOVIES + isAudio -> Environment.DIRECTORY_MUSIC + else -> Environment.DIRECTORY_DOWNLOADS + } + + val appName = AppUtils.getString(R.string.app_name) + val relativePath = "$directory/$appName" + val fileName = FileUtils.getNameFromFilePath(path) + val extension = FileUtils.getExtensionFromFileName(fileName) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + + val context = coreContext.context + val mediaStoreFilePath = when { + isImage -> { + val values = ContentValues().apply { + put(MediaStore.Images.Media.DISPLAY_NAME, fileName) + put(MediaStore.Images.Media.MIME_TYPE, mime) + put(MediaStore.Images.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Images.Media.IS_PENDING, 1) + } + val collection = Compatibility.getMediaCollectionUri(isImage = true) + addContentValuesToCollection( + context, + path, + collection, + values, + MediaStore.Images.Media.IS_PENDING + ) + } + isVideo -> { + val values = ContentValues().apply { + put(MediaStore.Video.Media.TITLE, fileName) + put(MediaStore.Video.Media.DISPLAY_NAME, fileName) + put(MediaStore.Video.Media.MIME_TYPE, mime) + put(MediaStore.Video.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Video.Media.IS_PENDING, 1) + } + val collection = Compatibility.getMediaCollectionUri(isVideo = true) + addContentValuesToCollection( + context, + path, + collection, + values, + MediaStore.Video.Media.IS_PENDING + ) + } + isAudio -> { + val values = ContentValues().apply { + put(MediaStore.Audio.Media.TITLE, fileName) + put(MediaStore.Audio.Media.DISPLAY_NAME, fileName) + put(MediaStore.Audio.Media.MIME_TYPE, mime) + put(MediaStore.Audio.Media.RELATIVE_PATH, relativePath) + put(MediaStore.Audio.Media.IS_PENDING, 1) + } + val collection = Compatibility.getMediaCollectionUri(isAudio = true) + addContentValuesToCollection( + context, + path, + collection, + values, + MediaStore.Audio.Media.IS_PENDING + ) + } + else -> "" + } + + if (mediaStoreFilePath.isNotEmpty()) { + Log.i("$TAG Exported file path to MediaStore is: $mediaStoreFilePath") + return true + } + + return false + } + + @UiThread + private suspend fun addContentValuesToCollection( + context: Context, + filePath: String, + collection: Uri, + values: ContentValues, + pendingKey: String + ): String { + try { + val fileUri = context.contentResolver.insert(collection, values) + if (fileUri == null) { + Log.e("$TAG Failed to get a URI to where store the file, aborting") + return "" + } + + context.contentResolver.openOutputStream(fileUri).use { out -> + if (FileUtils.copyFileTo(filePath, out)) { + values.clear() + values.put(pendingKey, 0) + context.contentResolver.update(fileUri, values, null, null) + + return fileUri.toString() + } + } + } catch (e: Exception) { + Log.e("$TAG Exception: $e") + } + return "" + } } diff --git a/app/src/main/java/org/linphone/utils/FileUtils.kt b/app/src/main/java/org/linphone/utils/FileUtils.kt index 3c6f996cf..557b35ba8 100644 --- a/app/src/main/java/org/linphone/utils/FileUtils.kt +++ b/app/src/main/java/org/linphone/utils/FileUtils.kt @@ -35,6 +35,7 @@ import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.IOException +import java.io.OutputStream import java.util.Locale import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -74,6 +75,13 @@ class FileUtils { return getMimeType(type) == MimeType.Video } + @AnyThread + fun isExtensionAudio(path: String): Boolean { + val extension = getExtensionFromFileName(path) + val type = MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension) + return getMimeType(type) == MimeType.Audio + } + @AnyThread fun getExtensionFromFileName(fileName: String): String { var extension = MimeTypeMap.getFileExtensionFromUrl(fileName) @@ -251,6 +259,34 @@ class FileUtils { return false } + suspend fun copyFileTo(filePath: String, outputStream: OutputStream?): Boolean { + if (outputStream == null) { + Log.e("$TAG Can't copy file $filePath to given null output stream") + return false + } + + val file = File(filePath) + if (!file.exists()) { + Log.e("$TAG Can't copy file $filePath, it doesn't exists") + return false + } + + try { + withContext(Dispatchers.IO) { + val inputStream = FileInputStream(file) + val buffer = ByteArray(4096) + var bytesRead: Int + while (inputStream.read(buffer).also { bytesRead = it } >= 0) { + outputStream.write(buffer, 0, bytesRead) + } + } + return true + } catch (e: IOException) { + Log.e("$TAG copyFileTo exception: $e") + } + return false + } + suspend fun deleteFile(filePath: String) { withContext(Dispatchers.IO) { val file = File(filePath) diff --git a/app/src/main/res/layout-land/file_viewer_fragment.xml b/app/src/main/res/layout-land/file_viewer_fragment.xml index bbc39ccec..f7f97960a 100644 --- a/app/src/main/res/layout-land/file_viewer_fragment.xml +++ b/app/src/main/res/layout-land/file_viewer_fragment.xml @@ -11,9 +11,6 @@ - @@ -130,7 +127,7 @@ - @@ -130,7 +127,7 @@