Merge pull request #1608 from vector-im/feature/save_attachement_legacy

Fix / save media on old android
This commit is contained in:
Benoit Marty 2020-07-04 12:16:59 +02:00 committed by GitHub
commit 92ecfafa0d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
4 changed files with 173 additions and 52 deletions

View file

@ -10,6 +10,8 @@ Improvements 🙌:
Bugfix 🐛: Bugfix 🐛:
- Fix crash when coming from a notification (#1601) - Fix crash when coming from a notification (#1601)
- Fix Exception when importing keys (#1576) - Fix Exception when importing keys (#1576)
- File isn't downloaded when another file with the same name already exists (#1578)
- saved images don't show up in gallery (#1324)
- Fix reply fallback leaking sender locale (#429) - Fix reply fallback leaking sender locale (#429)
Translations 🗣: Translations 🗣:

View file

@ -17,29 +17,39 @@
package im.vector.riotx.core.utils package im.vector.riotx.core.utils
import android.app.Activity import android.app.Activity
import android.app.DownloadManager
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.MediaScannerConnection
import android.net.Uri import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Environment
import android.provider.Browser import android.provider.Browser
import android.provider.MediaStore import android.provider.MediaStore
import android.webkit.MimeTypeMap
import android.widget.Toast import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession import androidx.browser.customtabs.CustomTabsSession
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment import androidx.fragment.app.Fragment
import im.vector.matrix.android.api.extensions.tryThis
import im.vector.riotx.BuildConfig import im.vector.riotx.BuildConfig
import im.vector.riotx.R import im.vector.riotx.R
import im.vector.riotx.features.notifications.NotificationUtils import im.vector.riotx.features.notifications.NotificationUtils
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source import okio.source
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
import java.util.Locale import java.util.Locale
@ -301,42 +311,20 @@ fun shareMedia(context: Context, file: File, mediaMimeType: String?) {
fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String?, notificationUtils: NotificationUtils) { fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String?, notificationUtils: NotificationUtils) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val externalContentUri: Uri val values = ContentValues().apply {
val values = ContentValues() put(MediaStore.Images.Media.TITLE, title)
when { put(MediaStore.Images.Media.DISPLAY_NAME, title)
mediaMimeType?.startsWith("image/") == true -> { put(MediaStore.Images.Media.MIME_TYPE, mediaMimeType)
externalContentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis())
values.put(MediaStore.Images.Media.TITLE, title) put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
values.put(MediaStore.Images.Media.DISPLAY_NAME, title)
values.put(MediaStore.Images.Media.MIME_TYPE, mediaMimeType)
values.put(MediaStore.Images.Media.DATE_ADDED, System.currentTimeMillis())
values.put(MediaStore.Images.Media.DATE_TAKEN, System.currentTimeMillis())
}
mediaMimeType?.startsWith("video/") == true -> {
externalContentUri = MediaStore.Video.Media.EXTERNAL_CONTENT_URI
values.put(MediaStore.Video.Media.TITLE, title)
values.put(MediaStore.Video.Media.DISPLAY_NAME, title)
values.put(MediaStore.Video.Media.MIME_TYPE, mediaMimeType)
values.put(MediaStore.Video.Media.DATE_ADDED, System.currentTimeMillis())
values.put(MediaStore.Video.Media.DATE_TAKEN, System.currentTimeMillis())
}
mediaMimeType?.startsWith("audio/") == true -> {
externalContentUri = MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
values.put(MediaStore.Audio.Media.TITLE, title)
values.put(MediaStore.Audio.Media.DISPLAY_NAME, title)
values.put(MediaStore.Audio.Media.MIME_TYPE, mediaMimeType)
values.put(MediaStore.Audio.Media.DATE_ADDED, System.currentTimeMillis())
values.put(MediaStore.Audio.Media.DATE_TAKEN, System.currentTimeMillis())
}
else -> {
externalContentUri = MediaStore.Downloads.EXTERNAL_CONTENT_URI
values.put(MediaStore.Downloads.TITLE, title)
values.put(MediaStore.Downloads.DISPLAY_NAME, title)
values.put(MediaStore.Downloads.MIME_TYPE, mediaMimeType)
values.put(MediaStore.Downloads.DATE_ADDED, System.currentTimeMillis())
values.put(MediaStore.Downloads.DATE_TAKEN, System.currentTimeMillis())
}
} }
val externalContentUri = when {
mediaMimeType?.startsWith("image/") == true -> MediaStore.Images.Media.EXTERNAL_CONTENT_URI
mediaMimeType?.startsWith("video/") == true -> MediaStore.Video.Media.EXTERNAL_CONTENT_URI
mediaMimeType?.startsWith("audio/") == true -> MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
else -> MediaStore.Downloads.EXTERNAL_CONTENT_URI
}
val uri = context.contentResolver.insert(externalContentUri, values) val uri = context.contentResolver.insert(externalContentUri, values)
if (uri == null) { if (uri == null) {
Toast.makeText(context, R.string.error_saving_media_file, Toast.LENGTH_LONG).show() Toast.makeText(context, R.string.error_saving_media_file, Toast.LENGTH_LONG).show()
@ -357,16 +345,70 @@ fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String
notificationUtils.showNotificationMessage("DL", uri.hashCode(), notification) notificationUtils.showNotificationMessage("DL", uri.hashCode(), notification)
} }
} }
// TODO add notification?
} else { } else {
@Suppress("DEPRECATION") saveMediaLegacy(context, mediaMimeType, title, file)
Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE).also { mediaScanIntent -> }
mediaScanIntent.data = Uri.fromFile(file) }
context.sendBroadcast(mediaScanIntent)
@Suppress("DEPRECATION")
private fun saveMediaLegacy(context: Context, mediaMimeType: String?, title: String, file: File) {
val state = Environment.getExternalStorageState()
if (Environment.MEDIA_MOUNTED != state) {
context.toast(context.getString(R.string.error_saving_media_file))
return
}
GlobalScope.launch(Dispatchers.IO) {
val dest = when {
mediaMimeType?.startsWith("image/") == true -> Environment.DIRECTORY_PICTURES
mediaMimeType?.startsWith("video/") == true -> Environment.DIRECTORY_MOVIES
mediaMimeType?.startsWith("audio/") == true -> Environment.DIRECTORY_MUSIC
else -> Environment.DIRECTORY_DOWNLOADS
}
val downloadDir = Environment.getExternalStoragePublicDirectory(dest)
try {
val outputFilename = if (title.substringAfterLast('.', "").isEmpty()) {
val extension = mediaMimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
"$title.$extension"
} else {
title
}
val savedFile = saveFileIntoLegacy(file, downloadDir, outputFilename)
if (savedFile != null) {
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
downloadManager?.addCompletedDownload(
savedFile.name,
title,
true,
mediaMimeType ?: "application/octet-stream",
savedFile.absolutePath,
savedFile.length(),
true)
addToGallery(savedFile, mediaMimeType, context)
}
} catch (error: Throwable) {
GlobalScope.launch(Dispatchers.Main) {
context.toast(context.getString(R.string.error_saving_media_file))
}
} }
} }
} }
private fun addToGallery(savedFile: File, mediaMimeType: String?, context: Context) {
// MediaScannerConnection provides a way for applications to pass a newly created or downloaded media file to the media scanner service.
var mediaConnection: MediaScannerConnection? = null
val mediaScannerConnectionClient: MediaScannerConnection.MediaScannerConnectionClient = object : MediaScannerConnection.MediaScannerConnectionClient {
override fun onMediaScannerConnected() {
mediaConnection?.scanFile(savedFile.path, mediaMimeType)
}
override fun onScanCompleted(path: String, uri: Uri?) {
if (path == savedFile.path) mediaConnection?.disconnect()
}
}
mediaConnection = MediaScannerConnection(context, mediaScannerConnectionClient).apply { connect() }
}
/** /**
* Open the play store to the provided application Id, default to this app * Open the play store to the provided application Id, default to this app
*/ */
@ -381,3 +423,76 @@ fun openPlayStore(activity: Activity, appId: String = BuildConfig.APPLICATION_ID
} }
} }
} }
// ==============================================================================================================
// Media utils
// ==============================================================================================================
/**
* Copy a file into a dstPath directory.
* The output filename can be provided.
* The output file is not overridden if it is already exist.
*
* ~~ This is copied from the old matrix sdk ~~
*
* @param sourceFile the file source path
* @param dstDirPath the dst path
* @param outputFilename optional the output filename
* @param callback the asynchronous callback
*/
@Suppress("DEPRECATION")
fun saveFileIntoLegacy(sourceFile: File, dstDirPath: File, outputFilename: String?): File? {
// defines another name for the external media
val dstFileName: String
// build a filename is not provided
if (null == outputFilename) {
// extract the file extension from the uri
val dotPos = sourceFile.name.lastIndexOf(".")
var fileExt = ""
if (dotPos > 0) {
fileExt = sourceFile.name.substring(dotPos)
}
dstFileName = "vector_" + System.currentTimeMillis() + fileExt
} else {
dstFileName = outputFilename
}
var dstFile = File(dstDirPath, dstFileName)
// if the file already exists, append a marker
if (dstFile.exists()) {
var baseFileName = dstFileName
var fileExt = ""
val lastDotPos = dstFileName.lastIndexOf(".")
if (lastDotPos > 0) {
baseFileName = dstFileName.substring(0, lastDotPos)
fileExt = dstFileName.substring(lastDotPos)
}
var counter = 1
while (dstFile.exists()) {
dstFile = File(dstDirPath, "$baseFileName($counter)$fileExt")
counter++
}
}
// Copy source file to destination
var inputStream: FileInputStream? = null
var outputStream: FileOutputStream? = null
try {
dstFile.createNewFile()
inputStream = FileInputStream(sourceFile)
outputStream = FileOutputStream(dstFile)
val buffer = ByteArray(1024 * 10)
var len: Int
while (inputStream.read(buffer).also { len = it } != -1) {
outputStream.write(buffer, 0, len)
}
return dstFile
} catch (failure: Throwable) {
return null
} finally {
// Close resources
tryThis { inputStream?.close() }
tryThis { outputStream?.close() }
}
}

View file

@ -22,6 +22,7 @@ import android.content.DialogInterface
import android.content.Intent import android.content.Intent
import android.graphics.Typeface import android.graphics.Typeface
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.text.Spannable import android.text.Spannable
@ -222,6 +223,7 @@ class RoomDetailFragment @Inject constructor(
private const val AUDIO_CALL_PERMISSION_REQUEST_CODE = 1 private const val AUDIO_CALL_PERMISSION_REQUEST_CODE = 1
private const val VIDEO_CALL_PERMISSION_REQUEST_CODE = 2 private const val VIDEO_CALL_PERMISSION_REQUEST_CODE = 2
private const val SAVE_ATTACHEMENT_REQUEST_CODE = 3
/** /**
* Sanitize the display name. * Sanitize the display name.
@ -1194,17 +1196,12 @@ class RoomDetailFragment @Inject constructor(
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (allGranted(grantResults)) { if (allGranted(grantResults)) {
when (requestCode) { when (requestCode) {
// PERMISSION_REQUEST_CODE_DOWNLOAD_FILE -> { SAVE_ATTACHEMENT_REQUEST_CODE -> {
// val action = roomDetailViewModel.pendingAction sharedActionViewModel.pendingAction?.let {
// if (action != null) { handleActions(it)
// (action as? RoomDetailAction.DownloadFile) sharedActionViewModel.pendingAction = null
// ?.messageFileContent }
// ?.getFileName() }
// ?.let { showSnackWithMessage(getString(R.string.downloading_file, it)) }
// roomDetailViewModel.pendingAction = null
// roomDetailViewModel.handle(action)
// }
// }
PERMISSION_REQUEST_CODE_INCOMING_URI -> { PERMISSION_REQUEST_CODE_INCOMING_URI -> {
val pendingUri = roomDetailViewModel.pendingUri val pendingUri = roomDetailViewModel.pendingUri
if (pendingUri != null) { if (pendingUri != null) {
@ -1357,6 +1354,11 @@ class RoomDetailFragment @Inject constructor(
} }
private fun onSaveActionClicked(action: EventSharedAction.Save) { private fun onSaveActionClicked(action: EventSharedAction.Save) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q
&& !checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, SAVE_ATTACHEMENT_REQUEST_CODE)) {
sharedActionViewModel.pendingAction = action
return
}
session.fileService().downloadFile( session.fileService().downloadFile(
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE, downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
id = action.eventId, id = action.eventId,

View file

@ -21,4 +21,6 @@ import javax.inject.Inject
/** /**
* Activity shared view model to handle message actions * Activity shared view model to handle message actions
*/ */
class MessageSharedActionViewModel @Inject constructor() : VectorSharedActionViewModel<EventSharedAction>() class MessageSharedActionViewModel @Inject constructor() : VectorSharedActionViewModel<EventSharedAction>() {
var pendingAction : EventSharedAction? = null
}