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 🙌: Improvements 🙌:
- Add ability to install APK from directly from Element (#2381) - Add ability to install APK from directly from Element (#2381)
- Delete and react to stickers (#3250) - 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 🐛: Bugfix 🐛:
- Message states cosmetic changes (#3007) - Message states cosmetic changes (#3007)
@ -18,6 +22,7 @@ Bugfix 🐛:
- Fix wording issue (#3242) - Fix wording issue (#3242)
- Fix missing sender information after edits (#3184) - Fix missing sender information after edits (#3184)
- Fix read marker not updating automatically (#3267) - Fix read marker not updating automatically (#3267)
- Sent video does not contains duration (#3272)
Translations 🗣: Translations 🗣:
- -

View File

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

View File

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

View File

@ -168,6 +168,9 @@ dependencies {
implementation 'com.jakewharton.timber:timber:4.7.1' implementation 'com.jakewharton.timber:timber:4.7.1'
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0' 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 // Phone number https://github.com/google/libphonenumber
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.22' implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.22'

View File

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

View File

@ -31,6 +31,8 @@ interface ContentUploadStateTracker {
sealed class State { sealed class State {
object Idle : State() object Idle : State()
object EncryptingThumbnail : 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 UploadingThumbnail(val current: Long, val total: Long) : State()
data class Encrypting(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() 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.crypto.model.event.EncryptedEventContent
import org.matrix.android.sdk.internal.di.MoshiProvider import org.matrix.android.sdk.internal.di.MoshiProvider
import org.json.JSONObject import org.json.JSONObject
import org.matrix.android.sdk.api.extensions.tryOrNull
import org.matrix.android.sdk.api.failure.MatrixError
import timber.log.Timber import timber.log.Timber
typealias Content = JsonDict typealias Content = JsonDict
@ -90,6 +92,16 @@ data class Event(
@Transient @Transient
var sendState: SendState = SendState.UNKNOWN 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 `age` value transcoded in a timestamp based on the device clock when the SDK received
* the event from the home server. * the event from the home server.

View File

@ -47,3 +47,10 @@ data class FileInfo(
*/ */
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null @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. * 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. * Metadata about the image referred to in thumbnail_url.
@ -57,3 +57,10 @@ data class ImageInfo(
*/ */
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null @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 @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 @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.RoomAPI
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.toMatrixErrorStr
import javax.inject.Inject import javax.inject.Inject
internal interface SendVerificationMessageTask : Task<SendVerificationMessageTask.Params, String> { internal interface SendVerificationMessageTask : Task<SendVerificationMessageTask.Params, String> {
@ -55,7 +56,7 @@ internal class DefaultSendVerificationMessageTask @Inject constructor(
localEchoRepository.updateSendState(localId, event.roomId, SendState.SENT) localEchoRepository.updateSendState(localId, event.roomId, SendState.SENT)
return response.eventId return response.eventId
} catch (e: Throwable) { } catch (e: Throwable) {
localEchoRepository.updateSendState(localId, event.roomId, SendState.UNDELIVERED) localEchoRepository.updateSendState(localId, event.roomId, SendState.UNDELIVERED, e.toMatrixErrorStr())
throw e throw e
} }
} }

View File

@ -43,7 +43,7 @@ import javax.inject.Inject
class RealmSessionStoreMigration @Inject constructor() : RealmMigration { class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
companion object { 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) { 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 <= 7) migrateTo8(realm)
if (oldVersion <= 8) migrateTo9(realm) if (oldVersion <= 8) migrateTo9(realm)
if (oldVersion <= 9) migrateTo10(realm) if (oldVersion <= 9) migrateTo10(realm)
if (oldVersion <= 10) migrateTo11(realm)
} }
private fun migrateTo1(realm: DynamicRealm) { private fun migrateTo1(realm: DynamicRealm) {
@ -163,7 +164,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
?.addRealmListField(EditAggregatedSummaryEntityFields.EDITIONS.`$`, editionOfEventSchema) ?.addRealmListField(EditAggregatedSummaryEntityFields.EDITIONS.`$`, editionOfEventSchema)
} }
fun migrateTo9(realm: DynamicRealm) { private fun migrateTo9(realm: DynamicRealm) {
Timber.d("Step 8 -> 9") Timber.d("Step 8 -> 9")
realm.schema.get("RoomSummaryEntity") 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") Timber.d("Step 9 -> 10")
realm.schema.create("SpaceChildSummaryEntity") realm.schema.create("SpaceChildSummaryEntity")
?.addField(SpaceChildSummaryEntityFields.ORDER, String::class.java) ?.addField(SpaceChildSummaryEntityFields.ORDER, String::class.java)
@ -240,4 +241,10 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
?.addRealmListField(RoomSummaryEntityFields.PARENTS.`$`, realm.schema.get("SpaceParentSummaryEntity")!!) ?.addRealmListField(RoomSummaryEntityFields.PARENTS.`$`, realm.schema.get("SpaceParentSummaryEntity")!!)
?.addRealmListField(RoomSummaryEntityFields.CHILDREN.`$`, realm.schema.get("SpaceChildSummaryEntity")!!) ?.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 { ).also {
it.ageLocalTs = eventEntity.ageLocalTs it.ageLocalTs = eventEntity.ageLocalTs
it.sendState = eventEntity.sendState it.sendState = eventEntity.sendState
it.sendStateDetails = eventEntity.sendStateDetails
eventEntity.decryptionResultJson?.let { json -> eventEntity.decryptionResultJson?.let { json ->
try { try {
it.mxDecryptionResult = MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).fromJson(json) 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, @Index var stateKey: String? = null,
var originServerTs: Long? = null, var originServerTs: Long? = null,
@Index var sender: String? = null, @Index var sender: String? = null,
// Can contain a serialized MatrixError
var sendStateDetails: String? = null,
var age: Long? = 0, var age: Long? = 0,
var unsignedData: String? = null, var unsignedData: String? = null,
var redacts: 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 org.matrix.android.sdk.internal.di.MoshiProvider
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import okhttp3.ResponseBody import okhttp3.ResponseBody
import org.matrix.android.sdk.api.extensions.orFalse
import retrofit2.HttpException import retrofit2.HttpException
import retrofit2.Response import retrofit2.Response
import timber.log.Timber import timber.log.Timber
@ -91,7 +92,7 @@ private fun toFailure(errorBody: ResponseBody?, httpCode: Int, globalErrorReceiv
} else if (httpCode == HttpURLConnection.HTTP_UNAUTHORIZED /* 401 */ } else if (httpCode == HttpURLConnection.HTTP_UNAUTHORIZED /* 401 */
&& matrixError.code == MatrixError.M_UNKNOWN_TOKEN) { && matrixError.code == MatrixError.M_UNKNOWN_TOKEN) {
// Also send this error to the globalErrorReceiver, for a global management // 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) return Failure.ServerError(matrixError, httpCode)

View File

@ -78,6 +78,16 @@ internal class DefaultContentUploadStateTracker @Inject constructor() : ContentU
updateState(key, progressData) 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) { internal fun setProgress(key: String, current: Long, total: Long) {
val progressData = ContentUploadStateTracker.State.Uploading(current, total) val progressData = ContentUploadStateTracker.State.Uploading(current, total)
updateState(key, progressData) updateState(key, progressData)

View File

@ -31,22 +31,28 @@ import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink import okio.BufferedSink
import okio.source import okio.source
import org.matrix.android.sdk.api.extensions.tryOrNull 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.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.di.Authenticated
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
import org.matrix.android.sdk.internal.network.ProgressRequestBody import org.matrix.android.sdk.internal.network.ProgressRequestBody
import org.matrix.android.sdk.internal.network.awaitResponse import org.matrix.android.sdk.internal.network.awaitResponse
import org.matrix.android.sdk.internal.network.toFailure 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.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
internal class FileUploader @Inject constructor(@Authenticated internal class FileUploader @Inject constructor(@Authenticated
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val globalErrorReceiver: GlobalErrorReceiver, private val globalErrorReceiver: GlobalErrorReceiver,
private val homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService,
private val context: Context, private val context: Context,
private val temporaryFileCreator: TemporaryFileCreator,
contentUrlResolver: ContentUrlResolver, contentUrlResolver: ContentUrlResolver,
moshi: Moshi) { moshi: Moshi) {
@ -57,6 +63,21 @@ internal class FileUploader @Inject constructor(@Authenticated
filename: String?, filename: String?,
mimeType: String?, mimeType: String?,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { 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() { val uploadBody = object : RequestBody() {
override fun contentLength() = file.length() override fun contentLength() = file.length()
@ -90,7 +111,7 @@ internal class FileUploader @Inject constructor(@Authenticated
val inputStream = withContext(Dispatchers.IO) { val inputStream = withContext(Dispatchers.IO) {
context.contentResolver.openInputStream(uri) context.contentResolver.openInputStream(uri)
} ?: throw FileNotFoundException() } ?: throw FileNotFoundException()
val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) val workingFile = temporaryFileCreator.create()
workingFile.outputStream().use { workingFile.outputStream().use {
inputStream.copyTo(it) inputStream.copyTo(it)
} }

View File

@ -16,19 +16,20 @@
package org.matrix.android.sdk.internal.session.content package org.matrix.android.sdk.internal.session.content
import android.content.Context
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.graphics.Matrix import android.graphics.Matrix
import androidx.exifinterface.media.ExifInterface import androidx.exifinterface.media.ExifInterface
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.matrix.android.sdk.internal.util.TemporaryFileCreator
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID
import javax.inject.Inject 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( suspend fun compress(
imageFile: File, imageFile: File,
desiredWidth: Int, desiredWidth: Int,
@ -45,7 +46,7 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
} }
} ?: return@withContext imageFile } ?: return@withContext imageFile
val destinationFile = createDestinationFile() val destinationFile = temporaryFileCreator.create()
runCatching { runCatching {
destinationFile.outputStream().use { 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 orientation = exifInfo.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
val matrix = Matrix() val matrix = Matrix()
when (orientation) { when (orientation) {
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f) ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f) ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f) ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f) ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f) ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
ExifInterface.ORIENTATION_TRANSPOSE -> { ExifInterface.ORIENTATION_TRANSPOSE -> {
matrix.preRotate(-90f) matrix.preRotate(-90f)
matrix.preScale(-1f, 1f) matrix.preScale(-1f, 1f)
} }
ExifInterface.ORIENTATION_TRANSVERSE -> { ExifInterface.ORIENTATION_TRANSVERSE -> {
matrix.preRotate(90f) matrix.preRotate(90f)
matrix.preScale(-1f, 1f) matrix.preScale(-1f, 1f)
} }
@ -116,8 +117,4 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
null 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.content.Context
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import android.media.MediaMetadataRetriever
import androidx.core.net.toUri
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import org.matrix.android.sdk.api.extensions.tryOrNull 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.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.toContent 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.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.LocalEchoIdentifiers
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository 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.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.SessionSafeCoroutineWorker
import org.matrix.android.sdk.internal.worker.SessionWorkerParams import org.matrix.android.sdk.internal.worker.SessionWorkerParams
import org.matrix.android.sdk.internal.worker.WorkerParamsFactory import org.matrix.android.sdk.internal.worker.WorkerParamsFactory
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
private data class NewAttachmentAttributes( private data class NewAttachmentAttributes(
@ -77,7 +81,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
@Inject lateinit var fileService: DefaultFileService @Inject lateinit var fileService: DefaultFileService
@Inject lateinit var cancelSendTracker: CancelSendTracker @Inject lateinit var cancelSendTracker: CancelSendTracker
@Inject lateinit var imageCompressor: ImageCompressor @Inject lateinit var imageCompressor: ImageCompressor
@Inject lateinit var videoCompressor: VideoCompressor
@Inject lateinit var localEchoRepository: LocalEchoRepository @Inject lateinit var localEchoRepository: LocalEchoRepository
@Inject lateinit var temporaryFileCreator: TemporaryFileCreator
override fun injectWith(injector: SessionComponent) { override fun injectWith(injector: SessionComponent) {
injector.inject(this) injector.inject(this)
@ -109,7 +115,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
val attachment = params.attachment val attachment = params.attachment
val filesToDelete = mutableListOf<File>() val filesToDelete = mutableListOf<File>()
try { return try {
val inputStream = context.contentResolver.openInputStream(attachment.queryUri) val inputStream = context.contentResolver.openInputStream(attachment.queryUri)
?: return Result.success( ?: return Result.success(
WorkerParamsFactory.toData( 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 // 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) } .also { filesToDelete.add(it) }
workingFile.outputStream().use { outputStream -> workingFile.outputStream().use { outputStream ->
inputStream.use { inputStream -> inputStream.use { inputStream ->
@ -128,8 +134,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
} }
} }
val uploadThumbnailResult = dealWithThumbnail(params)
val progressListener = object : ProgressRequestBody.Listener { val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) { override fun onProgress(current: Long, total: Long) {
notifyTracker(params) { notifyTracker(params) {
@ -144,7 +148,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
return try { try {
val fileToUpload: File val fileToUpload: File
var newAttachmentAttributes = NewAttachmentAttributes( var newAttachmentAttributes = NewAttachmentAttributes(
params.attachment.width?.toInt(), params.attachment.width?.toInt(),
@ -156,6 +160,8 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
// Do not compress gif // Do not compress gif
&& attachment.mimeType != MimeTypes.Gif && attachment.mimeType != MimeTypes.Gif
&& params.compressBeforeSending) { && params.compressBeforeSending) {
notifyTracker(params) { contentUploadStateTracker.setCompressingImage(it) }
fileToUpload = imageCompressor.compress(workingFile, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE) fileToUpload = imageCompressor.compress(workingFile, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE)
.also { compressedFile -> .also { compressedFile ->
// Get new Bitmap size // Get new Bitmap size
@ -170,6 +176,48 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
} }
} }
.also { filesToDelete.add(it) } .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 { } else {
fileToUpload = workingFile fileToUpload = workingFile
// Fix: OpenableColumns.SIZE may return -1 or 0 // Fix: OpenableColumns.SIZE may return -1 or 0
@ -180,9 +228,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
val encryptedFile: File? val encryptedFile: File?
val contentUploadResponse = if (params.isEncrypted) { 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) } .also { filesToDelete.add(it) }
uploadedFileEncryptedFileInfo = uploadedFileEncryptedFileInfo =
@ -192,18 +240,18 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
} }
} }
Timber.v("## FileService: Uploading file") Timber.v("## Uploading file")
fileUploader fileUploader
.uploadFile(encryptedFile, attachment.name, MimeTypes.OctetStream, progressListener) .uploadFile(encryptedFile, attachment.name, MimeTypes.OctetStream, progressListener)
} else { } else {
Timber.v("## FileService: Clear file") Timber.v("## Clear file")
encryptedFile = null encryptedFile = null
fileUploader fileUploader
.uploadFile(fileToUpload, attachment.name, attachment.getSafeMimeType(), progressListener) .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 { try {
fileService.storeDataFor( fileService.storeDataFor(
mxcUrl = contentUploadResponse.contentUri, mxcUrl = contentUploadResponse.contentUri,
@ -212,11 +260,13 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
originalFile = workingFile, originalFile = workingFile,
encryptedFile = encryptedFile encryptedFile = encryptedFile
) )
Timber.v("## FileService: cache storage updated") Timber.v("## cache storage updated")
} catch (failure: Throwable) { } 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, handleSuccess(params,
contentUploadResponse.contentUri, contentUploadResponse.contentUri,
uploadedFileEncryptedFileInfo, uploadedFileEncryptedFileInfo,
@ -224,12 +274,12 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
uploadThumbnailResult?.uploadedThumbnailEncryptedFileInfo, uploadThumbnailResult?.uploadedThumbnailEncryptedFileInfo,
newAttachmentAttributes) newAttachmentAttributes)
} catch (t: Throwable) { } catch (t: Throwable) {
Timber.e(t, "## FileService: ERROR ${t.localizedMessage}") Timber.e(t, "## ERROR ${t.localizedMessage}")
handleFailure(params, t) handleFailure(params, t)
} }
} catch (e: Exception) { } catch (e: Exception) {
Timber.e(e, "## FileService: ERROR") Timber.e(e, "## ERROR")
return handleFailure(params, e) handleFailure(params, e)
} finally { } finally {
// Delete all temporary files // Delete all temporary files
filesToDelete.forEach { filesToDelete.forEach {
@ -260,19 +310,23 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
Timber.v("Encrypt thumbnail") Timber.v("Encrypt thumbnail")
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) } notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType) val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType)
val contentUploadResponse = fileUploader.uploadByteArray(encryptionResult.encryptedByteArray, val contentUploadResponse = fileUploader.uploadByteArray(
"thumb_${params.attachment.name}", byteArray = encryptionResult.encryptedByteArray,
MimeTypes.OctetStream, filename = "thumb_${params.attachment.name}",
thumbnailProgressListener) mimeType = MimeTypes.OctetStream,
progressListener = thumbnailProgressListener
)
UploadThumbnailResult( UploadThumbnailResult(
contentUploadResponse.contentUri, contentUploadResponse.contentUri,
encryptionResult.encryptedFileInfo encryptionResult.encryptedFileInfo
) )
} else { } else {
val contentUploadResponse = fileUploader.uploadByteArray(thumbnailData.bytes, val contentUploadResponse = fileUploader.uploadByteArray(
"thumb_${params.attachment.name}", byteArray = thumbnailData.bytes,
thumbnailData.mimeType, filename = "thumb_${params.attachment.name}",
thumbnailProgressListener) mimeType = thumbnailData.mimeType,
progressListener = thumbnailProgressListener
)
UploadThumbnailResult( UploadThumbnailResult(
contentUploadResponse.contentUri, contentUploadResponse.contentUri,
null null
@ -291,7 +345,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
return Result.success( return Result.success(
WorkerParamsFactory.toData( WorkerParamsFactory.toData(
params.copy( 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 messageContent: MessageContent? = event.asDomain().content.toModel()
val updatedContent = when (messageContent) { val updatedContent = when (messageContent) {
is MessageImageContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes) is MessageImageContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes)
is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, newAttachmentAttributes)
newAttachmentAttributes.newFileSize)
is MessageFileContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize) is MessageFileContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize)
is MessageAudioContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize) is MessageAudioContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize)
else -> messageContent else -> messageContent
@ -351,7 +404,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
info = info?.copy( info = info?.copy(
width = newAttachmentAttributes?.newWidth ?: info.width, width = newAttachmentAttributes?.newWidth ?: info.width,
height = newAttachmentAttributes?.newHeight ?: info.height, 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?, encryptedFileInfo: EncryptedFileInfo?,
thumbnailUrl: String?, thumbnailUrl: String?,
thumbnailEncryptedFileInfo: EncryptedFileInfo?, thumbnailEncryptedFileInfo: EncryptedFileInfo?,
size: Long): MessageVideoContent { newAttachmentAttributes: NewAttachmentAttributes?): MessageVideoContent {
return copy( return copy(
url = if (encryptedFileInfo == null) url else null, url = if (encryptedFileInfo == null) url else null,
encryptedFileInfo = encryptedFileInfo?.copy(url = url), encryptedFileInfo = encryptedFileInfo?.copy(url = url),
videoInfo = videoInfo?.copy( videoInfo = videoInfo?.copy(
thumbnailUrl = if (thumbnailEncryptedFileInfo == null) thumbnailUrl else null, thumbnailUrl = if (thumbnailEncryptedFileInfo == null) thumbnailUrl else null,
thumbnailFile = thumbnailEncryptedFileInfo?.copy(url = thumbnailUrl), 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.age = editedEventEntity.age
entity.originServerTs = editedEventEntity.originServerTs entity.originServerTs = editedEventEntity.originServerTs
entity.sendState = editedEventEntity.sendState entity.sendState = editedEventEntity.sendState
entity.sendStateDetails = editedEventEntity.sendStateDetails
} }
} }
} }

View File

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

View File

@ -244,7 +244,7 @@ internal class LocalEchoEventFactory @Inject constructor(
mimeType = attachment.getSafeMimeType(), mimeType = attachment.getSafeMimeType(),
width = width?.toInt() ?: 0, width = width?.toInt() ?: 0,
height = height?.toInt() ?: 0, height = height?.toInt() ?: 0,
size = attachment.size.toInt() size = attachment.size
), ),
url = attachment.queryUri.toString() 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}") Timber.v("## SendEvent: [${System.currentTimeMillis()}] Update local state of $eventId to ${sendState.name}")
timelineInput.onLocalEchoUpdated(roomId = roomId ?: "", eventId = eventId, sendState = sendState) timelineInput.onLocalEchoUpdated(roomId = roomId ?: "", eventId = eventId, sendState = sendState)
updateEchoAsync(eventId) { realm, sendingEventEntity -> updateEchoAsync(eventId) { realm, sendingEventEntity ->
@ -96,6 +96,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
} else { } else {
sendingEventEntity.sendState = sendState sendingEventEntity.sendState = sendState
} }
sendingEventEntity.sendStateDetails = sendStateDetails
roomSummaryUpdater.updateSendingInformation(realm, sendingEventEntity.roomId) roomSummaryUpdater.updateSendingInformation(realm, sendingEventEntity.roomId)
} }
} }
@ -161,6 +162,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
val timelineEvents = TimelineEventEntity.where(realm, roomId, eventIds).findAll() val timelineEvents = TimelineEventEntity.where(realm, roomId, eventIds).findAll()
timelineEvents.forEach { timelineEvents.forEach {
it.root?.sendState = sendState it.root?.sendState = sendState
it.root?.sendStateDetails = null
} }
roomSummaryUpdater.updateSendingInformation(realm, roomId) roomSummaryUpdater.updateSendingInformation(realm, roomId)
} }

View File

@ -55,7 +55,12 @@ internal class MultipleEventSendingDispatcherWorker(context: Context, params: Wo
override fun doOnError(params: Params): Result { override fun doOnError(params: Params): Result {
params.localEchoIds.forEach { localEchoIds -> 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) 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.crypto.tasks.SendEventTask
import org.matrix.android.sdk.internal.di.SessionDatabase import org.matrix.android.sdk.internal.di.SessionDatabase
import org.matrix.android.sdk.internal.session.SessionComponent 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.SessionSafeCoroutineWorker
import org.matrix.android.sdk.internal.worker.SessionWorkerParams import org.matrix.android.sdk.internal.worker.SessionWorkerParams
import timber.log.Timber import timber.log.Timber
@ -77,7 +78,12 @@ internal class SendEventWorker(context: Context,
} }
if (params.lastFailureMessage != null) { 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 // Transmit the error
return Result.success(inputData) return Result.success(inputData)
.also { Timber.e("Work cancelled due to input error from parent") } .also { Timber.e("Work cancelled due to input error from parent") }
@ -90,7 +96,12 @@ internal class SendEventWorker(context: Context,
} catch (exception: Throwable) { } catch (exception: Throwable) {
if (/*currentAttemptCount >= MAX_NUMBER_OF_RETRY_BEFORE_FAILING ||**/ !exception.shouldBeRetried()) { if (/*currentAttemptCount >= MAX_NUMBER_OF_RETRY_BEFORE_FAILING ||**/ !exception.shouldBeRetried()) {
Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed cannot retry ${params.eventId} > ${exception.localizedMessage}") 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() Result.success()
} else { } else {
Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed schedule retry ${params.eventId} > ${exception.localizedMessage}") 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.Context
import android.content.Intent import android.content.Intent
import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.lib.multipicker.entity.MultiPickerAudioType import im.vector.lib.multipicker.entity.MultiPickerAudioType
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
/** /**
* Audio file picker implementation * 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. * Returns selected audio files or empty list if user did not select any files.
*/ */
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerAudioType> { override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerAudioType> {
val audioList = mutableListOf<MultiPickerAudioType>() return getSelectedUriList(data).mapNotNull { selectedUri ->
selectedUri.toMultiPickerAudioType(context)
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 audioList
} }
override fun createIntent(): Intent { override fun createIntent(): Intent {

View File

@ -23,7 +23,7 @@ import android.provider.MediaStore
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import androidx.core.content.FileProvider import androidx.core.content.FileProvider
import im.vector.lib.multipicker.entity.MultiPickerImageType 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.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.Date import java.util.Date
@ -54,40 +54,7 @@ class CameraPicker {
* or user cancelled the operation. * or user cancelled the operation.
*/ */
fun getTakenPhoto(context: Context, photoUri: Uri): MultiPickerImageType? { fun getTakenPhoto(context: Context, photoUri: Uri): MultiPickerImageType? {
val projection = arrayOf( return photoUri.toMultiPickerImageType(context)
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
} }
private fun createIntent(): Intent { 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.Context
import android.content.Intent import android.content.Intent
import android.provider.OpenableColumns import android.provider.OpenableColumns
import im.vector.lib.multipicker.entity.MultiPickerBaseType
import im.vector.lib.multipicker.entity.MultiPickerFileType 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 * Implementation of selecting any type of files
*/ */
class FilePicker : Picker<MultiPickerFileType>() { class FilePicker : Picker<MultiPickerBaseType>() {
/** /**
* Call this function from onActivityResult(int, int, Intent). * Call this function from onActivityResult(int, int, Intent).
* Returns selected files or empty list if user did not select any files. * Returns selected files or empty list if user did not select any files.
*/ */
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerFileType> { override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerBaseType> {
val fileList = mutableListOf<MultiPickerFileType>() return getSelectedUriList(data).mapNotNull { selectedUri ->
val type = context.contentResolver.getType(selectedUri)
getSelectedUriList(data).forEach { selectedUri -> when {
context.contentResolver.query(selectedUri, null, null, null, null) type.isMimeTypeVideo() -> selectedUri.toMultiPickerVideoType(context)
?.use { cursor -> type.isMimeTypeImage() -> selectedUri.toMultiPickerImageType(context)
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) type.isMimeTypeAudio() -> selectedUri.toMultiPickerAudioType(context)
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE) else -> {
if (cursor.moveToFirst()) { // Other files
val name = cursor.getString(nameColumn) context.contentResolver.query(selectedUri, null, null, null, null)
val size = cursor.getLong(sizeColumn) ?.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( MultiPickerFileType(
name, name,
size, size,
context.contentResolver.getType(selectedUri), context.contentResolver.getType(selectedUri),
selectedUri selectedUri
) )
) } else {
} null
} }
}
}
}
} }
return fileList
} }
override fun createIntent(): Intent { override fun createIntent(): Intent {

View File

@ -18,9 +18,8 @@ package im.vector.lib.multipicker
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.provider.MediaStore
import im.vector.lib.multipicker.entity.MultiPickerImageType import im.vector.lib.multipicker.entity.MultiPickerImageType
import im.vector.lib.multipicker.utils.ImageUtils import im.vector.lib.multipicker.utils.toMultiPickerImageType
/** /**
* Image Picker implementation * 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. * Returns selected image files or empty list if user did not select any files.
*/ */
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerImageType> { override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerImageType> {
val imageList = mutableListOf<MultiPickerImageType>() return getSelectedUriList(data).mapNotNull { selectedUri ->
selectedUri.toMultiPickerImageType(context)
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 imageList
} }
override fun createIntent(): Intent { 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 { companion object Type {
val IMAGE by lazy { MultiPicker<ImagePicker>() } val IMAGE by lazy { MultiPicker<ImagePicker>() }
val MEDIA by lazy { MultiPicker<MediaPicker>() }
val FILE by lazy { MultiPicker<FilePicker>() } val FILE by lazy { MultiPicker<FilePicker>() }
val VIDEO by lazy { MultiPicker<VideoPicker>() } val VIDEO by lazy { MultiPicker<VideoPicker>() }
val AUDIO by lazy { MultiPicker<AudioPicker>() } val AUDIO by lazy { MultiPicker<AudioPicker>() }
val CONTACT by lazy { MultiPicker<ContactPicker>() } val CONTACT by lazy { MultiPicker<ContactPicker>() }
val CAMERA by lazy { MultiPicker<CameraPicker>() } val CAMERA by lazy { MultiPicker<CameraPicker>() }
val CAMERA_VIDEO by lazy { MultiPicker<CameraVideoPicker>() }
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
fun <T> get(type: MultiPicker<T>): T { fun <T> get(type: MultiPicker<T>): T {
return when (type) { return when (type) {
IMAGE -> ImagePicker() as T IMAGE -> ImagePicker() as T
VIDEO -> VideoPicker() as T VIDEO -> VideoPicker() as T
MEDIA -> MediaPicker() as T
FILE -> FilePicker() as T FILE -> FilePicker() as T
AUDIO -> AudioPicker() as T AUDIO -> AudioPicker() as T
CONTACT -> ContactPicker() as T CONTACT -> ContactPicker() as T
CAMERA -> CameraPicker() as T CAMERA -> CameraPicker() as T
CAMERA_VIDEO -> CameraVideoPicker() as T
else -> throw IllegalArgumentException("Unsupported type $type") else -> throw IllegalArgumentException("Unsupported type $type")
} }
} }

View File

@ -18,9 +18,8 @@ package im.vector.lib.multipicker
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.media.MediaMetadataRetriever
import android.provider.MediaStore
import im.vector.lib.multipicker.entity.MultiPickerVideoType import im.vector.lib.multipicker.entity.MultiPickerVideoType
import im.vector.lib.multipicker.utils.toMultiPickerVideoType
/** /**
* Video Picker implementation * 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. * Returns selected video files or empty list if user did not select any files.
*/ */
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerVideoType> { override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerVideoType> {
val videoList = mutableListOf<MultiPickerVideoType>() return getSelectedUriList(data).mapNotNull { selectedUri ->
selectedUri.toMultiPickerVideoType(context)
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 videoList
} }
override fun createIntent(): Intent { 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 size: Long,
override val mimeType: String?, override val mimeType: String?,
override val contentUri: Uri, override val contentUri: Uri,
val width: Int, override val width: Int,
val height: Int, override val height: Int,
val orientation: Int override val orientation: Int
) : MultiPickerBaseType ) : MultiPickerBaseMediaType

View File

@ -23,8 +23,8 @@ data class MultiPickerVideoType(
override val size: Long, override val size: Long,
override val mimeType: String?, override val mimeType: String?,
override val contentUri: Uri, override val contentUri: Uri,
val width: Int, override val width: Int,
val height: Int, override val height: Int,
val orientation: Int, override val orientation: Int,
val duration: Long 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 @EpoxyAttribute
lateinit var body: CharSequence lateinit var body: CharSequence
@EpoxyAttribute
var bodyDetails: CharSequence? = null
@EpoxyAttribute @EpoxyAttribute
var imageContentRenderer: ImageContentRenderer? = null var imageContentRenderer: ImageContentRenderer? = null
@ -73,6 +76,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
holder.imagePreview.isVisible = data != null holder.imagePreview.isVisible = data != null
holder.body.movementMethod = movementMethod holder.body.movementMethod = movementMethod
holder.body.text = body holder.body.text = body
holder.bodyDetails.setTextOrHide(bodyDetails)
body.findPillsAndProcess(coroutineScope) { it.bind(holder.body) } body.findPillsAndProcess(coroutineScope) { it.bind(holder.body) }
holder.timestamp.setTextOrHide(time) 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 avatar by bind<ImageView>(R.id.bottom_sheet_message_preview_avatar)
val sender by bind<TextView>(R.id.bottom_sheet_message_preview_sender) 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 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 timestamp by bind<TextView>(R.id.bottom_sheet_message_preview_timestamp)
val imagePreview by bind<ImageView>(R.id.bottom_sheet_message_preview_image) 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 -> { throwable.error.code == MatrixError.M_LIMIT_EXCEEDED -> {
limitExceededError(throwable.error) 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 -> { throwable.error.code == MatrixError.M_THREEPID_NOT_FOUND -> {
stringProvider.getString(R.string.login_reset_password_error_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 package im.vector.app.core.ui.views
import android.content.Context import android.content.Context
import android.content.res.ColorStateList
import android.util.AttributeSet import android.util.AttributeSet
import androidx.appcompat.widget.AppCompatImageView import androidx.appcompat.widget.AppCompatImageView
import androidx.core.view.isVisible import androidx.core.view.isVisible
import im.vector.app.R import im.vector.app.R
import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration
import im.vector.app.features.themes.ThemeUtils
class SendStateImageView @JvmOverloads constructor( class SendStateImageView @JvmOverloads constructor(
context: Context, context: Context,
@ -39,16 +41,19 @@ class SendStateImageView @JvmOverloads constructor(
isVisible = when (sendState) { isVisible = when (sendState) {
SendStateDecoration.SENDING_NON_MEDIA -> { SendStateDecoration.SENDING_NON_MEDIA -> {
setImageResource(R.drawable.ic_sending_message) 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) contentDescription = context.getString(R.string.event_status_a11y_sending)
true true
} }
SendStateDecoration.SENT -> { SendStateDecoration.SENT -> {
setImageResource(R.drawable.ic_message_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) contentDescription = context.getString(R.string.event_status_a11y_sent)
true true
} }
SendStateDecoration.FAILED -> { SendStateDecoration.FAILED -> {
setImageResource(R.drawable.ic_sending_message_failed) setImageResource(R.drawable.ic_sending_message_failed)
imageTintList = null
contentDescription = context.getString(R.string.event_status_a11y_failed) contentDescription = context.getString(R.string.event_status_a11y_failed)
true true
} }

View File

@ -19,6 +19,7 @@ package im.vector.app.core.utils
import android.content.Context import android.content.Context
import android.os.Build import android.os.Build
import android.text.format.Formatter import android.text.format.Formatter
import org.threeten.bp.Duration
import java.util.TreeMap import java.util.TreeMap
object TextUtils { object TextUtils {
@ -68,4 +69,15 @@ object TextUtils {
Formatter.formatFileSize(context, normalizedSize) 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 package im.vector.app.features.attachments
import android.app.Activity
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultLauncher
import im.vector.app.core.dialogs.PhotoOrVideoDialog
import im.vector.app.core.platform.Restorable import im.vector.app.core.platform.Restorable
import im.vector.app.features.settings.VectorPreferences
import im.vector.lib.multipicker.MultiPicker import im.vector.lib.multipicker.MultiPicker
import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.api.session.content.ContentAttachmentData 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>) { 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>) { fun openCamera(activity: Activity,
captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, activityResultLauncher) 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( callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.IMAGE) MultiPicker.get(MultiPicker.MEDIA)
.getSelectedFiles(context, data) .getSelectedFiles(context, data)
.map { it.toContentAttachmentData() } .map { it.toContentAttachmentData() }
) )
} }
fun onPhotoResult() { fun onCameraResult() {
captureUri?.let { captureUri -> captureUri?.let { captureUri ->
MultiPicker.get(MultiPicker.CAMERA) MultiPicker.get(MultiPicker.CAMERA)
.getTakenPhoto(context, captureUri) .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?) { fun onVideoResult(data: Intent?) {
callback.onContentAttachmentsReady( callback.onContentAttachmentsReady(
MultiPicker.get(MultiPicker.VIDEO) MultiPicker.get(MultiPicker.VIDEO)

View File

@ -17,6 +17,7 @@
package im.vector.app.features.attachments package im.vector.app.features.attachments
import im.vector.lib.multipicker.entity.MultiPickerAudioType 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.MultiPickerBaseType
import im.vector.lib.multipicker.entity.MultiPickerContactType import im.vector.lib.multipicker.entity.MultiPickerContactType
import im.vector.lib.multipicker.entity.MultiPickerFileType 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 { fun MultiPickerImageType.toContentAttachmentData(): ContentAttachmentData {
if (mimeType == null) Timber.w("No mimeType") if (mimeType == null) Timber.w("No mimeType")
return ContentAttachmentData( return ContentAttachmentData(

View File

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

View File

@ -18,6 +18,7 @@ package im.vector.app.features.attachments.preview
import android.view.View import android.view.View
import android.widget.ImageView import android.widget.ImageView
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import com.bumptech.glide.Glide import com.bumptech.glide.Glide
@ -65,6 +66,7 @@ abstract class AttachmentMiniaturePreviewItem : AttachmentPreviewItem<Attachment
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
holder.imageView.isChecked = checked holder.imageView.isChecked = checked
holder.miniatureVideoIndicator.isVisible = attachment.type == ContentAttachmentData.Type.VIDEO
holder.view.setOnClickListener(clickListener) holder.view.setOnClickListener(clickListener)
} }
@ -72,6 +74,7 @@ abstract class AttachmentMiniaturePreviewItem : AttachmentPreviewItem<Attachment
override val imageView: CheckableImageView override val imageView: CheckableImageView
get() = miniatureImageView get() = miniatureImageView
private val miniatureImageView by bind<CheckableImageView>(R.id.attachmentMiniatureImageView) 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) attachmentBigPreviewController.setData(state)
views.attachmentPreviewerBigList.scrollToPosition(state.currentAttachmentIndex) views.attachmentPreviewerBigList.scrollToPosition(state.currentAttachmentIndex)
views.attachmentPreviewerMiniatureList.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 androidx.core.content.getSystemService
import im.vector.app.core.services.CallService import im.vector.app.core.services.CallService
import im.vector.app.core.utils.CountUpTimer 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.CameraEventsHandlerAdapter
import im.vector.app.features.call.CameraProxy import im.vector.app.features.call.CameraProxy
import im.vector.app.features.call.CameraType 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 // MxCall.StateListener
override fun onStateUpdate(call: MxCall) { 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.DimensionConverter
import im.vector.app.core.utils.KeyboardStateUtils import im.vector.app.core.utils.KeyboardStateUtils
import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES 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.checkPermissions
import im.vector.app.core.utils.colorizeMatchingText import im.vector.app.core.utils.colorizeMatchingText
import im.vector.app.core.utils.copyToClipboard import im.vector.app.core.utils.copyToClipboard
@ -381,7 +380,6 @@ class RoomDetailFragment @Inject constructor(
is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it) is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it)
is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message) is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message)
is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it) is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it)
is RoomDetailViewEvents.FileTooBigError -> displayFileTooBigError(it)
is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it)
is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(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) { private fun handleDownloadFileState(action: RoomDetailViewEvents.DownloadFileState) {
val activity = requireActivity() val activity = requireActivity()
if (action.throwable != null) { if (action.throwable != null) {
@ -986,7 +972,7 @@ class RoomDetailFragment @Inject constructor(
private val attachmentFileActivityResultLauncher = registerStartForActivityResult { private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
if (it.resultCode == Activity.RESULT_OK) { 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) { 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) { 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) { private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) {
when (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.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher)
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentImageActivityResultLauncher) AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher)
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher) AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher)
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment) AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment)

View File

@ -54,12 +54,6 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
object ShowWaitingView : RoomDetailViewEvents() object ShowWaitingView : RoomDetailViewEvents()
object HideWaitingView : RoomDetailViewEvents() object HideWaitingView : RoomDetailViewEvents()
data class FileTooBigError(
val filename: String,
val fileSizeInBytes: Long,
val homeServerLimitInBytes: Long
) : RoomDetailViewEvents()
data class DownloadFileState( data class DownloadFileState(
val mimeType: String?, val mimeType: String?,
val file: File?, 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.toContent
import org.matrix.android.sdk.api.session.events.model.toModel 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.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.ChangeMembershipState
import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams
import org.matrix.android.sdk.api.session.room.model.Membership 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.HandleTombstoneEvent -> handleTombstoneEvent(action)
is RoomDetailAction.ResendMessage -> handleResendEvent(action) is RoomDetailAction.ResendMessage -> handleResendEvent(action)
is RoomDetailAction.RemoveFailedEcho -> handleRemove(action) is RoomDetailAction.RemoveFailedEcho -> handleRemove(action)
is RoomDetailAction.ResendAll -> handleResendAll()
is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead() is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead()
is RoomDetailAction.ReportContent -> handleReportContent(action) is RoomDetailAction.ReportContent -> handleReportContent(action)
is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action) is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action)
@ -1107,23 +1105,7 @@ class RoomDetailViewModel @AssistedInject constructor(
} }
private fun handleSendMedia(action: RoomDetailAction.SendMedia) { private fun handleSendMedia(action: RoomDetailAction.SendMedia) {
val attachments = action.attachments room.sendMedias(action.attachments, action.compressBeforeSending, emptySet())
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
))
}
}
} }
private fun handleEventVisible(action: RoomDetailAction.TimelineEventTurnsVisible) { 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.bottomSheetQuickReactionsItem
import im.vector.app.core.epoxy.bottomsheet.bottomSheetSendStateItem import im.vector.app.core.epoxy.bottomsheet.bottomSheetSendStateItem
import im.vector.app.core.epoxy.dividerItem 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.resources.StringProvider
import im.vector.app.core.utils.DimensionConverter import im.vector.app.core.utils.DimensionConverter
import im.vector.app.features.home.AvatarRenderer 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.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.image.buildImageContentRendererData
import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration 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.createLinkMovementMethod
import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.home.room.detail.timeline.tools.linkify
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import org.matrix.android.sdk.api.extensions.orFalse 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 org.matrix.android.sdk.api.session.room.send.SendState
import javax.inject.Inject import javax.inject.Inject
@ -50,6 +53,8 @@ class MessageActionsEpoxyController @Inject constructor(
private val fontProvider: EmojiCompatFontProvider, private val fontProvider: EmojiCompatFontProvider,
private val imageContentRenderer: ImageContentRenderer, private val imageContentRenderer: ImageContentRenderer,
private val dimensionConverter: DimensionConverter, private val dimensionConverter: DimensionConverter,
private val errorFormatter: ErrorFormatter,
private val eventDetailsFormatter: EventDetailsFormatter,
private val dateFormatter: VectorDateFormatter private val dateFormatter: VectorDateFormatter
) : TypedEpoxyController<MessageActionState>() { ) : TypedEpoxyController<MessageActionState>() {
@ -68,16 +73,21 @@ class MessageActionsEpoxyController @Inject constructor(
data(state.timelineEvent()?.buildImageContentRendererData(dimensionConverter.dpToPx(66))) data(state.timelineEvent()?.buildImageContentRendererData(dimensionConverter.dpToPx(66)))
userClicked { listener?.didSelectMenuAction(EventSharedAction.OpenUserProfile(state.informationData.senderId)) } userClicked { listener?.didSelectMenuAction(EventSharedAction.OpenUserProfile(state.informationData.senderId)) }
body(state.messageBody.linkify(listener)) body(state.messageBody.linkify(listener))
bodyDetails(eventDetailsFormatter.format(state.timelineEvent()?.root))
time(formattedDate) time(formattedDate)
} }
// Send state // Send state
val sendState = state.sendState() val sendState = state.sendState()
if (sendState?.hasFailed().orFalse()) { 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 { bottomSheetSendStateItem {
id("send_state") id("send_state")
showProgress(false) showProgress(false)
text(stringProvider.getString(R.string.unable_to_send_message)) text(errorMessage)
drawableStart(R.drawable.ic_warning_badge) drawableStart(R.drawable.ic_warning_badge)
} }
} else if (sendState?.isSending().orFalse()) { } 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.OPTION_TYPE_POLL
import org.matrix.android.sdk.api.session.room.model.message.getFileName 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.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.session.room.timeline.getLastMessageContent
import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
@ -337,8 +338,7 @@ class MessageItemFactory @Inject constructor(
eventId = informationData.eventId, eventId = informationData.eventId,
filename = messageContent.body, filename = messageContent.body,
mimeType = messageContent.mimeType, mimeType = messageContent.mimeType,
url = messageContent.videoInfo?.thumbnailFile?.url url = messageContent.videoInfo?.getThumbnailUrl(),
?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height, height = messageContent.videoInfo?.height,
maxHeight = maxHeight, 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 package im.vector.app.features.home.room.detail.timeline.helper
import android.annotation.SuppressLint
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ProgressBar 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.ActiveSessionHolder
import im.vector.app.core.di.ScreenScope import im.vector.app.core.di.ScreenScope
import im.vector.app.core.error.ErrorFormatter 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.core.utils.TextUtils
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
import org.matrix.android.sdk.api.session.content.ContentUploadStateTracker 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 messageColorProvider: MessageColorProvider,
private val errorFormatter: ErrorFormatter) : ContentUploadStateTracker.UpdateListener { 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) { override fun onUpdate(state: ContentUploadStateTracker.State) {
when (state) { when (state) {
is ContentUploadStateTracker.State.Idle -> handleIdle() 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.Uploading -> handleProgress(state)
is ContentUploadStateTracker.State.Failure -> handleFailure(/*state*/) is ContentUploadStateTracker.State.Failure -> handleFailure(/*state*/)
is ContentUploadStateTracker.State.Success -> handleSuccess() is ContentUploadStateTracker.State.Success -> handleSuccess()
} is ContentUploadStateTracker.State.CompressingImage -> handleCompressingImage()
is ContentUploadStateTracker.State.CompressingVideo -> handleCompressingVideo(state)
}.exhaustive
} }
private fun handleIdle() { private fun handleIdle() {
if (isLocalFile) { if (isLocalFile) {
progressLayout.isVisible = true progressLayout.isVisible = true
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar) progressBar.isVisible = true
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView) progressBar.isIndeterminate = true
progressBar?.isVisible = true progressBar.progress = 0
progressBar?.isIndeterminate = true progressTextView.text = progressLayout.context.getString(R.string.send_file_step_idle)
progressBar?.progress = 0 progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT))
progressTextView?.text = progressLayout.context.getString(R.string.send_file_step_idle)
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT))
} else { } else {
progressLayout.isVisible = false 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) 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) { private fun doHandleEncrypting(resId: Int, current: Long, total: Long) {
progressLayout.visibility = View.VISIBLE progressLayout.visibility = View.VISIBLE
val percent = if (total > 0) (100L * (current.toFloat() / total.toFloat())) else 0f val percent = if (total > 0) (100L * (current.toFloat() / total.toFloat())) else 0f
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar) progressBar.isIndeterminate = false
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView) progressBar.progress = percent.toInt()
progressBar?.isIndeterminate = false
progressBar?.progress = percent.toInt()
progressTextView.isVisible = true progressTextView.isVisible = true
progressTextView?.text = progressLayout.context.getString(resId) progressTextView.text = progressLayout.context.getString(resId)
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING)) progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING))
} }
private fun doHandleProgress(resId: Int, current: Long, total: Long) { private fun doHandleProgress(resId: Int, current: Long, total: Long) {
progressLayout.visibility = View.VISIBLE progressLayout.visibility = View.VISIBLE
val percent = 100L * (current.toFloat() / total.toFloat()) val percent = 100L * (current.toFloat() / total.toFloat())
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar) progressBar.isVisible = true
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView) progressBar.isIndeterminate = false
progressBar?.isVisible = true progressBar.progress = percent.toInt()
progressBar?.isIndeterminate = false
progressBar?.progress = percent.toInt()
progressTextView.isVisible = true 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, current, true),
TextUtils.formatFileSize(progressLayout.context, total, 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*/) { private fun handleFailure(/*state: ContentUploadStateTracker.State.Failure*/) {
progressLayout.visibility = View.VISIBLE progressLayout.visibility = View.VISIBLE
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar) progressBar.isVisible = false
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.isVisible = false
// Do not show the message it's too technical for users, and unfortunate when upload is cancelled // 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 // in the middle by turning airplane mode for example
progressTextView.isVisible = false 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.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent 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.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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt 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>() root.isVideoMessage() -> root.getClearContent().toModel<MessageVideoContent>()
?.let { messageVideoContent -> ?.let { messageVideoContent ->
val videoInfo = messageVideoContent.videoInfo
ImageContentRenderer.Data( ImageContentRenderer.Data(
eventId = eventId, eventId = eventId,
filename = messageVideoContent.body, filename = messageVideoContent.body,
mimeType = messageVideoContent.mimeType, mimeType = videoInfo?.thumbnailInfo?.mimeType,
url = messageVideoContent.getFileUrl(), url = videoInfo?.getThumbnailUrl(),
elementToDecrypt = messageVideoContent.encryptedFileInfo?.toElementToDecrypt(), elementToDecrypt = videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageVideoContent.videoInfo?.height, height = videoInfo?.thumbnailInfo?.height,
maxHeight = maxHeight, maxHeight = maxHeight,
width = messageVideoContent.videoInfo?.width, width = videoInfo?.thumbnailInfo?.width,
maxWidth = maxHeight * 2, maxWidth = maxHeight * 2,
allowNonMxcUrls = false 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.MessageVideoContent
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent 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.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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.MimeTypes import org.matrix.android.sdk.api.util.MimeTypes
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
@ -78,8 +79,7 @@ class RoomEventsAttachmentProvider(
eventId = it.eventId, eventId = it.eventId,
filename = content.body, filename = content.body,
mimeType = content.mimeType, mimeType = content.mimeType,
url = content.videoInfo?.thumbnailFile?.url url = content.videoInfo?.getThumbnailUrl(),
?: content.videoInfo?.thumbnailUrl,
elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = content.videoInfo?.height, height = content.videoInfo?.height,
maxHeight = -1, maxHeight = -1,
@ -102,8 +102,7 @@ class RoomEventsAttachmentProvider(
data = data, data = data,
thumbnail = AttachmentInfo.Image( thumbnail = AttachmentInfo.Image(
uid = it.eventId, uid = it.eventId,
url = content.videoInfo?.thumbnailFile?.url url = content.videoInfo?.getThumbnailUrl() ?: "",
?: content.videoInfo?.thumbnailUrl ?: "",
data = thumbnailData 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.MessageImageContent
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent 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.getFileUrl
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import javax.inject.Inject import javax.inject.Inject
@ -141,8 +142,7 @@ class RoomUploadsMediaFragment @Inject constructor(
eventId = it.eventId, eventId = it.eventId,
filename = content.body, filename = content.body,
mimeType = content.mimeType, mimeType = content.mimeType,
url = content.videoInfo?.thumbnailFile?.url url = content.videoInfo?.getThumbnailUrl(),
?: content.videoInfo?.thumbnailUrl,
elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = content.videoInfo?.height, height = content.videoInfo?.height,
maxHeight = -1, 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.MessageType
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent 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.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.api.session.room.uploads.UploadEvent
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import javax.inject.Inject import javax.inject.Inject
@ -131,7 +132,7 @@ class UploadsMediaController @Inject constructor(
eventId = eventId, eventId = eventId,
filename = messageContent.body, filename = messageContent.body,
mimeType = messageContent.mimeType, mimeType = messageContent.mimeType,
url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl, url = messageContent.videoInfo?.getThumbnailUrl(),
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height, height = messageContent.videoInfo?.height,
maxHeight = itemSize, 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 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 // Background sync modes
// some preferences keys must be kept after a logout // some preferences keys must be kept after a logout
@ -948,4 +955,17 @@ class VectorPreferences @Inject constructor(private val context: Context) {
fun labsUseExperimentalRestricted(): Boolean { fun labsUseExperimentalRestricted(): Boolean {
return defaultPrefs.getBoolean(SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE, false) 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.core.view.children
import androidx.preference.Preference import androidx.preference.Preference
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.dialogs.PhotoOrVideoDialog
import im.vector.app.core.extensions.restart import im.vector.app.core.extensions.restart
import im.vector.app.core.preference.VectorListPreference import im.vector.app.core.preference.VectorListPreference
import im.vector.app.core.preference.VectorPreference import im.vector.app.core.preference.VectorPreference
@ -45,6 +46,9 @@ class VectorSettingsPreferencesFragment @Inject constructor(
private val textSizePreference by lazy { private val textSizePreference by lazy {
findPreference<VectorPreference>(VectorPreferences.SETTINGS_INTERFACE_TEXT_SIZE_KEY)!! findPreference<VectorPreference>(VectorPreferences.SETTINGS_INTERFACE_TEXT_SIZE_KEY)!!
} }
private val takePhotoOrVideoPreference by lazy {
findPreference<VectorPreference>("SETTINGS_INTERFACE_TAKE_PHOTO_VIDEO")!!
}
override fun bindPref() { override fun bindPref() {
// user interface preferences // user interface preferences
@ -123,6 +127,28 @@ class VectorSettingsPreferencesFragment @Inject constructor(
false 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"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android" <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:card_view="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="wrap_content" android:layout_width="wrap_content"
@ -17,4 +18,14 @@
android:scaleType="centerCrop" android:scaleType="centerCrop"
tools:src="@tools:sample/backgrounds/scenic" /> 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> </androidx.cardview.widget.CardView>

View File

@ -76,10 +76,29 @@
android:textColor="?riotx_text_secondary" android:textColor="?riotx_text_secondary"
android:textIsSelectable="false" android:textIsSelectable="false"
android:textSize="14sp" 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_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar" app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_image" 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> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -145,8 +145,8 @@
android:layout_marginBottom="4dp" android:layout_marginBottom="4dp"
android:contentDescription="@string/event_status_a11y_sending" android:contentDescription="@string/event_status_a11y_sending"
android:src="@drawable/ic_sending_message" android:src="@drawable/ic_sending_message"
android:tint="?riotx_text_tertiary"
android:visibility="gone" android:visibility="gone"
tools:tint="?riotx_text_tertiary"
tools:visibility="visible" /> tools:visibility="visible" />
<ProgressBar <ProgressBar
@ -154,11 +154,11 @@
android:layout_width="@dimen/item_event_message_state_size" android:layout_width="@dimen/item_event_message_state_size"
android:layout_height="@dimen/item_event_message_state_size" android:layout_height="@dimen/item_event_message_state_size"
android:layout_alignBottom="@+id/viewStubContainer" android:layout_alignBottom="@+id/viewStubContainer"
android:indeterminateTint="?riotx_text_secondary"
android:layout_alignParentEnd="true" android:layout_alignParentEnd="true"
android:layout_marginStart="8dp" android:layout_marginStart="8dp"
android:layout_marginEnd="8dp" android:layout_marginEnd="8dp"
android:layout_marginBottom="4dp" android:layout_marginBottom="4dp"
android:indeterminateTint="?riotx_text_secondary"
android:tint="?riotx_text_tertiary" android:tint="?riotx_text_tertiary"
android:visibility="gone" android:visibility="gone"
tools:visibility="visible" /> 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_video">Take photo or video</string>
<string name="option_take_photo">Take photo</string> <string name="option_take_photo">Take photo</string>
<string name="option_take_video">Take video</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 --> <!-- No sticker application dialog -->
<string name="no_sticker_application_dialog_content">You dont currently have any stickerpacks enabled.\n\nAdd some now?</string> <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_sending_thumbnail">Sending thumbnail (%1$s / %2$s)</string>
<string name="send_file_step_encrypting_file">Encrypting file…</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_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="downloading_file">Downloading file %1$s…</string>
<string name="downloaded_file">File %1$s has been downloaded!</string> <string name="downloaded_file">File %1$s has been downloaded!</string>
@ -2295,6 +2300,7 @@
<item quantity="other">%d users read</item> <item quantity="other">%d users read</item>
</plurals> </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_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> <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="one">Send image with the original size</item>
<item quantity="other">Send images with the original size</item> <item quantity="other">Send images with the original size</item>
</plurals> </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_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> <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 --> <!-- 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 --> <!-- Riotx attribute for palette -->
<item name="riotx_background">@color/riotx_background_dark</item> <item name="riotx_background">@color/riotx_background_dark</item>
<item name="vctr_home_drawer_header_background">@color/vctr_home_drawer_header_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"?> <?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android" <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 <im.vector.app.core.preference.VectorPreferenceCategory
android:key="SETTINGS_USER_INTERFACE_KEY" android:key="SETTINGS_USER_INTERFACE_KEY"
@ -55,6 +56,12 @@
android:summary="@string/settings_show_emoji_keyboard_summary" android:summary="@string/settings_show_emoji_keyboard_summary"
android:title="@string/settings_show_emoji_keyboard" /> 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>
<im.vector.app.core.preference.VectorPreferenceCategory android:title="@string/settings_category_timeline"> <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.VectorPreferenceCategory android:title="@string/settings_category_room_directory">
<im.vector.app.core.preference.VectorSwitchPreference <im.vector.app.core.preference.VectorSwitchPreference
android:key="SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS" android:key="SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS"
android:summary="@string/settings_room_directory_show_all_rooms_summary" android:summary="@string/settings_room_directory_show_all_rooms_summary"
android:title="@string/settings_room_directory_show_all_rooms" /> android:title="@string/settings_room_directory_show_all_rooms" />
</im.vector.app.core.preference.VectorPreferenceCategory> </im.vector.app.core.preference.VectorPreferenceCategory>
</androidx.preference.PreferenceScreen> </androidx.preference.PreferenceScreen>