Merge pull request #3264 from vector-im/feature/bma/fix_3245

Compress video and improve file too big error detection
This commit is contained in:
Benoit Marty 2021-05-05 15:50:21 +02:00 committed by GitHub
commit 64a37c251d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
74 changed files with 1389 additions and 397 deletions

View File

@ -7,6 +7,10 @@ Features ✨:
Improvements 🙌:
- Add ability to install APK from directly from Element (#2381)
- Delete and react to stickers (#3250)
- Compress video before sending (#442)
- Improve file too big error detection (#3245)
- User can now select video when selecting Gallery to send attachments to a room
- Add option to record a video from the camera
Bugfix 🐛:
- Message states cosmetic changes (#3007)
@ -18,6 +22,7 @@ Bugfix 🐛:
- Fix wording issue (#3242)
- Fix missing sender information after edits (#3184)
- Fix read marker not updating automatically (#3267)
- Sent video does not contains duration (#3272)
Translations 🗣:
-

View File

@ -17,20 +17,6 @@
apply plugin: 'com.android.library'
apply plugin: 'kotlin-android'
buildscript {
repositories {
maven {
url 'https://jitpack.io'
content {
// PhotoView
includeGroupByRegex 'com\\.github\\.chrisbanes'
}
}
jcenter()
}
}
android {
compileSdkVersion 30

View File

@ -45,7 +45,7 @@ allprojects {
// PFLockScreen-Android
includeGroupByRegex 'com\\.github\\.vector-im'
//Chat effects
// Chat effects
includeGroupByRegex 'com\\.github\\.jetradarmobile'
includeGroupByRegex 'nl\\.dionsegijn'
}

View File

@ -168,6 +168,9 @@ dependencies {
implementation 'com.jakewharton.timber:timber:4.7.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
// Video compression
implementation 'com.otaliastudios:transcoder:0.10.3'
// Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.22'

View File

@ -41,7 +41,7 @@ data class MatrixError(
// For M_LIMIT_EXCEEDED
@Json(name = "retry_after_ms") val retryAfterMillis: Long? = null,
// For M_UNKNOWN_TOKEN
@Json(name = "soft_logout") val isSoftLogout: Boolean = false,
@Json(name = "soft_logout") val isSoftLogout: Boolean? = null,
// For M_INVALID_PEPPER
// {"error": "pepper does not match 'erZvr'", "lookup_pepper": "pQgMS", "algorithm": "sha256", "errcode": "M_INVALID_PEPPER"}
@Json(name = "lookup_pepper") val newLookupPepper: String? = null,

View File

@ -31,6 +31,8 @@ interface ContentUploadStateTracker {
sealed class State {
object Idle : State()
object EncryptingThumbnail : State()
object CompressingImage : State()
data class CompressingVideo(val percent: Float) : State()
data class UploadingThumbnail(val current: Long, val total: Long) : State()
data class Encrypting(val current: Long, val total: Long) : State()
data class Uploading(val current: Long, val total: Long) : State()

View File

@ -28,6 +28,8 @@ import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
import org.matrix.android.sdk.internal.di.MoshiProvider
import org.json.JSONObject
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.MatrixError
import timber.log.Timber
typealias Content = JsonDict
@ -90,6 +92,16 @@ data class Event(
@Transient
var sendState: SendState = SendState.UNKNOWN
@Transient
var sendStateDetails: String? = null
fun sendStateError(): MatrixError? {
return sendStateDetails?.let {
val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java)
tryOrNull { matrixErrorAdapter.fromJson(it) }
}
}
/**
* The `age` value transcoded in a timestamp based on the device clock when the SDK received
* the event from the home server.

View File

@ -47,3 +47,10 @@ data class FileInfo(
*/
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
)
/**
* Get the url of the encrypted thumbnail or of the thumbnail
*/
fun FileInfo.getThumbnailUrl(): String? {
return thumbnailFile?.url ?: thumbnailUrl
}

View File

@ -40,7 +40,7 @@ data class ImageInfo(
/**
* Size of the image in bytes.
*/
@Json(name = "size") val size: Int = 0,
@Json(name = "size") val size: Long = 0,
/**
* Metadata about the image referred to in thumbnail_url.
@ -57,3 +57,10 @@ data class ImageInfo(
*/
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
)
/**
* Get the url of the encrypted thumbnail or of the thumbnail
*/
fun ImageInfo.getThumbnailUrl(): String? {
return thumbnailFile?.url ?: thumbnailUrl
}

View File

@ -37,3 +37,10 @@ data class LocationInfo(
*/
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
)
/**
* Get the url of the encrypted thumbnail or of the thumbnail
*/
fun LocationInfo.getThumbnailUrl(): String? {
return thumbnailFile?.url ?: thumbnailUrl
}

View File

@ -62,3 +62,10 @@ data class VideoInfo(
*/
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
)
/**
* Get the url of the encrypted thumbnail or of the thumbnail
*/
fun VideoInfo.getThumbnailUrl(): String? {
return thumbnailFile?.url ?: thumbnailUrl
}

View File

@ -23,6 +23,7 @@ import org.matrix.android.sdk.internal.network.executeRequest
import org.matrix.android.sdk.internal.session.room.RoomAPI
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.toMatrixErrorStr
import javax.inject.Inject
internal interface SendVerificationMessageTask : Task<SendVerificationMessageTask.Params, String> {
@ -55,7 +56,7 @@ internal class DefaultSendVerificationMessageTask @Inject constructor(
localEchoRepository.updateSendState(localId, event.roomId, SendState.SENT)
return response.eventId
} catch (e: Throwable) {
localEchoRepository.updateSendState(localId, event.roomId, SendState.UNDELIVERED)
localEchoRepository.updateSendState(localId, event.roomId, SendState.UNDELIVERED, e.toMatrixErrorStr())
throw e
}
}

View File

@ -43,7 +43,7 @@ import javax.inject.Inject
class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
companion object {
const val SESSION_STORE_SCHEMA_VERSION = 10L
const val SESSION_STORE_SCHEMA_VERSION = 11L
}
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
@ -59,6 +59,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
if (oldVersion <= 7) migrateTo8(realm)
if (oldVersion <= 8) migrateTo9(realm)
if (oldVersion <= 9) migrateTo10(realm)
if (oldVersion <= 10) migrateTo11(realm)
}
private fun migrateTo1(realm: DynamicRealm) {
@ -163,7 +164,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
?.addRealmListField(EditAggregatedSummaryEntityFields.EDITIONS.`$`, editionOfEventSchema)
}
fun migrateTo9(realm: DynamicRealm) {
private fun migrateTo9(realm: DynamicRealm) {
Timber.d("Step 8 -> 9")
realm.schema.get("RoomSummaryEntity")
@ -201,7 +202,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
}
}
fun migrateTo10(realm: DynamicRealm) {
private fun migrateTo10(realm: DynamicRealm) {
Timber.d("Step 9 -> 10")
realm.schema.create("SpaceChildSummaryEntity")
?.addField(SpaceChildSummaryEntityFields.ORDER, String::class.java)
@ -240,4 +241,10 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
?.addRealmListField(RoomSummaryEntityFields.PARENTS.`$`, realm.schema.get("SpaceParentSummaryEntity")!!)
?.addRealmListField(RoomSummaryEntityFields.CHILDREN.`$`, realm.schema.get("SpaceChildSummaryEntity")!!)
}
private fun migrateTo11(realm: DynamicRealm) {
Timber.d("Step 10 -> 11")
realm.schema.get("EventEntity")
?.addField(EventEntityFields.SEND_STATE_DETAILS, String::class.java)
}
}

View File

@ -80,6 +80,7 @@ internal object EventMapper {
).also {
it.ageLocalTs = eventEntity.ageLocalTs
it.sendState = eventEntity.sendState
it.sendStateDetails = eventEntity.sendStateDetails
eventEntity.decryptionResultJson?.let { json ->
try {
it.mxDecryptionResult = MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).fromJson(json)

View File

@ -32,6 +32,8 @@ internal open class EventEntity(@Index var eventId: String = "",
@Index var stateKey: String? = null,
var originServerTs: Long? = null,
@Index var sender: String? = null,
// Can contain a serialized MatrixError
var sendStateDetails: String? = null,
var age: Long? = 0,
var unsignedData: String? = null,
var redacts: String? = null,

View File

@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.internal.di.MoshiProvider
import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.ResponseBody
import org.matrix.android.sdk.api.extensions.orFalse
import retrofit2.HttpException
import retrofit2.Response
import timber.log.Timber
@ -91,7 +92,7 @@ private fun toFailure(errorBody: ResponseBody?, httpCode: Int, globalErrorReceiv
} else if (httpCode == HttpURLConnection.HTTP_UNAUTHORIZED /* 401 */
&& matrixError.code == MatrixError.M_UNKNOWN_TOKEN) {
// Also send this error to the globalErrorReceiver, for a global management
globalErrorReceiver?.handleGlobalError(GlobalError.InvalidToken(matrixError.isSoftLogout))
globalErrorReceiver?.handleGlobalError(GlobalError.InvalidToken(matrixError.isSoftLogout.orFalse()))
}
return Failure.ServerError(matrixError, httpCode)

View File

@ -78,6 +78,16 @@ internal class DefaultContentUploadStateTracker @Inject constructor() : ContentU
updateState(key, progressData)
}
internal fun setCompressingImage(key: String) {
val progressData = ContentUploadStateTracker.State.CompressingImage
updateState(key, progressData)
}
internal fun setCompressingVideo(key: String, percent: Float) {
val progressData = ContentUploadStateTracker.State.CompressingVideo(percent)
updateState(key, progressData)
}
internal fun setProgress(key: String, current: Long, total: Long) {
val progressData = ContentUploadStateTracker.State.Uploading(current, total)
updateState(key, progressData)

View File

@ -31,22 +31,28 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.internal.di.Authenticated
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.ProgressRequestBody
import org.matrix.android.sdk.internal.network.awaitResponse
import org.matrix.android.sdk.internal.network.toFailure
import org.matrix.android.sdk.internal.session.homeserver.DefaultHomeServerCapabilitiesService
import org.matrix.android.sdk.internal.util.TemporaryFileCreator
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
import java.util.UUID
import javax.inject.Inject
internal class FileUploader @Inject constructor(@Authenticated
private val okHttpClient: OkHttpClient,
private val globalErrorReceiver: GlobalErrorReceiver,
private val homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService,
private val context: Context,
private val temporaryFileCreator: TemporaryFileCreator,
contentUrlResolver: ContentUrlResolver,
moshi: Moshi) {
@ -57,6 +63,21 @@ internal class FileUploader @Inject constructor(@Authenticated
filename: String?,
mimeType: String?,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
// Check size limit
val maxUploadFileSize = homeServerCapabilitiesService.getHomeServerCapabilities().maxUploadFileSize
if (maxUploadFileSize != HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN
&& file.length() > maxUploadFileSize) {
// Known limitation and file too big for the server, save the pain to upload it
throw Failure.ServerError(
error = MatrixError(
code = MatrixError.M_TOO_LARGE,
message = "Cannot upload files larger than ${maxUploadFileSize / 1048576L}mb"
),
httpCode = 413
)
}
val uploadBody = object : RequestBody() {
override fun contentLength() = file.length()
@ -90,7 +111,7 @@ internal class FileUploader @Inject constructor(@Authenticated
val inputStream = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri)
} ?: throw FileNotFoundException()
val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
val workingFile = temporaryFileCreator.create()
workingFile.outputStream().use {
inputStream.copyTo(it)
}

View File

@ -16,19 +16,20 @@
package org.matrix.android.sdk.internal.session.content
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.graphics.Matrix
import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.internal.util.TemporaryFileCreator
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
internal class ImageCompressor @Inject constructor(private val context: Context) {
internal class ImageCompressor @Inject constructor(
private val temporaryFileCreator: TemporaryFileCreator
) {
suspend fun compress(
imageFile: File,
desiredWidth: Int,
@ -45,7 +46,7 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
}
} ?: return@withContext imageFile
val destinationFile = createDestinationFile()
val destinationFile = temporaryFileCreator.create()
runCatching {
destinationFile.outputStream().use {
@ -53,7 +54,7 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
}
}
return@withContext destinationFile
destinationFile
}
}
@ -64,16 +65,16 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
val orientation = exifInfo.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
val matrix = Matrix()
when (orientation) {
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.preRotate(-90f)
matrix.preScale(-1f, 1f)
}
ExifInterface.ORIENTATION_TRANSVERSE -> {
ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.preRotate(90f)
matrix.preScale(-1f, 1f)
}
@ -116,8 +117,4 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
null
}
}
private fun createDestinationFile(): File {
return File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
}
}

View File

@ -18,9 +18,12 @@ package org.matrix.android.sdk.internal.session.content
import android.content.Context
import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import androidx.core.net.toUri
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.listeners.ProgressListener
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
@ -41,12 +44,13 @@ import org.matrix.android.sdk.internal.session.room.send.CancelSendTracker
import org.matrix.android.sdk.internal.session.room.send.LocalEchoIdentifiers
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
import org.matrix.android.sdk.internal.session.room.send.MultipleEventSendingDispatcherWorker
import org.matrix.android.sdk.internal.util.TemporaryFileCreator
import org.matrix.android.sdk.internal.util.toMatrixErrorStr
import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker
import org.matrix.android.sdk.internal.worker.SessionWorkerParams
import org.matrix.android.sdk.internal.worker.WorkerParamsFactory
import timber.log.Timber
import java.io.File
import java.util.UUID
import javax.inject.Inject
private data class NewAttachmentAttributes(
@ -77,7 +81,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
@Inject lateinit var fileService: DefaultFileService
@Inject lateinit var cancelSendTracker: CancelSendTracker
@Inject lateinit var imageCompressor: ImageCompressor
@Inject lateinit var videoCompressor: VideoCompressor
@Inject lateinit var localEchoRepository: LocalEchoRepository
@Inject lateinit var temporaryFileCreator: TemporaryFileCreator
override fun injectWith(injector: SessionComponent) {
injector.inject(this)
@ -109,7 +115,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
val attachment = params.attachment
val filesToDelete = mutableListOf<File>()
try {
return try {
val inputStream = context.contentResolver.openInputStream(attachment.queryUri)
?: return Result.success(
WorkerParamsFactory.toData(
@ -120,7 +126,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
)
// always use a temporary file, it guaranties that we could report progress on upload and simplifies the flows
val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
val workingFile = temporaryFileCreator.create()
.also { filesToDelete.add(it) }
workingFile.outputStream().use { outputStream ->
inputStream.use { inputStream ->
@ -128,8 +134,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
}
}
val uploadThumbnailResult = dealWithThumbnail(params)
val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) {
notifyTracker(params) {
@ -144,7 +148,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
return try {
try {
val fileToUpload: File
var newAttachmentAttributes = NewAttachmentAttributes(
params.attachment.width?.toInt(),
@ -156,6 +160,8 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
// Do not compress gif
&& attachment.mimeType != MimeTypes.Gif
&& params.compressBeforeSending) {
notifyTracker(params) { contentUploadStateTracker.setCompressingImage(it) }
fileToUpload = imageCompressor.compress(workingFile, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE)
.also { compressedFile ->
// Get new Bitmap size
@ -170,6 +176,48 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
}
}
.also { filesToDelete.add(it) }
} else if (attachment.type == ContentAttachmentData.Type.VIDEO
// Do not compress gif
&& attachment.mimeType != MimeTypes.Gif
&& params.compressBeforeSending) {
fileToUpload = videoCompressor.compress(workingFile, object : ProgressListener {
override fun onProgress(progress: Int, total: Int) {
notifyTracker(params) { contentUploadStateTracker.setCompressingVideo(it, progress.toFloat()) }
}
})
.let { videoCompressionResult ->
when (videoCompressionResult) {
is VideoCompressionResult.Success -> {
val compressedFile = videoCompressionResult.compressedFile
var compressedWidth: Int? = null
var compressedHeight: Int? = null
tryOrNull {
context.contentResolver.openFileDescriptor(compressedFile.toUri(), "r")?.use { pfd ->
MediaMetadataRetriever().let {
it.setDataSource(pfd.fileDescriptor)
compressedWidth = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt()
compressedHeight = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt()
}
}
}
// Get new Video file size and dimensions
newAttachmentAttributes = newAttachmentAttributes.copy(
newFileSize = compressedFile.length(),
newWidth = compressedWidth ?: newAttachmentAttributes.newWidth,
newHeight = compressedHeight ?: newAttachmentAttributes.newHeight
)
compressedFile
.also { filesToDelete.add(it) }
}
VideoCompressionResult.CompressionNotNeeded,
VideoCompressionResult.CompressionCancelled,
is VideoCompressionResult.CompressionFailed -> {
workingFile
}
}
}
} else {
fileToUpload = workingFile
// Fix: OpenableColumns.SIZE may return -1 or 0
@ -180,9 +228,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
val encryptedFile: File?
val contentUploadResponse = if (params.isEncrypted) {
Timber.v("## FileService: Encrypt file")
Timber.v("## Encrypt file")
encryptedFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
encryptedFile = temporaryFileCreator.create()
.also { filesToDelete.add(it) }
uploadedFileEncryptedFileInfo =
@ -192,18 +240,18 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
}
}
Timber.v("## FileService: Uploading file")
Timber.v("## Uploading file")
fileUploader
.uploadFile(encryptedFile, attachment.name, MimeTypes.OctetStream, progressListener)
} else {
Timber.v("## FileService: Clear file")
Timber.v("## Clear file")
encryptedFile = null
fileUploader
.uploadFile(fileToUpload, attachment.name, attachment.getSafeMimeType(), progressListener)
}
Timber.v("## FileService: Update cache storage for ${contentUploadResponse.contentUri}")
Timber.v("## Update cache storage for ${contentUploadResponse.contentUri}")
try {
fileService.storeDataFor(
mxcUrl = contentUploadResponse.contentUri,
@ -212,11 +260,13 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
originalFile = workingFile,
encryptedFile = encryptedFile
)
Timber.v("## FileService: cache storage updated")
Timber.v("## cache storage updated")
} catch (failure: Throwable) {
Timber.e(failure, "## FileService: Failed to update file cache")
Timber.e(failure, "## Failed to update file cache")
}
val uploadThumbnailResult = dealWithThumbnail(params)
handleSuccess(params,
contentUploadResponse.contentUri,
uploadedFileEncryptedFileInfo,
@ -224,12 +274,12 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
uploadThumbnailResult?.uploadedThumbnailEncryptedFileInfo,
newAttachmentAttributes)
} catch (t: Throwable) {
Timber.e(t, "## FileService: ERROR ${t.localizedMessage}")
Timber.e(t, "## ERROR ${t.localizedMessage}")
handleFailure(params, t)
}
} catch (e: Exception) {
Timber.e(e, "## FileService: ERROR")
return handleFailure(params, e)
Timber.e(e, "## ERROR")
handleFailure(params, e)
} finally {
// Delete all temporary files
filesToDelete.forEach {
@ -260,19 +310,23 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
Timber.v("Encrypt thumbnail")
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType)
val contentUploadResponse = fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
"thumb_${params.attachment.name}",
MimeTypes.OctetStream,
thumbnailProgressListener)
val contentUploadResponse = fileUploader.uploadByteArray(
byteArray = encryptionResult.encryptedByteArray,
filename = "thumb_${params.attachment.name}",
mimeType = MimeTypes.OctetStream,
progressListener = thumbnailProgressListener
)
UploadThumbnailResult(
contentUploadResponse.contentUri,
encryptionResult.encryptedFileInfo
)
} else {
val contentUploadResponse = fileUploader.uploadByteArray(thumbnailData.bytes,
"thumb_${params.attachment.name}",
thumbnailData.mimeType,
thumbnailProgressListener)
val contentUploadResponse = fileUploader.uploadByteArray(
byteArray = thumbnailData.bytes,
filename = "thumb_${params.attachment.name}",
mimeType = thumbnailData.mimeType,
progressListener = thumbnailProgressListener
)
UploadThumbnailResult(
contentUploadResponse.contentUri,
null
@ -291,7 +345,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
return Result.success(
WorkerParamsFactory.toData(
params.copy(
lastFailureMessage = failure.localizedMessage
lastFailureMessage = failure.toMatrixErrorStr()
)
)
)
@ -328,8 +382,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
val messageContent: MessageContent? = event.asDomain().content.toModel()
val updatedContent = when (messageContent) {
is MessageImageContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes)
is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo,
newAttachmentAttributes.newFileSize)
is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, newAttachmentAttributes)
is MessageFileContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize)
is MessageAudioContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize)
else -> messageContent
@ -351,7 +404,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
info = info?.copy(
width = newAttachmentAttributes?.newWidth ?: info.width,
height = newAttachmentAttributes?.newHeight ?: info.height,
size = newAttachmentAttributes?.newFileSize?.toInt() ?: info.size
size = newAttachmentAttributes?.newFileSize ?: info.size
)
)
}
@ -360,14 +413,16 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
encryptedFileInfo: EncryptedFileInfo?,
thumbnailUrl: String?,
thumbnailEncryptedFileInfo: EncryptedFileInfo?,
size: Long): MessageVideoContent {
newAttachmentAttributes: NewAttachmentAttributes?): MessageVideoContent {
return copy(
url = if (encryptedFileInfo == null) url else null,
encryptedFileInfo = encryptedFileInfo?.copy(url = url),
videoInfo = videoInfo?.copy(
thumbnailUrl = if (thumbnailEncryptedFileInfo == null) thumbnailUrl else null,
thumbnailFile = thumbnailEncryptedFileInfo?.copy(url = thumbnailUrl),
size = size
width = newAttachmentAttributes?.newWidth ?: videoInfo.width,
height = newAttachmentAttributes?.newHeight ?: videoInfo.height,
size = newAttachmentAttributes?.newFileSize ?: videoInfo.size
)
)
}

View File

@ -0,0 +1,26 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.content
import java.io.File
internal sealed class VideoCompressionResult {
data class Success(val compressedFile: File) : VideoCompressionResult()
object CompressionNotNeeded : VideoCompressionResult()
object CompressionCancelled : VideoCompressionResult()
data class CompressionFailed(val failure: Throwable) : VideoCompressionResult()
}

View File

@ -0,0 +1,119 @@
/*
* Copyright 2021 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.session.content
import com.otaliastudios.transcoder.Transcoder
import com.otaliastudios.transcoder.TranscoderListener
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.withContext
import org.matrix.android.sdk.api.listeners.ProgressListener
import org.matrix.android.sdk.internal.util.TemporaryFileCreator
import timber.log.Timber
import java.io.File
import javax.inject.Inject
internal class VideoCompressor @Inject constructor(
private val temporaryFileCreator: TemporaryFileCreator
) {
suspend fun compress(videoFile: File,
progressListener: ProgressListener?): VideoCompressionResult {
val destinationFile = temporaryFileCreator.create()
val job = Job()
Timber.d("Compressing: start")
progressListener?.onProgress(0, 100)
var result: Int = -1
var failure: Throwable? = null
Transcoder.into(destinationFile.path)
.addDataSource(videoFile.path)
.setListener(object : TranscoderListener {
override fun onTranscodeProgress(progress: Double) {
Timber.d("Compressing: $progress%")
progressListener?.onProgress((progress * 100).toInt(), 100)
}
override fun onTranscodeCompleted(successCode: Int) {
Timber.d("Compressing: success: $successCode")
result = successCode
job.complete()
}
override fun onTranscodeCanceled() {
Timber.d("Compressing: cancel")
job.cancel()
}
override fun onTranscodeFailed(exception: Throwable) {
Timber.w(exception, "Compressing: failure")
failure = exception
job.completeExceptionally(exception)
}
})
.transcode()
job.join()
// Note: job is also cancelled if completeExceptionally() was called
if (job.isCancelled) {
// Delete now the temporary file
deleteFile(destinationFile)
return when (val finalFailure = failure) {
null -> {
// We do not throw a CancellationException, because it's not critical, we will try to send the original file
// Anyway this should never occurs, since we never cancel the return value of transcode()
Timber.w("Compressing: A failure occurred")
VideoCompressionResult.CompressionCancelled
}
else -> {
// Compression failure can also be considered as not critical, but let the caller decide
Timber.w("Compressing: Job cancelled")
VideoCompressionResult.CompressionFailed(finalFailure)
}
}
}
progressListener?.onProgress(100, 100)
return when (result) {
Transcoder.SUCCESS_TRANSCODED -> {
VideoCompressionResult.Success(destinationFile)
}
Transcoder.SUCCESS_NOT_NEEDED -> {
// Delete now the temporary file
deleteFile(destinationFile)
VideoCompressionResult.CompressionNotNeeded
}
else -> {
// Should not happen...
// Delete now the temporary file
deleteFile(destinationFile)
Timber.w("Unknown result: $result")
VideoCompressionResult.CompressionFailed(IllegalStateException("Unknown result: $result"))
}
}
}
private suspend fun deleteFile(file: File) {
withContext(Dispatchers.IO) {
file.delete()
}
}
}

View File

@ -99,6 +99,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor:
entity.age = editedEventEntity.age
entity.originServerTs = editedEventEntity.originServerTs
entity.sendState = editedEventEntity.sendState
entity.sendStateDetails = editedEventEntity.sendStateDetails
}
}
}

View File

@ -109,14 +109,6 @@ internal class DefaultSendService @AssistedInject constructor(
.let { sendEvent(it) }
}
override fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable {
return attachments.mapTo(CancelableBag()) {
sendMedia(it, compressBeforeSending, roomIds)
}
}
override fun redactEvent(event: Event, reason: String?): Cancelable {
// TODO manage media/attachements?
val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason)
@ -149,7 +141,7 @@ internal class DefaultSendService @AssistedInject constructor(
is MessageImageContent -> {
// The image has not yet been sent
val attachmentData = ContentAttachmentData(
size = messageContent.info!!.size.toLong(),
size = messageContent.info!!.size,
mimeType = messageContent.info.mimeType!!,
width = messageContent.info.width.toLong(),
height = messageContent.info.height.toLong(),
@ -240,6 +232,14 @@ internal class DefaultSendService @AssistedInject constructor(
}
}
override fun sendMedias(attachments: List<ContentAttachmentData>,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable {
return attachments.mapTo(CancelableBag()) {
sendMedia(it, compressBeforeSending, roomIds)
}
}
override fun sendMedia(attachment: ContentAttachmentData,
compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable {

View File

@ -244,7 +244,7 @@ internal class LocalEchoEventFactory @Inject constructor(
mimeType = attachment.getSafeMimeType(),
width = width?.toInt() ?: 0,
height = height?.toInt() ?: 0,
size = attachment.size.toInt()
size = attachment.size
),
url = attachment.queryUri.toString()
)

View File

@ -87,7 +87,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
}
}
fun updateSendState(eventId: String, roomId: String?, sendState: SendState) {
fun updateSendState(eventId: String, roomId: String?, sendState: SendState, sendStateDetails: String? = null) {
Timber.v("## SendEvent: [${System.currentTimeMillis()}] Update local state of $eventId to ${sendState.name}")
timelineInput.onLocalEchoUpdated(roomId = roomId ?: "", eventId = eventId, sendState = sendState)
updateEchoAsync(eventId) { realm, sendingEventEntity ->
@ -96,6 +96,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
} else {
sendingEventEntity.sendState = sendState
}
sendingEventEntity.sendStateDetails = sendStateDetails
roomSummaryUpdater.updateSendingInformation(realm, sendingEventEntity.roomId)
}
}
@ -161,6 +162,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
val timelineEvents = TimelineEventEntity.where(realm, roomId, eventIds).findAll()
timelineEvents.forEach {
it.root?.sendState = sendState
it.root?.sendStateDetails = null
}
roomSummaryUpdater.updateSendingInformation(realm, roomId)
}

View File

@ -55,7 +55,12 @@ internal class MultipleEventSendingDispatcherWorker(context: Context, params: Wo
override fun doOnError(params: Params): Result {
params.localEchoIds.forEach { localEchoIds ->
localEchoRepository.updateSendState(localEchoIds.eventId, localEchoIds.roomId, SendState.UNDELIVERED)
localEchoRepository.updateSendState(
eventId = localEchoIds.eventId,
roomId = localEchoIds.roomId,
sendState = SendState.UNDELIVERED,
sendStateDetails = params.lastFailureMessage
)
}
return super.doOnError(params)

View File

@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState
import org.matrix.android.sdk.internal.crypto.tasks.SendEventTask
import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.SessionComponent
import org.matrix.android.sdk.internal.util.toMatrixErrorStr
import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker
import org.matrix.android.sdk.internal.worker.SessionWorkerParams
import timber.log.Timber
@ -77,7 +78,12 @@ internal class SendEventWorker(context: Context,
}
if (params.lastFailureMessage != null) {
localEchoRepository.updateSendState(event.eventId, event.roomId, SendState.UNDELIVERED)
localEchoRepository.updateSendState(
eventId = event.eventId,
roomId = event.roomId,
sendState = SendState.UNDELIVERED,
sendStateDetails = params.lastFailureMessage
)
// Transmit the error
return Result.success(inputData)
.also { Timber.e("Work cancelled due to input error from parent") }
@ -90,7 +96,12 @@ internal class SendEventWorker(context: Context,
} catch (exception: Throwable) {
if (/*currentAttemptCount >= MAX_NUMBER_OF_RETRY_BEFORE_FAILING ||**/ !exception.shouldBeRetried()) {
Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed cannot retry ${params.eventId} > ${exception.localizedMessage}")
localEchoRepository.updateSendState(event.eventId, event.roomId, SendState.UNDELIVERED)
localEchoRepository.updateSendState(
eventId = event.eventId,
roomId = event.roomId,
sendState = SendState.UNDELIVERED,
sendStateDetails = exception.toMatrixErrorStr()
)
Result.success()
} else {
Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed schedule retry ${params.eventId} > ${exception.localizedMessage}")

View File

@ -0,0 +1,36 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.util
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.failure.MatrixError
import org.matrix.android.sdk.internal.di.MoshiProvider
/**
* Try to extract and serialize a MatrixError, or default to localizedMessage
*/
internal fun Throwable.toMatrixErrorStr(): String {
return (this as? Failure.ServerError)
?.let {
// Serialize the MatrixError in this case
val adapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java)
tryOrNull { adapter.toJson(error) }
}
?: localizedMessage
?: "error"
}

View File

@ -0,0 +1,34 @@
/*
* Copyright (c) 2021 The Matrix.org Foundation C.I.C.
*
* 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 org.matrix.android.sdk.internal.util
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.util.UUID
import javax.inject.Inject
internal class TemporaryFileCreator @Inject constructor(
private val context: Context
) {
suspend fun create(): File {
return withContext(Dispatchers.IO) {
File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
}
}
}

View File

@ -18,9 +18,8 @@ package im.vector.lib.multipicker
import android.content.Context
import android.content.Intent
import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.lib.multipicker.entity.MultiPickerAudioType
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
/**
* Audio file picker implementation
@ -32,48 +31,9 @@ class AudioPicker : Picker<MultiPickerAudioType>() {
* Returns selected audio files or empty list if user did not select any files.
*/
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerAudioType> {
val audioList = mutableListOf<MultiPickerAudioType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
var duration = 0L
context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd ->
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
}
audioList.add(
MultiPickerAudioType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
duration
)
)
}
}
return getSelectedUriList(data).mapNotNull { selectedUri ->
selectedUri.toMultiPickerAudioType(context)
}
return audioList
}
override fun createIntent(): Intent {

View File

@ -23,7 +23,7 @@ import android.provider.MediaStore
import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.FileProvider
import im.vector.lib.multipicker.entity.MultiPickerImageType
import im.vector.lib.multipicker.utils.ImageUtils
import im.vector.lib.multipicker.utils.toMultiPickerImageType
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
@ -54,40 +54,7 @@ class CameraPicker {
* or user cancelled the operation.
*/
fun getTakenPhoto(context: Context, photoUri: Uri): MultiPickerImageType? {
val projection = arrayOf(
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE
)
context.contentResolver.query(
photoUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val bitmap = ImageUtils.getBitmap(context, photoUri)
val orientation = ImageUtils.getOrientation(context, photoUri)
return MultiPickerImageType(
name,
size,
context.contentResolver.getType(photoUri),
photoUri,
bitmap?.width ?: 0,
bitmap?.height ?: 0,
orientation
)
}
}
return null
return photoUri.toMultiPickerImageType(context)
}
private fun createIntent(): Intent {

View File

@ -0,0 +1,81 @@
/*
* Copyright (c) 2021 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 im.vector.lib.multipicker
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.MediaStore
import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.FileProvider
import im.vector.lib.multipicker.entity.MultiPickerVideoType
import im.vector.lib.multipicker.utils.toMultiPickerVideoType
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
/**
* Implementation of taking a video with Camera
*/
class CameraVideoPicker {
/**
* Start camera by using a ActivityResultLauncher
* @return Uri of taken photo or null if the operation is cancelled.
*/
fun startWithExpectingFile(context: Context, activityResultLauncher: ActivityResultLauncher<Intent>): Uri? {
val videoUri = createVideoUri(context)
val intent = createIntent().apply {
putExtra(MediaStore.EXTRA_OUTPUT, videoUri)
}
activityResultLauncher.launch(intent)
return videoUri
}
/**
* Call this function from onActivityResult(int, int, Intent).
* @return Taken photo or null if request code is wrong
* or result code is not Activity.RESULT_OK
* or user cancelled the operation.
*/
fun getTakenVideo(context: Context, videoUri: Uri): MultiPickerVideoType? {
return videoUri.toMultiPickerVideoType(context)
}
private fun createIntent(): Intent {
return Intent(MediaStore.ACTION_VIDEO_CAPTURE)
}
companion object {
fun createVideoUri(context: Context): Uri {
val file = createVideoFile(context)
val authority = context.packageName + ".multipicker.fileprovider"
return FileProvider.getUriForFile(context, authority, file)
}
private fun createVideoFile(context: Context): File {
val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date())
val storageDir: File = context.filesDir
return File.createTempFile(
"${timeStamp}_", /* prefix */
".mp4", /* suffix */
storageDir /* directory */
)
}
}
}

View File

@ -19,41 +19,55 @@ package im.vector.lib.multipicker
import android.content.Context
import android.content.Intent
import android.provider.OpenableColumns
import im.vector.lib.multipicker.entity.MultiPickerBaseType
import im.vector.lib.multipicker.entity.MultiPickerFileType
import im.vector.lib.multipicker.utils.isMimeTypeAudio
import im.vector.lib.multipicker.utils.isMimeTypeImage
import im.vector.lib.multipicker.utils.isMimeTypeVideo
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
import im.vector.lib.multipicker.utils.toMultiPickerImageType
import im.vector.lib.multipicker.utils.toMultiPickerVideoType
/**
* Implementation of selecting any type of files
*/
class FilePicker : Picker<MultiPickerFileType>() {
class FilePicker : Picker<MultiPickerBaseType>() {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected files or empty list if user did not select any files.
*/
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerFileType> {
val fileList = mutableListOf<MultiPickerFileType>()
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerBaseType> {
return getSelectedUriList(data).mapNotNull { selectedUri ->
val type = context.contentResolver.getType(selectedUri)
getSelectedUriList(data).forEach { selectedUri ->
context.contentResolver.query(selectedUri, null, null, null, null)
?.use { cursor ->
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
when {
type.isMimeTypeVideo() -> selectedUri.toMultiPickerVideoType(context)
type.isMimeTypeImage() -> selectedUri.toMultiPickerImageType(context)
type.isMimeTypeAudio() -> selectedUri.toMultiPickerAudioType(context)
else -> {
// Other files
context.contentResolver.query(selectedUri, null, null, null, null)
?.use { cursor ->
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
if (cursor.moveToFirst()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
fileList.add(
MultiPickerFileType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri
)
)
}
}
} else {
null
}
}
}
}
}
return fileList
}
override fun createIntent(): Intent {

View File

@ -18,9 +18,8 @@ package im.vector.lib.multipicker
import android.content.Context
import android.content.Intent
import android.provider.MediaStore
import im.vector.lib.multipicker.entity.MultiPickerImageType
import im.vector.lib.multipicker.utils.ImageUtils
import im.vector.lib.multipicker.utils.toMultiPickerImageType
/**
* Image Picker implementation
@ -32,46 +31,9 @@ class ImagePicker : Picker<MultiPickerImageType>() {
* Returns selected image files or empty list if user did not select any files.
*/
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerImageType> {
val imageList = mutableListOf<MultiPickerImageType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val bitmap = ImageUtils.getBitmap(context, selectedUri)
val orientation = ImageUtils.getOrientation(context, selectedUri)
imageList.add(
MultiPickerImageType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
bitmap?.width ?: 0,
bitmap?.height ?: 0,
orientation
)
)
}
}
return getSelectedUriList(data).mapNotNull { selectedUri ->
selectedUri.toMultiPickerImageType(context)
}
return imageList
}
override fun createIntent(): Intent {

View File

@ -0,0 +1,57 @@
/*
* Copyright (c) 2020 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 im.vector.lib.multipicker
import android.content.Context
import android.content.Intent
import im.vector.lib.multipicker.entity.MultiPickerBaseMediaType
import im.vector.lib.multipicker.utils.isMimeTypeVideo
import im.vector.lib.multipicker.utils.toMultiPickerImageType
import im.vector.lib.multipicker.utils.toMultiPickerVideoType
/**
* Image/Video Picker implementation
*/
class MediaPicker : Picker<MultiPickerBaseMediaType>() {
/**
* Call this function from onActivityResult(int, int, Intent).
* Returns selected image/video files or empty list if user did not select any files.
*/
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerBaseMediaType> {
return getSelectedUriList(data).mapNotNull { selectedUri ->
val mimeType = context.contentResolver.getType(selectedUri)
if (mimeType.isMimeTypeVideo()) {
selectedUri.toMultiPickerVideoType(context)
} else {
// Assume it's an image
selectedUri.toMultiPickerImageType(context)
}
}
}
override fun createIntent(): Intent {
return Intent(Intent.ACTION_GET_CONTENT).apply {
addCategory(Intent.CATEGORY_OPENABLE)
putExtra(Intent.EXTRA_ALLOW_MULTIPLE, !single)
type = "video/*, image/*"
val mimeTypes = arrayOf("image/*", "video/*")
putExtra(Intent.EXTRA_MIME_TYPES, mimeTypes)
}
}
}

View File

@ -20,21 +20,25 @@ class MultiPicker<T> {
companion object Type {
val IMAGE by lazy { MultiPicker<ImagePicker>() }
val MEDIA by lazy { MultiPicker<MediaPicker>() }
val FILE by lazy { MultiPicker<FilePicker>() }
val VIDEO by lazy { MultiPicker<VideoPicker>() }
val AUDIO by lazy { MultiPicker<AudioPicker>() }
val CONTACT by lazy { MultiPicker<ContactPicker>() }
val CAMERA by lazy { MultiPicker<CameraPicker>() }
val CAMERA_VIDEO by lazy { MultiPicker<CameraVideoPicker>() }
@Suppress("UNCHECKED_CAST")
fun <T> get(type: MultiPicker<T>): T {
return when (type) {
IMAGE -> ImagePicker() as T
VIDEO -> VideoPicker() as T
MEDIA -> MediaPicker() as T
FILE -> FilePicker() as T
AUDIO -> AudioPicker() as T
CONTACT -> ContactPicker() as T
CAMERA -> CameraPicker() as T
CAMERA_VIDEO -> CameraVideoPicker() as T
else -> throw IllegalArgumentException("Unsupported type $type")
}
}

View File

@ -18,9 +18,8 @@ package im.vector.lib.multipicker
import android.content.Context
import android.content.Intent
import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.lib.multipicker.entity.MultiPickerVideoType
import im.vector.lib.multipicker.utils.toMultiPickerVideoType
/**
* Video Picker implementation
@ -32,57 +31,9 @@ class VideoPicker : Picker<MultiPickerVideoType>() {
* Returns selected video files or empty list if user did not select any files.
*/
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerVideoType> {
val videoList = mutableListOf<MultiPickerVideoType>()
getSelectedUriList(data).forEach { selectedUri ->
val projection = arrayOf(
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.SIZE
)
context.contentResolver.query(
selectedUri,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
var duration = 0L
var width = 0
var height = 0
var orientation = 0
context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd ->
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
orientation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
}
videoList.add(
MultiPickerVideoType(
name,
size,
context.contentResolver.getType(selectedUri),
selectedUri,
width,
height,
orientation,
duration
)
)
}
}
return getSelectedUriList(data).mapNotNull { selectedUri ->
selectedUri.toMultiPickerVideoType(context)
}
return videoList
}
override fun createIntent(): Intent {

View File

@ -0,0 +1,23 @@
/*
* Copyright (c) 2020 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 im.vector.lib.multipicker.entity
interface MultiPickerBaseMediaType : MultiPickerBaseType {
val width: Int
val height: Int
val orientation: Int
}

View File

@ -23,7 +23,7 @@ data class MultiPickerImageType(
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri,
val width: Int,
val height: Int,
val orientation: Int
) : MultiPickerBaseType
override val width: Int,
override val height: Int,
override val orientation: Int
) : MultiPickerBaseMediaType

View File

@ -23,8 +23,8 @@ data class MultiPickerVideoType(
override val size: Long,
override val mimeType: String?,
override val contentUri: Uri,
val width: Int,
val height: Int,
val orientation: Int,
override val width: Int,
override val height: Int,
override val orientation: Int,
val duration: Long
) : MultiPickerBaseType
) : MultiPickerBaseMediaType

View File

@ -0,0 +1,152 @@
/*
* Copyright (c) 2021 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 im.vector.lib.multipicker.utils
import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import android.provider.MediaStore
import im.vector.lib.multipicker.entity.MultiPickerAudioType
import im.vector.lib.multipicker.entity.MultiPickerImageType
import im.vector.lib.multipicker.entity.MultiPickerVideoType
internal fun Uri.toMultiPickerImageType(context: Context): MultiPickerImageType? {
val projection = arrayOf(
MediaStore.Images.Media.DISPLAY_NAME,
MediaStore.Images.Media.SIZE
)
return context.contentResolver.query(
this,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
val bitmap = ImageUtils.getBitmap(context, this)
val orientation = ImageUtils.getOrientation(context, this)
MultiPickerImageType(
name,
size,
context.contentResolver.getType(this),
this,
bitmap?.width ?: 0,
bitmap?.height ?: 0,
orientation
)
} else {
null
}
}
}
internal fun Uri.toMultiPickerVideoType(context: Context): MultiPickerVideoType? {
val projection = arrayOf(
MediaStore.Video.Media.DISPLAY_NAME,
MediaStore.Video.Media.SIZE
)
return context.contentResolver.query(
this,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
var duration = 0L
var width = 0
var height = 0
var orientation = 0
context.contentResolver.openFileDescriptor(this, "r")?.use { pfd ->
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
orientation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
}
MultiPickerVideoType(
name,
size,
context.contentResolver.getType(this),
this,
width,
height,
orientation,
duration
)
} else {
null
}
}
}
internal fun Uri.toMultiPickerAudioType(context: Context): MultiPickerAudioType? {
val projection = arrayOf(
MediaStore.Audio.Media.DISPLAY_NAME,
MediaStore.Audio.Media.SIZE
)
return context.contentResolver.query(
this,
projection,
null,
null,
null
)?.use { cursor ->
val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME)
val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE)
if (cursor.moveToNext()) {
val name = cursor.getString(nameColumn)
val size = cursor.getLong(sizeColumn)
var duration = 0L
context.contentResolver.openFileDescriptor(this, "r")?.use { pfd ->
val mediaMetadataRetriever = MediaMetadataRetriever()
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
}
MultiPickerAudioType(
name,
size,
context.contentResolver.getType(this),
this,
duration
)
} else {
null
}
}
}

View File

@ -0,0 +1,21 @@
/*
* Copyright (c) 2021 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 im.vector.lib.multipicker.utils
internal fun String?.isMimeTypeImage() = this?.startsWith("image/") == true
internal fun String?.isMimeTypeVideo() = this?.startsWith("video/") == true
internal fun String?.isMimeTypeAudio() = this?.startsWith("audio/") == true

View File

@ -0,0 +1,118 @@
/*
* Copyright (c) 2021 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 im.vector.app.core.dialogs
import android.app.Activity
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.databinding.DialogPhotoOrVideoBinding
import im.vector.app.features.settings.VectorPreferences
class PhotoOrVideoDialog(
private val activity: Activity,
private val vectorPreferences: VectorPreferences
) {
interface PhotoOrVideoDialogListener {
fun takePhoto()
fun takeVideo()
}
interface PhotoOrVideoDialogSettingsListener {
fun onUpdated()
}
fun show(listener: PhotoOrVideoDialogListener) {
when (vectorPreferences.getTakePhotoVideoMode()) {
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> listener.takePhoto()
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> listener.takeVideo()
/* VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK */
else -> {
val dialogLayout = activity.layoutInflater.inflate(R.layout.dialog_photo_or_video, null)
val views = DialogPhotoOrVideoBinding.bind(dialogLayout)
// Show option to set as default in this case
views.dialogPhotoOrVideoAsDefault.isVisible = true
// Always default to photo
views.dialogPhotoOrVideoPhoto.isChecked = true
AlertDialog.Builder(activity)
.setTitle(R.string.option_take_photo_video)
.setView(dialogLayout)
.setPositiveButton(R.string._continue) { _, _ ->
submit(views, vectorPreferences, listener)
}
.setNegativeButton(R.string.cancel, null)
.show()
}
}
}
private fun submit(views: DialogPhotoOrVideoBinding,
vectorPreferences: VectorPreferences,
listener: PhotoOrVideoDialogListener) {
val mode = if (views.dialogPhotoOrVideoPhoto.isChecked) {
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO
} else {
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO
}
if (views.dialogPhotoOrVideoAsDefault.isChecked) {
vectorPreferences.setTakePhotoVideoMode(mode)
}
when (mode) {
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> listener.takePhoto()
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> listener.takeVideo()
}
}
fun showForSettings(listener: PhotoOrVideoDialogSettingsListener) {
val currentMode = vectorPreferences.getTakePhotoVideoMode()
val dialogLayout = activity.layoutInflater.inflate(R.layout.dialog_photo_or_video, null)
val views = DialogPhotoOrVideoBinding.bind(dialogLayout)
// Show option for always ask in this case
views.dialogPhotoOrVideoAlwaysAsk.isVisible = true
// Always default to photo
views.dialogPhotoOrVideoPhoto.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO
views.dialogPhotoOrVideoVideo.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO
views.dialogPhotoOrVideoAlwaysAsk.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK
AlertDialog.Builder(activity)
.setTitle(R.string.option_take_photo_video)
.setView(dialogLayout)
.setPositiveButton(R.string.save) { _, _ ->
submitSettings(views)
listener.onUpdated()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
private fun submitSettings(views: DialogPhotoOrVideoBinding) {
vectorPreferences.setTakePhotoVideoMode(
when {
views.dialogPhotoOrVideoPhoto.isChecked -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO
views.dialogPhotoOrVideoVideo.isChecked -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO
else -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK
}
)
}
}

View File

@ -46,6 +46,9 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
@EpoxyAttribute
lateinit var body: CharSequence
@EpoxyAttribute
var bodyDetails: CharSequence? = null
@EpoxyAttribute
var imageContentRenderer: ImageContentRenderer? = null
@ -73,6 +76,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
holder.imagePreview.isVisible = data != null
holder.body.movementMethod = movementMethod
holder.body.text = body
holder.bodyDetails.setTextOrHide(bodyDetails)
body.findPillsAndProcess(coroutineScope) { it.bind(holder.body) }
holder.timestamp.setTextOrHide(time)
}
@ -86,6 +90,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
val avatar by bind<ImageView>(R.id.bottom_sheet_message_preview_avatar)
val sender by bind<TextView>(R.id.bottom_sheet_message_preview_sender)
val body by bind<TextView>(R.id.bottom_sheet_message_preview_body)
val bodyDetails by bind<TextView>(R.id.bottom_sheet_message_preview_body_details)
val timestamp by bind<TextView>(R.id.bottom_sheet_message_preview_timestamp)
val imagePreview by bind<ImageView>(R.id.bottom_sheet_message_preview_image)
}

View File

@ -78,6 +78,9 @@ class DefaultErrorFormatter @Inject constructor(
throwable.error.code == MatrixError.M_LIMIT_EXCEEDED -> {
limitExceededError(throwable.error)
}
throwable.error.code == MatrixError.M_TOO_LARGE -> {
stringProvider.getString(R.string.error_file_too_big_simple)
}
throwable.error.code == MatrixError.M_THREEPID_NOT_FOUND -> {
stringProvider.getString(R.string.login_reset_password_error_not_found)
}

View File

@ -17,11 +17,13 @@
package im.vector.app.core.ui.views
import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isVisible
import im.vector.app.R
import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration
import im.vector.app.features.themes.ThemeUtils
class SendStateImageView @JvmOverloads constructor(
context: Context,
@ -39,16 +41,19 @@ class SendStateImageView @JvmOverloads constructor(
isVisible = when (sendState) {
SendStateDecoration.SENDING_NON_MEDIA -> {
setImageResource(R.drawable.ic_sending_message)
imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.riotx_text_tertiary))
contentDescription = context.getString(R.string.event_status_a11y_sending)
true
}
SendStateDecoration.SENT -> {
setImageResource(R.drawable.ic_message_sent)
imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.riotx_text_tertiary))
contentDescription = context.getString(R.string.event_status_a11y_sent)
true
}
SendStateDecoration.FAILED -> {
setImageResource(R.drawable.ic_sending_message_failed)
imageTintList = null
contentDescription = context.getString(R.string.event_status_a11y_failed)
true
}

View File

@ -19,6 +19,7 @@ package im.vector.app.core.utils
import android.content.Context
import android.os.Build
import android.text.format.Formatter
import org.threeten.bp.Duration
import java.util.TreeMap
object TextUtils {
@ -68,4 +69,15 @@ object TextUtils {
Formatter.formatFileSize(context, normalizedSize)
}
}
fun formatDuration(duration: Duration): String {
val hours = duration.seconds / 3600
val minutes = (duration.seconds % 3600) / 60
val seconds = duration.seconds % 60
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
}
}

View File

@ -15,12 +15,15 @@
*/
package im.vector.app.features.attachments
import android.app.Activity
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher
import im.vector.app.core.dialogs.PhotoOrVideoDialog
import im.vector.app.core.platform.Restorable
import im.vector.app.features.settings.VectorPreferences
import im.vector.lib.multipicker.MultiPicker
import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
@ -77,10 +80,10 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab
}
/**
* Starts the process for handling image picking
* Starts the process for handling image/video picking
*/
fun selectGallery(activityResultLauncher: ActivityResultLauncher<Intent>) {
MultiPicker.get(MultiPicker.IMAGE).startWith(activityResultLauncher)
MultiPicker.get(MultiPicker.MEDIA).startWith(activityResultLauncher)
}
/**
@ -91,10 +94,21 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab
}
/**
* Starts the process for handling capture image picking
* Starts the process for handling image/video capture. Can open a dialog
*/
fun openCamera(context: Context, activityResultLauncher: ActivityResultLauncher<Intent>) {
captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, activityResultLauncher)
fun openCamera(activity: Activity,
vectorPreferences: VectorPreferences,
cameraActivityResultLauncher: ActivityResultLauncher<Intent>,
cameraVideoActivityResultLauncher: ActivityResultLauncher<Intent>) {
PhotoOrVideoDialog(activity, vectorPreferences).show(object : PhotoOrVideoDialog.PhotoOrVideoDialogListener {
override fun takePhoto() {
captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, cameraActivityResultLauncher)
}
override fun takeVideo() {
captureUri = MultiPicker.get(MultiPicker.CAMERA_VIDEO).startWithExpectingFile(context, cameraVideoActivityResultLauncher)
}
})
}
/**
@ -133,15 +147,15 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab
}
}
fun onImageResult(data: Intent?) {
fun onMediaResult(data: Intent?) {
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.IMAGE)
MultiPicker.get(MultiPicker.MEDIA)
.getSelectedFiles(context, data)
.map { it.toContentAttachmentData() }
)
}
fun onPhotoResult() {
fun onCameraResult() {
captureUri?.let { captureUri ->
MultiPicker.get(MultiPicker.CAMERA)
.getTakenPhoto(context, captureUri)
@ -153,6 +167,18 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab
}
}
fun onCameraVideoResult() {
captureUri?.let { captureUri ->
MultiPicker.get(MultiPicker.CAMERA_VIDEO)
.getTakenVideo(context, captureUri)
?.let {
callback.onContentAttachmentsReady(
listOf(it).map { it.toContentAttachmentData() }
)
}
}
}
fun onVideoResult(data: Intent?) {
callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.VIDEO)

View File

@ -17,6 +17,7 @@
package im.vector.app.features.attachments
import im.vector.lib.multipicker.entity.MultiPickerAudioType
import im.vector.lib.multipicker.entity.MultiPickerBaseMediaType
import im.vector.lib.multipicker.entity.MultiPickerBaseType
import im.vector.lib.multipicker.entity.MultiPickerContactType
import im.vector.lib.multipicker.entity.MultiPickerFileType
@ -69,6 +70,24 @@ private fun MultiPickerBaseType.mapType(): ContentAttachmentData.Type {
}
}
fun MultiPickerBaseType.toContentAttachmentData(): ContentAttachmentData {
return when (this) {
is MultiPickerImageType -> toContentAttachmentData()
is MultiPickerVideoType -> toContentAttachmentData()
is MultiPickerAudioType -> toContentAttachmentData()
is MultiPickerFileType -> toContentAttachmentData()
else -> throw IllegalStateException("Unknown file type")
}
}
fun MultiPickerBaseMediaType.toContentAttachmentData(): ContentAttachmentData {
return when (this) {
is MultiPickerImageType -> toContentAttachmentData()
is MultiPickerVideoType -> toContentAttachmentData()
else -> throw IllegalStateException("Unknown media type")
}
}
fun MultiPickerImageType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData(

View File

@ -21,15 +21,15 @@ import org.matrix.android.sdk.api.util.MimeTypes
private val listOfPreviewableMimeTypes = listOf(
MimeTypes.Jpeg,
MimeTypes.BadJpg,
MimeTypes.Png,
MimeTypes.Gif
)
fun ContentAttachmentData.isPreviewable(): Boolean {
// For now the preview only supports still image
return type == ContentAttachmentData.Type.IMAGE
&& listOfPreviewableMimeTypes.contains(getSafeMimeType() ?: "")
// Preview supports image and video
return (type == ContentAttachmentData.Type.IMAGE
&& listOfPreviewableMimeTypes.contains(getSafeMimeType() ?: ""))
|| type == ContentAttachmentData.Type.VIDEO
}
data class GroupedContentAttachmentData(

View File

@ -18,6 +18,7 @@ package im.vector.app.features.attachments.preview
import android.view.View
import android.widget.ImageView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.Glide
@ -65,6 +66,7 @@ abstract class AttachmentMiniaturePreviewItem : AttachmentPreviewItem<Attachment
override fun bind(holder: Holder) {
super.bind(holder)
holder.imageView.isChecked = checked
holder.miniatureVideoIndicator.isVisible = attachment.type == ContentAttachmentData.Type.VIDEO
holder.view.setOnClickListener(clickListener)
}
@ -72,6 +74,7 @@ abstract class AttachmentMiniaturePreviewItem : AttachmentPreviewItem<Attachment
override val imageView: CheckableImageView
get() = miniatureImageView
private val miniatureImageView by bind<CheckableImageView>(R.id.attachmentMiniatureImageView)
val miniatureVideoIndicator by bind<ImageView>(R.id.attachmentMiniatureVideoIndicator)
}
}

View File

@ -139,7 +139,17 @@ class AttachmentsPreviewFragment @Inject constructor(
attachmentBigPreviewController.setData(state)
views.attachmentPreviewerBigList.scrollToPosition(state.currentAttachmentIndex)
views.attachmentPreviewerMiniatureList.scrollToPosition(state.currentAttachmentIndex)
views.attachmentPreviewerSendImageOriginalSize.text = resources.getQuantityString(R.plurals.send_images_with_original_size, state.attachments.size)
views.attachmentPreviewerSendImageOriginalSize.text = getCheckboxText(state)
}
}
private fun getCheckboxText(state: AttachmentsPreviewViewState): CharSequence {
val nbImages = state.attachments.count { it.type == ContentAttachmentData.Type.IMAGE }
val nbVideos = state.attachments.count { it.type == ContentAttachmentData.Type.VIDEO }
return when {
nbVideos == 0 -> resources.getQuantityString(R.plurals.send_images_with_original_size, nbImages)
nbImages == 0 -> resources.getQuantityString(R.plurals.send_videos_with_original_size, nbVideos)
else -> getString(R.string.send_images_and_video_with_original_size)
}
}

View File

@ -21,6 +21,7 @@ import android.hardware.camera2.CameraManager
import androidx.core.content.getSystemService
import im.vector.app.core.services.CallService
import im.vector.app.core.utils.CountUpTimer
import im.vector.app.core.utils.TextUtils.formatDuration
import im.vector.app.features.call.CameraEventsHandlerAdapter
import im.vector.app.features.call.CameraProxy
import im.vector.app.features.call.CameraType
@ -829,17 +830,6 @@ class WebRtcCall(val mxCall: MxCall,
}
}
private fun formatDuration(duration: Duration): String {
val hours = duration.seconds / 3600
val minutes = (duration.seconds % 3600) / 60
val seconds = duration.seconds % 60
return if (hours > 0) {
String.format("%d:%02d:%02d", hours, minutes, seconds)
} else {
String.format("%02d:%02d", minutes, seconds)
}
}
// MxCall.StateListener
override fun onStateUpdate(call: MxCall) {

View File

@ -101,7 +101,6 @@ import im.vector.app.core.utils.Debouncer
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.core.utils.KeyboardStateUtils
import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES
import im.vector.app.core.utils.TextUtils
import im.vector.app.core.utils.checkPermissions
import im.vector.app.core.utils.colorizeMatchingText
import im.vector.app.core.utils.copyToClipboard
@ -381,7 +380,6 @@ class RoomDetailFragment @Inject constructor(
is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it)
is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message)
is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it)
is RoomDetailViewEvents.FileTooBigError -> displayFileTooBigError(it)
is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it)
is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it)
@ -749,18 +747,6 @@ class RoomDetailFragment @Inject constructor(
}
}
private fun displayFileTooBigError(action: RoomDetailViewEvents.FileTooBigError) {
AlertDialog.Builder(requireActivity())
.setTitle(R.string.dialog_title_error)
.setMessage(getString(R.string.error_file_too_big,
action.filename,
TextUtils.formatFileSize(requireContext(), action.fileSizeInBytes),
TextUtils.formatFileSize(requireContext(), action.homeServerLimitInBytes)
))
.setPositiveButton(R.string.ok, null)
.show()
}
private fun handleDownloadFileState(action: RoomDetailViewEvents.DownloadFileState) {
val activity = requireActivity()
if (action.throwable != null) {
@ -986,7 +972,7 @@ class RoomDetailFragment @Inject constructor(
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
if (it.resultCode == Activity.RESULT_OK) {
attachmentsHelper.onImageResult(it.data)
attachmentsHelper.onFileResult(it.data)
}
}
@ -1002,15 +988,21 @@ class RoomDetailFragment @Inject constructor(
}
}
private val attachmentImageActivityResultLauncher = registerStartForActivityResult {
private val attachmentMediaActivityResultLauncher = registerStartForActivityResult {
if (it.resultCode == Activity.RESULT_OK) {
attachmentsHelper.onImageResult(it.data)
attachmentsHelper.onMediaResult(it.data)
}
}
private val attachmentPhotoActivityResultLauncher = registerStartForActivityResult {
private val attachmentCameraActivityResultLauncher = registerStartForActivityResult {
if (it.resultCode == Activity.RESULT_OK) {
attachmentsHelper.onPhotoResult()
attachmentsHelper.onCameraResult()
}
}
private val attachmentCameraVideoActivityResultLauncher = registerStartForActivityResult {
if (it.resultCode == Activity.RESULT_OK) {
attachmentsHelper.onCameraVideoResult()
}
}
@ -2003,9 +1995,14 @@ class RoomDetailFragment @Inject constructor(
private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) {
when (type) {
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(requireContext(), attachmentPhotoActivityResultLauncher)
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(
activity = requireActivity(),
vectorPreferences = vectorPreferences,
cameraActivityResultLauncher = attachmentCameraActivityResultLauncher,
cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher
)
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher)
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentImageActivityResultLauncher)
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher)
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment)

View File

@ -54,12 +54,6 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
object ShowWaitingView : RoomDetailViewEvents()
object HideWaitingView : RoomDetailViewEvents()
data class FileTooBigError(
val filename: String,
val fileSizeInBytes: Long,
val homeServerLimitInBytes: Long
) : RoomDetailViewEvents()
data class DownloadFileState(
val mimeType: String?,
val file: File?,

View File

@ -79,7 +79,6 @@ import org.matrix.android.sdk.api.session.events.model.isTextMessage
import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams
import org.matrix.android.sdk.api.session.room.model.Membership
@ -292,7 +291,6 @@ class RoomDetailViewModel @AssistedInject constructor(
is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action)
is RoomDetailAction.ResendMessage -> handleResendEvent(action)
is RoomDetailAction.RemoveFailedEcho -> handleRemove(action)
is RoomDetailAction.ResendAll -> handleResendAll()
is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead()
is RoomDetailAction.ReportContent -> handleReportContent(action)
is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action)
@ -1107,23 +1105,7 @@ class RoomDetailViewModel @AssistedInject constructor(
}
private fun handleSendMedia(action: RoomDetailAction.SendMedia) {
val attachments = action.attachments
val homeServerCapabilities = session.getHomeServerCapabilities()
val maxUploadFileSize = homeServerCapabilities.maxUploadFileSize
if (maxUploadFileSize == HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN) {
// Unknown limitation
room.sendMedias(attachments, action.compressBeforeSending, emptySet())
} else {
when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) {
null -> room.sendMedias(attachments, action.compressBeforeSending, emptySet())
else -> _viewEvents.post(RoomDetailViewEvents.FileTooBigError(
tooBigFile.name ?: tooBigFile.queryUri.toString(),
tooBigFile.size,
maxUploadFileSize
))
}
}
room.sendMedias(action.attachments, action.compressBeforeSending, emptySet())
}
private fun handleEventVisible(action: RoomDetailAction.TimelineEventTurnsVisible) {

View File

@ -28,16 +28,19 @@ import im.vector.app.core.epoxy.bottomsheet.bottomSheetMessagePreviewItem
import im.vector.app.core.epoxy.bottomsheet.bottomSheetQuickReactionsItem
import im.vector.app.core.epoxy.bottomsheet.bottomSheetSendStateItem
import im.vector.app.core.epoxy.dividerItem
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.resources.StringProvider
import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
import im.vector.app.features.home.room.detail.timeline.format.EventDetailsFormatter
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration
import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod
import im.vector.app.features.home.room.detail.timeline.tools.linkify
import im.vector.app.features.media.ImageContentRenderer
import org.matrix.android.sdk.api.extensions.orFalse
import org.matrix.android.sdk.api.failure.Failure
import org.matrix.android.sdk.api.session.room.send.SendState
import javax.inject.Inject
@ -50,6 +53,8 @@ class MessageActionsEpoxyController @Inject constructor(
private val fontProvider: EmojiCompatFontProvider,
private val imageContentRenderer: ImageContentRenderer,
private val dimensionConverter: DimensionConverter,
private val errorFormatter: ErrorFormatter,
private val eventDetailsFormatter: EventDetailsFormatter,
private val dateFormatter: VectorDateFormatter
) : TypedEpoxyController<MessageActionState>() {
@ -68,16 +73,21 @@ class MessageActionsEpoxyController @Inject constructor(
data(state.timelineEvent()?.buildImageContentRendererData(dimensionConverter.dpToPx(66)))
userClicked { listener?.didSelectMenuAction(EventSharedAction.OpenUserProfile(state.informationData.senderId)) }
body(state.messageBody.linkify(listener))
bodyDetails(eventDetailsFormatter.format(state.timelineEvent()?.root))
time(formattedDate)
}
// Send state
val sendState = state.sendState()
if (sendState?.hasFailed().orFalse()) {
// Get more details about the error
val errorMessage = state.timelineEvent()?.root?.sendStateError()
?.let { errorFormatter.toHumanReadable(Failure.ServerError(it, 0)) }
?: stringProvider.getString(R.string.unable_to_send_message)
bottomSheetSendStateItem {
id("send_state")
showProgress(false)
text(stringProvider.getString(R.string.unable_to_send_message))
text(errorMessage)
drawableStart(R.drawable.ic_warning_badge)
}
} else if (sendState?.isSending().orFalse()) {

View File

@ -85,6 +85,7 @@ import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_POLL
import org.matrix.android.sdk.api.session.room.model.message.getFileName
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
@ -337,8 +338,7 @@ class MessageItemFactory @Inject constructor(
eventId = informationData.eventId,
filename = messageContent.body,
mimeType = messageContent.mimeType,
url = messageContent.videoInfo?.thumbnailFile?.url
?: messageContent.videoInfo?.thumbnailUrl,
url = messageContent.videoInfo?.getThumbnailUrl(),
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height,
maxHeight = maxHeight,

View File

@ -0,0 +1,92 @@
/*
* Copyright 2021 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 im.vector.app.features.home.room.detail.timeline.format
import android.content.Context
import im.vector.app.core.utils.TextUtils
import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.isAudioMessage
import org.matrix.android.sdk.api.session.events.model.isFileMessage
import org.matrix.android.sdk.api.session.events.model.isImageMessage
import org.matrix.android.sdk.api.session.events.model.isVideoMessage
import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent
import org.matrix.android.sdk.api.session.room.model.message.MessageFileContent
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.threeten.bp.Duration
import javax.inject.Inject
class EventDetailsFormatter @Inject constructor(
private val context: Context
) {
fun format(event: Event?): CharSequence? {
event ?: return null
if (event.isRedacted()) {
return null
}
if (event.isEncrypted() && event.mxDecryptionResult == null) {
return null
}
return when {
event.isImageMessage() -> formatForImageMessage(event)
event.isVideoMessage() -> formatForVideoMessage(event)
event.isAudioMessage() -> formatForAudioMessage(event)
event.isFileMessage() -> formatForFileMessage(event)
else -> null
}
}
/**
* Example: "1024 x 720 - 670 kB"
*/
private fun formatForImageMessage(event: Event): CharSequence? {
return event.getClearContent().toModel<MessageImageContent>()?.info
?.let { "${it.width} x ${it.height} - ${it.size.asFileSize()}" }
}
/**
* Example: "02:45 - 1024 x 720 - 670 kB"
*/
private fun formatForVideoMessage(event: Event): CharSequence? {
return event.getClearContent().toModel<MessageVideoContent>()?.videoInfo
?.let { "${it.duration.asDuration()} - ${it.width} x ${it.height} - ${it.size.asFileSize()}" }
}
/**
* Example: "02:45 - 670 kB"
*/
private fun formatForAudioMessage(event: Event): CharSequence? {
return event.getClearContent().toModel<MessageAudioContent>()?.audioInfo
?.let { "${it.duration.asDuration()} - ${it.size.asFileSize()}" }
}
/**
* Example: "670 kB - application/pdf"
*/
private fun formatForFileMessage(event: Event): CharSequence? {
return event.getClearContent().toModel<MessageFileContent>()?.info
?.let { "${it.size.asFileSize()} - ${it.mimeType}" }
}
private fun Long.asFileSize() = TextUtils.formatFileSize(context, this)
private fun Int.asDuration() = TextUtils.formatDuration(Duration.ofMillis(toLong()))
}

View File

@ -16,6 +16,7 @@
package im.vector.app.features.home.room.detail.timeline.helper
import android.annotation.SuppressLint
import android.view.View
import android.view.ViewGroup
import android.widget.ProgressBar
@ -25,6 +26,7 @@ import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.di.ScreenScope
import im.vector.app.core.error.ErrorFormatter
import im.vector.app.core.extensions.exhaustive
import im.vector.app.core.utils.TextUtils
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import org.matrix.android.sdk.api.session.content.ContentUploadStateTracker
@ -70,6 +72,9 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
private val messageColorProvider: MessageColorProvider,
private val errorFormatter: ErrorFormatter) : ContentUploadStateTracker.UpdateListener {
private val progressBar: ProgressBar = progressLayout.findViewById(R.id.mediaProgressBar)
private val progressTextView: TextView = progressLayout.findViewById(R.id.mediaProgressTextView)
override fun onUpdate(state: ContentUploadStateTracker.State) {
when (state) {
is ContentUploadStateTracker.State.Idle -> handleIdle()
@ -79,19 +84,19 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
is ContentUploadStateTracker.State.Uploading -> handleProgress(state)
is ContentUploadStateTracker.State.Failure -> handleFailure(/*state*/)
is ContentUploadStateTracker.State.Success -> handleSuccess()
}
is ContentUploadStateTracker.State.CompressingImage -> handleCompressingImage()
is ContentUploadStateTracker.State.CompressingVideo -> handleCompressingVideo(state)
}.exhaustive
}
private fun handleIdle() {
if (isLocalFile) {
progressLayout.isVisible = true
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.isVisible = true
progressBar?.isIndeterminate = true
progressBar?.progress = 0
progressTextView?.text = progressLayout.context.getString(R.string.send_file_step_idle)
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT))
progressBar.isVisible = true
progressBar.isIndeterminate = true
progressBar.progress = 0
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_idle)
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT))
} else {
progressLayout.isVisible = false
}
@ -113,38 +118,54 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
doHandleProgress(R.string.send_file_step_sending_file, state.current, state.total)
}
private fun handleCompressingImage() {
progressLayout.visibility = View.VISIBLE
progressBar.isVisible = true
progressBar.isIndeterminate = true
progressTextView.isVisible = true
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_compressing_image)
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
}
// Add SuppressLint to fix a false positive
@SuppressLint("StringFormatMatches")
private fun handleCompressingVideo(state: ContentUploadStateTracker.State.CompressingVideo) {
progressLayout.visibility = View.VISIBLE
progressBar.isVisible = true
progressBar.isIndeterminate = false
progressBar.progress = state.percent.toInt()
progressTextView.isVisible = true
// False positive is here...
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_compressing_video, state.percent.toInt())
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
}
private fun doHandleEncrypting(resId: Int, current: Long, total: Long) {
progressLayout.visibility = View.VISIBLE
val percent = if (total > 0) (100L * (current.toFloat() / total.toFloat())) else 0f
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.isIndeterminate = false
progressBar?.progress = percent.toInt()
progressBar.isIndeterminate = false
progressBar.progress = percent.toInt()
progressTextView.isVisible = true
progressTextView?.text = progressLayout.context.getString(resId)
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING))
progressTextView.text = progressLayout.context.getString(resId)
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING))
}
private fun doHandleProgress(resId: Int, current: Long, total: Long) {
progressLayout.visibility = View.VISIBLE
val percent = 100L * (current.toFloat() / total.toFloat())
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.isVisible = true
progressBar?.isIndeterminate = false
progressBar?.progress = percent.toInt()
progressBar.isVisible = true
progressBar.isIndeterminate = false
progressBar.progress = percent.toInt()
progressTextView.isVisible = true
progressTextView?.text = progressLayout.context.getString(resId,
progressTextView.text = progressLayout.context.getString(resId,
TextUtils.formatFileSize(progressLayout.context, current, true),
TextUtils.formatFileSize(progressLayout.context, total, true))
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
}
private fun handleFailure(/*state: ContentUploadStateTracker.State.Failure*/) {
progressLayout.visibility = View.VISIBLE
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.isVisible = false
progressBar.isVisible = false
// Do not show the message it's too technical for users, and unfortunate when upload is cancelled
// in the middle by turning airplane mode for example
progressTextView.isVisible = false

View File

@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
@ -45,15 +46,16 @@ fun TimelineEvent.buildImageContentRendererData(maxHeight: Int): ImageContentRen
}
root.isVideoMessage() -> root.getClearContent().toModel<MessageVideoContent>()
?.let { messageVideoContent ->
val videoInfo = messageVideoContent.videoInfo
ImageContentRenderer.Data(
eventId = eventId,
filename = messageVideoContent.body,
mimeType = messageVideoContent.mimeType,
url = messageVideoContent.getFileUrl(),
elementToDecrypt = messageVideoContent.encryptedFileInfo?.toElementToDecrypt(),
height = messageVideoContent.videoInfo?.height,
mimeType = videoInfo?.thumbnailInfo?.mimeType,
url = videoInfo?.getThumbnailUrl(),
elementToDecrypt = videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = videoInfo?.thumbnailInfo?.height,
maxHeight = maxHeight,
width = messageVideoContent.videoInfo?.width,
width = videoInfo?.thumbnailInfo?.width,
maxWidth = maxHeight * 2,
allowNonMxcUrls = false
)

View File

@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
@ -78,8 +79,7 @@ class RoomEventsAttachmentProvider(
eventId = it.eventId,
filename = content.body,
mimeType = content.mimeType,
url = content.videoInfo?.thumbnailFile?.url
?: content.videoInfo?.thumbnailUrl,
url = content.videoInfo?.getThumbnailUrl(),
elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = content.videoInfo?.height,
maxHeight = -1,
@ -102,8 +102,7 @@ class RoomEventsAttachmentProvider(
data = data,
thumbnail = AttachmentInfo.Image(
uid = it.eventId,
url = content.videoInfo?.thumbnailFile?.url
?: content.videoInfo?.thumbnailUrl ?: "",
url = content.videoInfo?.getThumbnailUrl() ?: "",
data = thumbnailData
)

View File

@ -50,6 +50,7 @@ import im.vector.app.features.roomprofile.uploads.RoomUploadsViewState
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import javax.inject.Inject
@ -141,8 +142,7 @@ class RoomUploadsMediaFragment @Inject constructor(
eventId = it.eventId,
filename = content.body,
mimeType = content.mimeType,
url = content.videoInfo?.thumbnailFile?.url
?: content.videoInfo?.thumbnailUrl,
url = content.videoInfo?.getThumbnailUrl(),
elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = content.videoInfo?.height,
maxHeight = -1,

View File

@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.api.session.room.uploads.UploadEvent
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import javax.inject.Inject
@ -131,7 +132,7 @@ class UploadsMediaController @Inject constructor(
eventId = eventId,
filename = messageContent.body,
mimeType = messageContent.mimeType,
url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl,
url = messageContent.videoInfo?.getThumbnailUrl(),
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height,
maxHeight = itemSize,

View File

@ -193,6 +193,13 @@ class VectorPreferences @Inject constructor(private val context: Context) {
private const val SETTINGS_UNKNOWN_DEVICE_DISMISSED_LIST = "SETTINGS_UNKNWON_DEVICE_DISMISSED_LIST"
private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE"
// Possible values for TAKE_PHOTO_VIDEO_MODE
const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0
const val TAKE_PHOTO_VIDEO_MODE_PHOTO = 1
const val TAKE_PHOTO_VIDEO_MODE_VIDEO = 2
// Background sync modes
// some preferences keys must be kept after a logout
@ -948,4 +955,17 @@ class VectorPreferences @Inject constructor(private val context: Context) {
fun labsUseExperimentalRestricted(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE, false)
}
/*
* Photo / video picker
*/
fun getTakePhotoVideoMode(): Int {
return defaultPrefs.getInt(TAKE_PHOTO_VIDEO_MODE, TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK)
}
fun setTakePhotoVideoMode(mode: Int) {
return defaultPrefs.edit {
putInt(TAKE_PHOTO_VIDEO_MODE, mode)
}
}
}

View File

@ -23,6 +23,7 @@ import androidx.appcompat.app.AlertDialog
import androidx.core.view.children
import androidx.preference.Preference
import im.vector.app.R
import im.vector.app.core.dialogs.PhotoOrVideoDialog
import im.vector.app.core.extensions.restart
import im.vector.app.core.preference.VectorListPreference
import im.vector.app.core.preference.VectorPreference
@ -45,6 +46,9 @@ class VectorSettingsPreferencesFragment @Inject constructor(
private val textSizePreference by lazy {
findPreference<VectorPreference>(VectorPreferences.SETTINGS_INTERFACE_TEXT_SIZE_KEY)!!
}
private val takePhotoOrVideoPreference by lazy {
findPreference<VectorPreference>("SETTINGS_INTERFACE_TAKE_PHOTO_VIDEO")!!
}
override fun bindPref() {
// user interface preferences
@ -123,6 +127,28 @@ class VectorSettingsPreferencesFragment @Inject constructor(
false
}
}
// Take photo or video
updateTakePhotoOrVideoPreferenceSummary()
takePhotoOrVideoPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
PhotoOrVideoDialog(requireActivity(), vectorPreferences).showForSettings(object: PhotoOrVideoDialog.PhotoOrVideoDialogSettingsListener {
override fun onUpdated() {
updateTakePhotoOrVideoPreferenceSummary()
}
})
true
}
}
private fun updateTakePhotoOrVideoPreferenceSummary() {
takePhotoOrVideoPreference.summary = getString(
when (vectorPreferences.getTakePhotoVideoMode()) {
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> R.string.option_take_photo
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> R.string.option_take_video
/* VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK */
else -> R.string.option_always_ask
}
)
}
// ==============================================================================================================

View File

@ -0,0 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout_root"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:paddingStart="?dialogPreferredPadding"
android:paddingTop="12dp"
android:paddingEnd="?dialogPreferredPadding"
android:paddingBottom="12dp">
<RadioGroup
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.radiobutton.MaterialRadioButton
android:id="@+id/dialog_photo_or_video_photo"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/option_take_photo"
tools:checked="true" />
<com.google.android.material.radiobutton.MaterialRadioButton
android:id="@+id/dialog_photo_or_video_video"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="180dp"
android:text="@string/option_take_video" />
<!-- Displayed only form the settings -->
<com.google.android.material.radiobutton.MaterialRadioButton
android:id="@+id/dialog_photo_or_video_always_ask"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:minWidth="180dp"
android:text="@string/option_always_ask"
android:visibility="gone"
tools:visibility="visible" />
</RadioGroup>
<!-- Displayed only form the timeline -->
<com.google.android.material.checkbox.MaterialCheckBox
android:id="@+id/dialog_photo_or_video_as_default"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/use_as_default_and_do_not_ask_again"
android:visibility="gone"
tools:visibility="visible" />
</LinearLayout>

View File

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:card_view="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content"
@ -17,4 +18,14 @@
android:scaleType="centerCrop"
tools:src="@tools:sample/backgrounds/scenic" />
<ImageView
android:id="@+id/attachmentMiniatureVideoIndicator"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center"
android:contentDescription="@string/a11y_video"
app:srcCompat="@drawable/ic_play_arrow"
app:tint="@color/white"
tools:ignore="MissingPrefix" />
</androidx.cardview.widget.CardView>

View File

@ -76,10 +76,29 @@
android:textColor="?riotx_text_secondary"
android:textIsSelectable="false"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_message_preview_body_details"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_image"
tools:text="Quis harum id autem cumque consequatur laboriosam aliquam sed. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. " />
app:layout_goneMarginBottom="4dp"
tools:text="Quis harum id autem cumque consequatur laboriosam aliquam sed. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. Sint accusamus dignissimos nobis ullam earum debitis aspernatur." />
<TextView
android:id="@+id/bottom_sheet_message_preview_body_details"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="4dp"
android:ellipsize="end"
android:maxLines="1"
android:textColor="?riotx_text_tertiary"
android:textIsSelectable="false"
android:textSize="12sp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@id/bottom_sheet_message_preview_body"
app:layout_constraintStart_toStartOf="@id/bottom_sheet_message_preview_body"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_body"
tools:text="1080 x 1024 - 43s - 12kB"
tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -145,8 +145,8 @@
android:layout_marginBottom="4dp"
android:contentDescription="@string/event_status_a11y_sending"
android:src="@drawable/ic_sending_message"
android:tint="?riotx_text_tertiary"
android:visibility="gone"
tools:tint="?riotx_text_tertiary"
tools:visibility="visible" />
<ProgressBar
@ -154,11 +154,11 @@
android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size"
android:layout_alignBottom="@+id/viewStubContainer"
android:indeterminateTint="?riotx_text_secondary"
android:layout_alignParentEnd="true"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp"
android:indeterminateTint="?riotx_text_secondary"
android:tint="?riotx_text_tertiary"
android:visibility="gone"
tools:visibility="visible" />

View File

@ -575,6 +575,9 @@
<string name="option_take_photo_video">Take photo or video</string>
<string name="option_take_photo">Take photo</string>
<string name="option_take_video">Take video</string>
<string name="option_always_ask">Always ask</string>
<string name="use_as_default_and_do_not_ask_again">Use as default and do not ask again</string>
<!-- No sticker application dialog -->
<string name="no_sticker_application_dialog_content">You dont currently have any stickerpacks enabled.\n\nAdd some now?</string>
@ -2174,6 +2177,8 @@
<string name="send_file_step_sending_thumbnail">Sending thumbnail (%1$s / %2$s)</string>
<string name="send_file_step_encrypting_file">Encrypting file…</string>
<string name="send_file_step_sending_file">Sending file (%1$s / %2$s)</string>
<string name="send_file_step_compressing_image">Compressing image…</string>
<string name="send_file_step_compressing_video">Compressing video %d%%</string>
<string name="downloading_file">Downloading file %1$s…</string>
<string name="downloaded_file">File %1$s has been downloaded!</string>
@ -2295,6 +2300,7 @@
<item quantity="other">%d users read</item>
</plurals>
<string name="error_file_too_big_simple">"The file is too large to upload."</string>
<string name="error_file_too_big">"The file '%1$s' (%2$s) is too large to upload. The limit is %3$s."</string>
<string name="error_attachment">"An error occurred while retrieving the attachment."</string>
@ -2785,6 +2791,11 @@
<item quantity="one">Send image with the original size</item>
<item quantity="other">Send images with the original size</item>
</plurals>
<plurals name="send_videos_with_original_size">
<item quantity="one">Send video with the original size</item>
<item quantity="other">Send videos with the original size</item>
</plurals>
<string name="send_images_and_video_with_original_size">Send media with the original size</string>
<string name="delete_event_dialog_title">Confirm Removal</string>
<string name="delete_event_dialog_content">Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.</string>

View File

@ -3,7 +3,7 @@
<!-- DARK THEME COLORS -->
<style name="AppTheme.Base.Dark" parent="Theme.MaterialComponents.NoActionBar.Bridge">
<style name="AppTheme.Base.Dark" parent="Theme.MaterialComponents.NoActionBar">
<!-- Riotx attribute for palette -->
<item name="riotx_background">@color/riotx_background_dark</item>
<item name="vctr_home_drawer_header_background">@color/vctr_home_drawer_header_background_dark</item>

View File

@ -1,6 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools">
<im.vector.app.core.preference.VectorPreferenceCategory
android:key="SETTINGS_USER_INTERFACE_KEY"
@ -55,6 +56,12 @@
android:summary="@string/settings_show_emoji_keyboard_summary"
android:title="@string/settings_show_emoji_keyboard" />
<im.vector.app.core.preference.VectorPreference
android:key="SETTINGS_INTERFACE_TAKE_PHOTO_VIDEO"
android:persistent="false"
android:title="@string/option_take_photo_video"
tools:summary="@string/option_always_ask" />
</im.vector.app.core.preference.VectorPreferenceCategory>
<im.vector.app.core.preference.VectorPreferenceCategory android:title="@string/settings_category_timeline">
@ -185,10 +192,10 @@
<im.vector.app.core.preference.VectorPreferenceCategory android:title="@string/settings_category_room_directory">
<im.vector.app.core.preference.VectorSwitchPreference
android:key="SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS"
android:summary="@string/settings_room_directory_show_all_rooms_summary"
android:title="@string/settings_room_directory_show_all_rooms" />
<im.vector.app.core.preference.VectorSwitchPreference
android:key="SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS"
android:summary="@string/settings_room_directory_show_all_rooms_summary"
android:title="@string/settings_room_directory_show_all_rooms" />
</im.vector.app.core.preference.VectorPreferenceCategory>
</androidx.preference.PreferenceScreen>