mirror of
https://github.com/SchildiChat/SchildiChat-android.git
synced 2024-11-24 10:25:51 +03:00
Merge pull request #1608 from vector-im/feature/save_attachement_legacy
Fix / save media on old android
This commit is contained in:
commit
92ecfafa0d
4 changed files with 173 additions and 52 deletions
|
@ -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 🗣:
|
||||||
|
|
|
@ -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() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
|
}
|
||||||
|
|
Loading…
Reference in a new issue