2019-12-19 19:09:40 +01:00
|
|
|
/* Copyright 2019 Tusky Contributors
|
|
|
|
*
|
|
|
|
* This file is a part of Tusky.
|
|
|
|
*
|
|
|
|
* This program is free software; you can redistribute it and/or modify it under the terms of the
|
|
|
|
* GNU General Public License as published by the Free Software Foundation; either version 3 of the
|
|
|
|
* License, or (at your option) any later version.
|
|
|
|
*
|
|
|
|
* Tusky is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even
|
|
|
|
* the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General
|
|
|
|
* Public License for more details.
|
|
|
|
*
|
|
|
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
|
|
|
* see <http://www.gnu.org/licenses>. */
|
|
|
|
|
|
|
|
package com.keylesspalace.tusky.components.compose
|
|
|
|
|
2022-03-28 18:39:05 +02:00
|
|
|
import android.content.ContentResolver
|
2019-12-19 19:09:40 +01:00
|
|
|
import android.content.Context
|
|
|
|
import android.net.Uri
|
|
|
|
import android.os.Environment
|
|
|
|
import android.util.Log
|
|
|
|
import android.webkit.MimeTypeMap
|
|
|
|
import androidx.core.content.FileProvider
|
|
|
|
import androidx.core.net.toUri
|
|
|
|
import com.keylesspalace.tusky.BuildConfig
|
|
|
|
import com.keylesspalace.tusky.R
|
|
|
|
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
2022-05-01 17:16:22 +02:00
|
|
|
import com.keylesspalace.tusky.network.MediaUploadApi
|
2019-12-19 19:09:40 +01:00
|
|
|
import com.keylesspalace.tusky.network.ProgressRequestBody
|
2021-06-28 21:13:24 +02:00
|
|
|
import com.keylesspalace.tusky.util.MEDIA_SIZE_UNKNOWN
|
|
|
|
import com.keylesspalace.tusky.util.getImageSquarePixels
|
|
|
|
import com.keylesspalace.tusky.util.getMediaSize
|
|
|
|
import com.keylesspalace.tusky.util.randomAlphanumericString
|
2022-04-21 18:46:21 +02:00
|
|
|
import kotlinx.coroutines.Dispatchers
|
|
|
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
|
|
|
import kotlinx.coroutines.channels.awaitClose
|
|
|
|
import kotlinx.coroutines.flow.Flow
|
|
|
|
import kotlinx.coroutines.flow.callbackFlow
|
|
|
|
import kotlinx.coroutines.flow.flatMapLatest
|
|
|
|
import kotlinx.coroutines.flow.flow
|
|
|
|
import kotlinx.coroutines.flow.flowOn
|
2019-12-19 19:09:40 +01:00
|
|
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
|
|
|
import okhttp3.MultipartBody
|
|
|
|
import java.io.File
|
2022-03-28 18:39:05 +02:00
|
|
|
import java.io.FileInputStream
|
2019-12-19 19:09:40 +01:00
|
|
|
import java.io.FileOutputStream
|
|
|
|
import java.io.IOException
|
2021-06-28 21:13:24 +02:00
|
|
|
import java.util.Date
|
2022-01-23 20:24:55 +01:00
|
|
|
import javax.inject.Inject
|
2019-12-19 19:09:40 +01:00
|
|
|
|
|
|
|
sealed class UploadEvent {
|
|
|
|
data class ProgressEvent(val percentage: Int) : UploadEvent()
|
2022-02-25 18:57:18 +01:00
|
|
|
data class FinishedEvent(val mediaId: String) : UploadEvent()
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
fun createNewImageFile(context: Context): File {
|
|
|
|
// Create an image file name
|
|
|
|
val randomId = randomAlphanumericString(12)
|
|
|
|
val imageFileName = "Tusky_${randomId}_"
|
|
|
|
val storageDir = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
|
|
|
|
return File.createTempFile(
|
2021-06-28 21:13:24 +02:00
|
|
|
imageFileName, /* prefix */
|
|
|
|
".jpg", /* suffix */
|
|
|
|
storageDir /* directory */
|
2019-12-19 19:09:40 +01:00
|
|
|
)
|
|
|
|
}
|
|
|
|
|
|
|
|
data class PreparedMedia(val type: QueuedMedia.Type, val uri: Uri, val size: Long)
|
|
|
|
|
2020-01-16 19:05:52 +01:00
|
|
|
class AudioSizeException : Exception()
|
2019-12-19 19:09:40 +01:00
|
|
|
class VideoSizeException : Exception()
|
|
|
|
class MediaTypeException : Exception()
|
|
|
|
class CouldNotOpenFileException : Exception()
|
|
|
|
|
2022-01-23 20:24:55 +01:00
|
|
|
class MediaUploader @Inject constructor(
|
2021-06-28 21:13:24 +02:00
|
|
|
private val context: Context,
|
2022-05-01 17:16:22 +02:00
|
|
|
private val mediaUploadApi: MediaUploadApi
|
2022-01-23 20:24:55 +01:00
|
|
|
) {
|
2022-04-21 18:46:21 +02:00
|
|
|
|
|
|
|
@OptIn(ExperimentalCoroutinesApi::class)
|
|
|
|
fun uploadMedia(media: QueuedMedia): Flow<UploadEvent> {
|
|
|
|
return flow {
|
|
|
|
if (shouldResizeMedia(media)) {
|
|
|
|
emit(downsize(media))
|
|
|
|
} else {
|
|
|
|
emit(media)
|
2021-06-28 21:13:24 +02:00
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
}
|
|
|
|
.flatMapLatest { upload(it) }
|
|
|
|
.flowOn(Dispatchers.IO)
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
|
|
|
|
2022-04-21 18:46:21 +02:00
|
|
|
fun prepareMedia(inUri: Uri): PreparedMedia {
|
|
|
|
var mediaSize = MEDIA_SIZE_UNKNOWN
|
|
|
|
var uri = inUri
|
|
|
|
val mimeType: String?
|
2019-12-19 19:09:40 +01:00
|
|
|
|
2022-04-21 18:46:21 +02:00
|
|
|
try {
|
|
|
|
when (inUri.scheme) {
|
|
|
|
ContentResolver.SCHEME_CONTENT -> {
|
2022-03-28 18:39:05 +02:00
|
|
|
|
2022-04-21 18:46:21 +02:00
|
|
|
mimeType = contentResolver.getType(uri)
|
2022-03-28 18:39:05 +02:00
|
|
|
|
2022-04-21 18:46:21 +02:00
|
|
|
val suffix = "." + MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType ?: "tmp")
|
2022-03-28 18:39:05 +02:00
|
|
|
|
2022-04-21 18:46:21 +02:00
|
|
|
contentResolver.openInputStream(inUri).use { input ->
|
|
|
|
if (input == null) {
|
|
|
|
Log.w(TAG, "Media input is null")
|
|
|
|
uri = inUri
|
|
|
|
return@use
|
2022-03-28 18:39:05 +02:00
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
val file = File.createTempFile("randomTemp1", suffix, context.cacheDir)
|
2022-03-28 18:39:05 +02:00
|
|
|
FileOutputStream(file.absoluteFile).use { out ->
|
|
|
|
input.copyTo(out)
|
|
|
|
uri = FileProvider.getUriForFile(
|
|
|
|
context,
|
|
|
|
BuildConfig.APPLICATION_ID + ".fileprovider",
|
|
|
|
file
|
|
|
|
)
|
|
|
|
mediaSize = getMediaSize(contentResolver, uri)
|
|
|
|
}
|
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
}
|
|
|
|
ContentResolver.SCHEME_FILE -> {
|
|
|
|
val path = uri.path
|
|
|
|
if (path == null) {
|
|
|
|
Log.w(TAG, "empty uri path $uri")
|
2022-03-28 18:39:05 +02:00
|
|
|
throw CouldNotOpenFileException()
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
val inputFile = File(path)
|
|
|
|
val suffix = inputFile.name.substringAfterLast('.', "tmp")
|
|
|
|
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suffix)
|
|
|
|
val file = File.createTempFile("randomTemp1", ".$suffix", context.cacheDir)
|
|
|
|
val input = FileInputStream(inputFile)
|
|
|
|
|
|
|
|
FileOutputStream(file.absoluteFile).use { out ->
|
|
|
|
input.copyTo(out)
|
|
|
|
uri = FileProvider.getUriForFile(
|
|
|
|
context,
|
|
|
|
BuildConfig.APPLICATION_ID + ".fileprovider",
|
|
|
|
file
|
|
|
|
)
|
|
|
|
mediaSize = getMediaSize(contentResolver, uri)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
else -> {
|
|
|
|
Log.w(TAG, "Unknown uri scheme $uri")
|
|
|
|
throw CouldNotOpenFileException()
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
} catch (e: IOException) {
|
|
|
|
Log.w(TAG, e)
|
|
|
|
throw CouldNotOpenFileException()
|
|
|
|
}
|
|
|
|
if (mediaSize == MEDIA_SIZE_UNKNOWN) {
|
|
|
|
Log.w(TAG, "Could not determine file size of upload")
|
|
|
|
throw MediaTypeException()
|
|
|
|
}
|
2019-12-19 19:09:40 +01:00
|
|
|
|
2022-04-21 18:46:21 +02:00
|
|
|
if (mimeType != null) {
|
|
|
|
return when (mimeType.substring(0, mimeType.indexOf('/'))) {
|
|
|
|
"video" -> {
|
|
|
|
if (mediaSize > STATUS_VIDEO_SIZE_LIMIT) {
|
|
|
|
throw VideoSizeException()
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
PreparedMedia(QueuedMedia.Type.VIDEO, uri, mediaSize)
|
|
|
|
}
|
|
|
|
"image" -> {
|
|
|
|
PreparedMedia(QueuedMedia.Type.IMAGE, uri, mediaSize)
|
|
|
|
}
|
|
|
|
"audio" -> {
|
|
|
|
if (mediaSize > STATUS_AUDIO_SIZE_LIMIT) {
|
|
|
|
throw AudioSizeException()
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
PreparedMedia(QueuedMedia.Type.AUDIO, uri, mediaSize)
|
|
|
|
}
|
|
|
|
else -> {
|
|
|
|
throw MediaTypeException()
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
} else {
|
|
|
|
Log.w(TAG, "Could not determine mime type of upload")
|
|
|
|
throw MediaTypeException()
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private val contentResolver = context.contentResolver
|
|
|
|
|
2022-04-21 18:46:21 +02:00
|
|
|
private suspend fun upload(media: QueuedMedia): Flow<UploadEvent> {
|
|
|
|
return callbackFlow {
|
2019-12-19 19:09:40 +01:00
|
|
|
var mimeType = contentResolver.getType(media.uri)
|
|
|
|
val map = MimeTypeMap.getSingleton()
|
|
|
|
val fileExtension = map.getExtensionFromMimeType(mimeType)
|
2021-06-28 21:13:24 +02:00
|
|
|
val filename = "%s_%s_%s.%s".format(
|
|
|
|
context.getString(R.string.app_name),
|
|
|
|
Date().time.toString(),
|
|
|
|
randomAlphanumericString(10),
|
|
|
|
fileExtension
|
|
|
|
)
|
2019-12-19 19:09:40 +01:00
|
|
|
|
|
|
|
val stream = contentResolver.openInputStream(media.uri)
|
|
|
|
|
|
|
|
if (mimeType == null) mimeType = "multipart/form-data"
|
|
|
|
|
|
|
|
var lastProgress = -1
|
2021-06-28 21:13:24 +02:00
|
|
|
val fileBody = ProgressRequestBody(
|
2022-04-21 18:46:21 +02:00
|
|
|
stream!!, media.mediaSize,
|
|
|
|
mimeType.toMediaTypeOrNull()!!
|
2021-06-28 21:13:24 +02:00
|
|
|
) { percentage ->
|
2019-12-19 19:09:40 +01:00
|
|
|
if (percentage != lastProgress) {
|
2022-04-21 18:46:21 +02:00
|
|
|
trySend(UploadEvent.ProgressEvent(percentage))
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
|
|
|
lastProgress = percentage
|
|
|
|
}
|
|
|
|
|
|
|
|
val body = MultipartBody.Part.createFormData("file", filename, fileBody)
|
|
|
|
|
2021-01-21 18:57:09 +01:00
|
|
|
val description = if (media.description != null) {
|
|
|
|
MultipartBody.Part.createFormData("description", media.description)
|
|
|
|
} else {
|
|
|
|
null
|
|
|
|
}
|
|
|
|
|
2022-05-01 17:16:22 +02:00
|
|
|
val result = mediaUploadApi.uploadMedia(body, description).getOrThrow()
|
2022-05-01 12:54:22 +02:00
|
|
|
if (media.uri.scheme == "file") {
|
|
|
|
media.uri.path?.let {
|
|
|
|
File(it).delete()
|
|
|
|
}
|
|
|
|
}
|
2022-04-21 18:46:21 +02:00
|
|
|
send(UploadEvent.FinishedEvent(result.id))
|
|
|
|
awaitClose()
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun downsize(media: QueuedMedia): QueuedMedia {
|
|
|
|
val file = createNewImageFile(context)
|
2022-04-21 18:46:21 +02:00
|
|
|
downsizeImage(media.uri, STATUS_IMAGE_SIZE_LIMIT, contentResolver, file)
|
2019-12-19 19:09:40 +01:00
|
|
|
return media.copy(uri = file.toUri(), mediaSize = file.length())
|
|
|
|
}
|
|
|
|
|
|
|
|
private fun shouldResizeMedia(media: QueuedMedia): Boolean {
|
2021-06-28 21:13:24 +02:00
|
|
|
return media.type == QueuedMedia.Type.IMAGE &&
|
|
|
|
(media.mediaSize > STATUS_IMAGE_SIZE_LIMIT || getImageSquarePixels(context.contentResolver, media.uri) > STATUS_IMAGE_PIXEL_SIZE_LIMIT)
|
2019-12-19 19:09:40 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
private companion object {
|
|
|
|
private const val TAG = "MediaUploaderImpl"
|
|
|
|
private const val STATUS_VIDEO_SIZE_LIMIT = 41943040 // 40MiB
|
2020-01-16 19:05:52 +01:00
|
|
|
private const val STATUS_AUDIO_SIZE_LIMIT = 41943040 // 40MiB
|
2019-12-19 19:09:40 +01:00
|
|
|
private const val STATUS_IMAGE_SIZE_LIMIT = 8388608 // 8MiB
|
|
|
|
private const val STATUS_IMAGE_PIXEL_SIZE_LIMIT = 16777216 // 4096^2 Pixels
|
|
|
|
}
|
|
|
|
}
|