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:
commit
64a37c251d
|
@ -7,6 +7,10 @@ Features ✨:
|
|||
Improvements 🙌:
|
||||
- Add ability to install APK from directly from Element (#2381)
|
||||
- Delete and react to stickers (#3250)
|
||||
- Compress video before sending (#442)
|
||||
- Improve file too big error detection (#3245)
|
||||
- User can now select video when selecting Gallery to send attachments to a room
|
||||
- Add option to record a video from the camera
|
||||
|
||||
Bugfix 🐛:
|
||||
- Message states cosmetic changes (#3007)
|
||||
|
@ -18,6 +22,7 @@ Bugfix 🐛:
|
|||
- Fix wording issue (#3242)
|
||||
- Fix missing sender information after edits (#3184)
|
||||
- Fix read marker not updating automatically (#3267)
|
||||
- Sent video does not contains duration (#3272)
|
||||
|
||||
Translations 🗣:
|
||||
-
|
||||
|
|
|
@ -17,20 +17,6 @@
|
|||
apply plugin: 'com.android.library'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
maven {
|
||||
url 'https://jitpack.io'
|
||||
content {
|
||||
// PhotoView
|
||||
includeGroupByRegex 'com\\.github\\.chrisbanes'
|
||||
}
|
||||
}
|
||||
jcenter()
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
android {
|
||||
compileSdkVersion 30
|
||||
|
||||
|
|
|
@ -45,7 +45,7 @@ allprojects {
|
|||
// PFLockScreen-Android
|
||||
includeGroupByRegex 'com\\.github\\.vector-im'
|
||||
|
||||
//Chat effects
|
||||
// Chat effects
|
||||
includeGroupByRegex 'com\\.github\\.jetradarmobile'
|
||||
includeGroupByRegex 'nl\\.dionsegijn'
|
||||
}
|
||||
|
|
|
@ -168,6 +168,9 @@ dependencies {
|
|||
implementation 'com.jakewharton.timber:timber:4.7.1'
|
||||
implementation 'com.facebook.stetho:stetho-okhttp3:1.6.0'
|
||||
|
||||
// Video compression
|
||||
implementation 'com.otaliastudios:transcoder:0.10.3'
|
||||
|
||||
// Phone number https://github.com/google/libphonenumber
|
||||
implementation 'com.googlecode.libphonenumber:libphonenumber:8.12.22'
|
||||
|
||||
|
|
|
@ -41,7 +41,7 @@ data class MatrixError(
|
|||
// For M_LIMIT_EXCEEDED
|
||||
@Json(name = "retry_after_ms") val retryAfterMillis: Long? = null,
|
||||
// For M_UNKNOWN_TOKEN
|
||||
@Json(name = "soft_logout") val isSoftLogout: Boolean = false,
|
||||
@Json(name = "soft_logout") val isSoftLogout: Boolean? = null,
|
||||
// For M_INVALID_PEPPER
|
||||
// {"error": "pepper does not match 'erZvr'", "lookup_pepper": "pQgMS", "algorithm": "sha256", "errcode": "M_INVALID_PEPPER"}
|
||||
@Json(name = "lookup_pepper") val newLookupPepper: String? = null,
|
||||
|
|
|
@ -31,6 +31,8 @@ interface ContentUploadStateTracker {
|
|||
sealed class State {
|
||||
object Idle : State()
|
||||
object EncryptingThumbnail : State()
|
||||
object CompressingImage : State()
|
||||
data class CompressingVideo(val percent: Float) : State()
|
||||
data class UploadingThumbnail(val current: Long, val total: Long) : State()
|
||||
data class Encrypting(val current: Long, val total: Long) : State()
|
||||
data class Uploading(val current: Long, val total: Long) : State()
|
||||
|
|
|
@ -28,6 +28,8 @@ import org.matrix.android.sdk.internal.crypto.algorithms.olm.OlmDecryptionResult
|
|||
import org.matrix.android.sdk.internal.crypto.model.event.EncryptedEventContent
|
||||
import org.matrix.android.sdk.internal.di.MoshiProvider
|
||||
import org.json.JSONObject
|
||||
import org.matrix.android.sdk.api.extensions.tryOrNull
|
||||
import org.matrix.android.sdk.api.failure.MatrixError
|
||||
import timber.log.Timber
|
||||
|
||||
typealias Content = JsonDict
|
||||
|
@ -90,6 +92,16 @@ data class Event(
|
|||
@Transient
|
||||
var sendState: SendState = SendState.UNKNOWN
|
||||
|
||||
@Transient
|
||||
var sendStateDetails: String? = null
|
||||
|
||||
fun sendStateError(): MatrixError? {
|
||||
return sendStateDetails?.let {
|
||||
val matrixErrorAdapter = MoshiProvider.providesMoshi().adapter(MatrixError::class.java)
|
||||
tryOrNull { matrixErrorAdapter.fromJson(it) }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The `age` value transcoded in a timestamp based on the device clock when the SDK received
|
||||
* the event from the home server.
|
||||
|
|
|
@ -47,3 +47,10 @@ data class FileInfo(
|
|||
*/
|
||||
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the url of the encrypted thumbnail or of the thumbnail
|
||||
*/
|
||||
fun FileInfo.getThumbnailUrl(): String? {
|
||||
return thumbnailFile?.url ?: thumbnailUrl
|
||||
}
|
||||
|
|
|
@ -40,7 +40,7 @@ data class ImageInfo(
|
|||
/**
|
||||
* Size of the image in bytes.
|
||||
*/
|
||||
@Json(name = "size") val size: Int = 0,
|
||||
@Json(name = "size") val size: Long = 0,
|
||||
|
||||
/**
|
||||
* Metadata about the image referred to in thumbnail_url.
|
||||
|
@ -57,3 +57,10 @@ data class ImageInfo(
|
|||
*/
|
||||
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the url of the encrypted thumbnail or of the thumbnail
|
||||
*/
|
||||
fun ImageInfo.getThumbnailUrl(): String? {
|
||||
return thumbnailFile?.url ?: thumbnailUrl
|
||||
}
|
||||
|
|
|
@ -37,3 +37,10 @@ data class LocationInfo(
|
|||
*/
|
||||
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the url of the encrypted thumbnail or of the thumbnail
|
||||
*/
|
||||
fun LocationInfo.getThumbnailUrl(): String? {
|
||||
return thumbnailFile?.url ?: thumbnailUrl
|
||||
}
|
||||
|
|
|
@ -62,3 +62,10 @@ data class VideoInfo(
|
|||
*/
|
||||
@Json(name = "thumbnail_file") val thumbnailFile: EncryptedFileInfo? = null
|
||||
)
|
||||
|
||||
/**
|
||||
* Get the url of the encrypted thumbnail or of the thumbnail
|
||||
*/
|
||||
fun VideoInfo.getThumbnailUrl(): String? {
|
||||
return thumbnailFile?.url ?: thumbnailUrl
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import org.matrix.android.sdk.internal.network.executeRequest
|
|||
import org.matrix.android.sdk.internal.session.room.RoomAPI
|
||||
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
|
||||
import org.matrix.android.sdk.internal.task.Task
|
||||
import org.matrix.android.sdk.internal.util.toMatrixErrorStr
|
||||
import javax.inject.Inject
|
||||
|
||||
internal interface SendVerificationMessageTask : Task<SendVerificationMessageTask.Params, String> {
|
||||
|
@ -55,7 +56,7 @@ internal class DefaultSendVerificationMessageTask @Inject constructor(
|
|||
localEchoRepository.updateSendState(localId, event.roomId, SendState.SENT)
|
||||
return response.eventId
|
||||
} catch (e: Throwable) {
|
||||
localEchoRepository.updateSendState(localId, event.roomId, SendState.UNDELIVERED)
|
||||
localEchoRepository.updateSendState(localId, event.roomId, SendState.UNDELIVERED, e.toMatrixErrorStr())
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@ import javax.inject.Inject
|
|||
class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
|
||||
|
||||
companion object {
|
||||
const val SESSION_STORE_SCHEMA_VERSION = 10L
|
||||
const val SESSION_STORE_SCHEMA_VERSION = 11L
|
||||
}
|
||||
|
||||
override fun migrate(realm: DynamicRealm, oldVersion: Long, newVersion: Long) {
|
||||
|
@ -59,6 +59,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
|
|||
if (oldVersion <= 7) migrateTo8(realm)
|
||||
if (oldVersion <= 8) migrateTo9(realm)
|
||||
if (oldVersion <= 9) migrateTo10(realm)
|
||||
if (oldVersion <= 10) migrateTo11(realm)
|
||||
}
|
||||
|
||||
private fun migrateTo1(realm: DynamicRealm) {
|
||||
|
@ -163,7 +164,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
|
|||
?.addRealmListField(EditAggregatedSummaryEntityFields.EDITIONS.`$`, editionOfEventSchema)
|
||||
}
|
||||
|
||||
fun migrateTo9(realm: DynamicRealm) {
|
||||
private fun migrateTo9(realm: DynamicRealm) {
|
||||
Timber.d("Step 8 -> 9")
|
||||
|
||||
realm.schema.get("RoomSummaryEntity")
|
||||
|
@ -201,7 +202,7 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
|
|||
}
|
||||
}
|
||||
|
||||
fun migrateTo10(realm: DynamicRealm) {
|
||||
private fun migrateTo10(realm: DynamicRealm) {
|
||||
Timber.d("Step 9 -> 10")
|
||||
realm.schema.create("SpaceChildSummaryEntity")
|
||||
?.addField(SpaceChildSummaryEntityFields.ORDER, String::class.java)
|
||||
|
@ -240,4 +241,10 @@ class RealmSessionStoreMigration @Inject constructor() : RealmMigration {
|
|||
?.addRealmListField(RoomSummaryEntityFields.PARENTS.`$`, realm.schema.get("SpaceParentSummaryEntity")!!)
|
||||
?.addRealmListField(RoomSummaryEntityFields.CHILDREN.`$`, realm.schema.get("SpaceChildSummaryEntity")!!)
|
||||
}
|
||||
|
||||
private fun migrateTo11(realm: DynamicRealm) {
|
||||
Timber.d("Step 10 -> 11")
|
||||
realm.schema.get("EventEntity")
|
||||
?.addField(EventEntityFields.SEND_STATE_DETAILS, String::class.java)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -80,6 +80,7 @@ internal object EventMapper {
|
|||
).also {
|
||||
it.ageLocalTs = eventEntity.ageLocalTs
|
||||
it.sendState = eventEntity.sendState
|
||||
it.sendStateDetails = eventEntity.sendStateDetails
|
||||
eventEntity.decryptionResultJson?.let { json ->
|
||||
try {
|
||||
it.mxDecryptionResult = MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java).fromJson(json)
|
||||
|
|
|
@ -32,6 +32,8 @@ internal open class EventEntity(@Index var eventId: String = "",
|
|||
@Index var stateKey: String? = null,
|
||||
var originServerTs: Long? = null,
|
||||
@Index var sender: String? = null,
|
||||
// Can contain a serialized MatrixError
|
||||
var sendStateDetails: String? = null,
|
||||
var age: Long? = 0,
|
||||
var unsignedData: String? = null,
|
||||
var redacts: String? = null,
|
||||
|
|
|
@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.failure.MatrixError
|
|||
import org.matrix.android.sdk.internal.di.MoshiProvider
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import okhttp3.ResponseBody
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import retrofit2.HttpException
|
||||
import retrofit2.Response
|
||||
import timber.log.Timber
|
||||
|
@ -91,7 +92,7 @@ private fun toFailure(errorBody: ResponseBody?, httpCode: Int, globalErrorReceiv
|
|||
} else if (httpCode == HttpURLConnection.HTTP_UNAUTHORIZED /* 401 */
|
||||
&& matrixError.code == MatrixError.M_UNKNOWN_TOKEN) {
|
||||
// Also send this error to the globalErrorReceiver, for a global management
|
||||
globalErrorReceiver?.handleGlobalError(GlobalError.InvalidToken(matrixError.isSoftLogout))
|
||||
globalErrorReceiver?.handleGlobalError(GlobalError.InvalidToken(matrixError.isSoftLogout.orFalse()))
|
||||
}
|
||||
|
||||
return Failure.ServerError(matrixError, httpCode)
|
||||
|
|
|
@ -78,6 +78,16 @@ internal class DefaultContentUploadStateTracker @Inject constructor() : ContentU
|
|||
updateState(key, progressData)
|
||||
}
|
||||
|
||||
internal fun setCompressingImage(key: String) {
|
||||
val progressData = ContentUploadStateTracker.State.CompressingImage
|
||||
updateState(key, progressData)
|
||||
}
|
||||
|
||||
internal fun setCompressingVideo(key: String, percent: Float) {
|
||||
val progressData = ContentUploadStateTracker.State.CompressingVideo(percent)
|
||||
updateState(key, progressData)
|
||||
}
|
||||
|
||||
internal fun setProgress(key: String, current: Long, total: Long) {
|
||||
val progressData = ContentUploadStateTracker.State.Uploading(current, total)
|
||||
updateState(key, progressData)
|
||||
|
|
|
@ -31,22 +31,28 @@ import okhttp3.RequestBody.Companion.toRequestBody
|
|||
import okio.BufferedSink
|
||||
import okio.source
|
||||
import org.matrix.android.sdk.api.extensions.tryOrNull
|
||||
import org.matrix.android.sdk.api.failure.Failure
|
||||
import org.matrix.android.sdk.api.failure.MatrixError
|
||||
import org.matrix.android.sdk.api.session.content.ContentUrlResolver
|
||||
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
|
||||
import org.matrix.android.sdk.internal.di.Authenticated
|
||||
import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
|
||||
import org.matrix.android.sdk.internal.network.ProgressRequestBody
|
||||
import org.matrix.android.sdk.internal.network.awaitResponse
|
||||
import org.matrix.android.sdk.internal.network.toFailure
|
||||
import org.matrix.android.sdk.internal.session.homeserver.DefaultHomeServerCapabilitiesService
|
||||
import org.matrix.android.sdk.internal.util.TemporaryFileCreator
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class FileUploader @Inject constructor(@Authenticated
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val globalErrorReceiver: GlobalErrorReceiver,
|
||||
private val homeServerCapabilitiesService: DefaultHomeServerCapabilitiesService,
|
||||
private val context: Context,
|
||||
private val temporaryFileCreator: TemporaryFileCreator,
|
||||
contentUrlResolver: ContentUrlResolver,
|
||||
moshi: Moshi) {
|
||||
|
||||
|
@ -57,6 +63,21 @@ internal class FileUploader @Inject constructor(@Authenticated
|
|||
filename: String?,
|
||||
mimeType: String?,
|
||||
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
|
||||
// Check size limit
|
||||
val maxUploadFileSize = homeServerCapabilitiesService.getHomeServerCapabilities().maxUploadFileSize
|
||||
|
||||
if (maxUploadFileSize != HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN
|
||||
&& file.length() > maxUploadFileSize) {
|
||||
// Known limitation and file too big for the server, save the pain to upload it
|
||||
throw Failure.ServerError(
|
||||
error = MatrixError(
|
||||
code = MatrixError.M_TOO_LARGE,
|
||||
message = "Cannot upload files larger than ${maxUploadFileSize / 1048576L}mb"
|
||||
),
|
||||
httpCode = 413
|
||||
)
|
||||
}
|
||||
|
||||
val uploadBody = object : RequestBody() {
|
||||
override fun contentLength() = file.length()
|
||||
|
||||
|
@ -90,7 +111,7 @@ internal class FileUploader @Inject constructor(@Authenticated
|
|||
val inputStream = withContext(Dispatchers.IO) {
|
||||
context.contentResolver.openInputStream(uri)
|
||||
} ?: throw FileNotFoundException()
|
||||
val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
|
||||
val workingFile = temporaryFileCreator.create()
|
||||
workingFile.outputStream().use {
|
||||
inputStream.copyTo(it)
|
||||
}
|
||||
|
|
|
@ -16,19 +16,20 @@
|
|||
|
||||
package org.matrix.android.sdk.internal.session.content
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.BitmapFactory
|
||||
import android.graphics.Matrix
|
||||
import androidx.exifinterface.media.ExifInterface
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.android.sdk.internal.util.TemporaryFileCreator
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class ImageCompressor @Inject constructor(private val context: Context) {
|
||||
internal class ImageCompressor @Inject constructor(
|
||||
private val temporaryFileCreator: TemporaryFileCreator
|
||||
) {
|
||||
suspend fun compress(
|
||||
imageFile: File,
|
||||
desiredWidth: Int,
|
||||
|
@ -45,7 +46,7 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
|
|||
}
|
||||
} ?: return@withContext imageFile
|
||||
|
||||
val destinationFile = createDestinationFile()
|
||||
val destinationFile = temporaryFileCreator.create()
|
||||
|
||||
runCatching {
|
||||
destinationFile.outputStream().use {
|
||||
|
@ -53,7 +54,7 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
|
|||
}
|
||||
}
|
||||
|
||||
return@withContext destinationFile
|
||||
destinationFile
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -64,16 +65,16 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
|
|||
val orientation = exifInfo.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
|
||||
val matrix = Matrix()
|
||||
when (orientation) {
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_ROTATE_270 -> matrix.postRotate(270f)
|
||||
ExifInterface.ORIENTATION_ROTATE_180 -> matrix.postRotate(180f)
|
||||
ExifInterface.ORIENTATION_ROTATE_90 -> matrix.postRotate(90f)
|
||||
ExifInterface.ORIENTATION_FLIP_HORIZONTAL -> matrix.preScale(-1f, 1f)
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
ExifInterface.ORIENTATION_FLIP_VERTICAL -> matrix.preScale(1f, -1f)
|
||||
ExifInterface.ORIENTATION_TRANSPOSE -> {
|
||||
matrix.preRotate(-90f)
|
||||
matrix.preScale(-1f, 1f)
|
||||
}
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
ExifInterface.ORIENTATION_TRANSVERSE -> {
|
||||
matrix.preRotate(90f)
|
||||
matrix.preScale(-1f, 1f)
|
||||
}
|
||||
|
@ -116,8 +117,4 @@ internal class ImageCompressor @Inject constructor(private val context: Context)
|
|||
null
|
||||
}
|
||||
}
|
||||
|
||||
private fun createDestinationFile(): File {
|
||||
return File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,9 +18,12 @@ package org.matrix.android.sdk.internal.session.content
|
|||
|
||||
import android.content.Context
|
||||
import android.graphics.BitmapFactory
|
||||
import android.media.MediaMetadataRetriever
|
||||
import androidx.core.net.toUri
|
||||
import androidx.work.WorkerParameters
|
||||
import com.squareup.moshi.JsonClass
|
||||
import org.matrix.android.sdk.api.extensions.tryOrNull
|
||||
import org.matrix.android.sdk.api.listeners.ProgressListener
|
||||
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
|
||||
import org.matrix.android.sdk.api.session.events.model.toContent
|
||||
import org.matrix.android.sdk.api.session.events.model.toModel
|
||||
|
@ -41,12 +44,13 @@ import org.matrix.android.sdk.internal.session.room.send.CancelSendTracker
|
|||
import org.matrix.android.sdk.internal.session.room.send.LocalEchoIdentifiers
|
||||
import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository
|
||||
import org.matrix.android.sdk.internal.session.room.send.MultipleEventSendingDispatcherWorker
|
||||
import org.matrix.android.sdk.internal.util.TemporaryFileCreator
|
||||
import org.matrix.android.sdk.internal.util.toMatrixErrorStr
|
||||
import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker
|
||||
import org.matrix.android.sdk.internal.worker.SessionWorkerParams
|
||||
import org.matrix.android.sdk.internal.worker.WorkerParamsFactory
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.util.UUID
|
||||
import javax.inject.Inject
|
||||
|
||||
private data class NewAttachmentAttributes(
|
||||
|
@ -77,7 +81,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
@Inject lateinit var fileService: DefaultFileService
|
||||
@Inject lateinit var cancelSendTracker: CancelSendTracker
|
||||
@Inject lateinit var imageCompressor: ImageCompressor
|
||||
@Inject lateinit var videoCompressor: VideoCompressor
|
||||
@Inject lateinit var localEchoRepository: LocalEchoRepository
|
||||
@Inject lateinit var temporaryFileCreator: TemporaryFileCreator
|
||||
|
||||
override fun injectWith(injector: SessionComponent) {
|
||||
injector.inject(this)
|
||||
|
@ -109,7 +115,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
val attachment = params.attachment
|
||||
val filesToDelete = mutableListOf<File>()
|
||||
|
||||
try {
|
||||
return try {
|
||||
val inputStream = context.contentResolver.openInputStream(attachment.queryUri)
|
||||
?: return Result.success(
|
||||
WorkerParamsFactory.toData(
|
||||
|
@ -120,7 +126,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
)
|
||||
|
||||
// always use a temporary file, it guaranties that we could report progress on upload and simplifies the flows
|
||||
val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
|
||||
val workingFile = temporaryFileCreator.create()
|
||||
.also { filesToDelete.add(it) }
|
||||
workingFile.outputStream().use { outputStream ->
|
||||
inputStream.use { inputStream ->
|
||||
|
@ -128,8 +134,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
}
|
||||
}
|
||||
|
||||
val uploadThumbnailResult = dealWithThumbnail(params)
|
||||
|
||||
val progressListener = object : ProgressRequestBody.Listener {
|
||||
override fun onProgress(current: Long, total: Long) {
|
||||
notifyTracker(params) {
|
||||
|
@ -144,7 +148,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
|
||||
var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null
|
||||
|
||||
return try {
|
||||
try {
|
||||
val fileToUpload: File
|
||||
var newAttachmentAttributes = NewAttachmentAttributes(
|
||||
params.attachment.width?.toInt(),
|
||||
|
@ -156,6 +160,8 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
// Do not compress gif
|
||||
&& attachment.mimeType != MimeTypes.Gif
|
||||
&& params.compressBeforeSending) {
|
||||
notifyTracker(params) { contentUploadStateTracker.setCompressingImage(it) }
|
||||
|
||||
fileToUpload = imageCompressor.compress(workingFile, MAX_IMAGE_SIZE, MAX_IMAGE_SIZE)
|
||||
.also { compressedFile ->
|
||||
// Get new Bitmap size
|
||||
|
@ -170,6 +176,48 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
}
|
||||
}
|
||||
.also { filesToDelete.add(it) }
|
||||
} else if (attachment.type == ContentAttachmentData.Type.VIDEO
|
||||
// Do not compress gif
|
||||
&& attachment.mimeType != MimeTypes.Gif
|
||||
&& params.compressBeforeSending) {
|
||||
fileToUpload = videoCompressor.compress(workingFile, object : ProgressListener {
|
||||
override fun onProgress(progress: Int, total: Int) {
|
||||
notifyTracker(params) { contentUploadStateTracker.setCompressingVideo(it, progress.toFloat()) }
|
||||
}
|
||||
})
|
||||
.let { videoCompressionResult ->
|
||||
when (videoCompressionResult) {
|
||||
is VideoCompressionResult.Success -> {
|
||||
val compressedFile = videoCompressionResult.compressedFile
|
||||
var compressedWidth: Int? = null
|
||||
var compressedHeight: Int? = null
|
||||
|
||||
tryOrNull {
|
||||
context.contentResolver.openFileDescriptor(compressedFile.toUri(), "r")?.use { pfd ->
|
||||
MediaMetadataRetriever().let {
|
||||
it.setDataSource(pfd.fileDescriptor)
|
||||
compressedWidth = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt()
|
||||
compressedHeight = it.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get new Video file size and dimensions
|
||||
newAttachmentAttributes = newAttachmentAttributes.copy(
|
||||
newFileSize = compressedFile.length(),
|
||||
newWidth = compressedWidth ?: newAttachmentAttributes.newWidth,
|
||||
newHeight = compressedHeight ?: newAttachmentAttributes.newHeight
|
||||
)
|
||||
compressedFile
|
||||
.also { filesToDelete.add(it) }
|
||||
}
|
||||
VideoCompressionResult.CompressionNotNeeded,
|
||||
VideoCompressionResult.CompressionCancelled,
|
||||
is VideoCompressionResult.CompressionFailed -> {
|
||||
workingFile
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fileToUpload = workingFile
|
||||
// Fix: OpenableColumns.SIZE may return -1 or 0
|
||||
|
@ -180,9 +228,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
|
||||
val encryptedFile: File?
|
||||
val contentUploadResponse = if (params.isEncrypted) {
|
||||
Timber.v("## FileService: Encrypt file")
|
||||
Timber.v("## Encrypt file")
|
||||
|
||||
encryptedFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
|
||||
encryptedFile = temporaryFileCreator.create()
|
||||
.also { filesToDelete.add(it) }
|
||||
|
||||
uploadedFileEncryptedFileInfo =
|
||||
|
@ -192,18 +240,18 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
}
|
||||
}
|
||||
|
||||
Timber.v("## FileService: Uploading file")
|
||||
Timber.v("## Uploading file")
|
||||
|
||||
fileUploader
|
||||
.uploadFile(encryptedFile, attachment.name, MimeTypes.OctetStream, progressListener)
|
||||
} else {
|
||||
Timber.v("## FileService: Clear file")
|
||||
Timber.v("## Clear file")
|
||||
encryptedFile = null
|
||||
fileUploader
|
||||
.uploadFile(fileToUpload, attachment.name, attachment.getSafeMimeType(), progressListener)
|
||||
}
|
||||
|
||||
Timber.v("## FileService: Update cache storage for ${contentUploadResponse.contentUri}")
|
||||
Timber.v("## Update cache storage for ${contentUploadResponse.contentUri}")
|
||||
try {
|
||||
fileService.storeDataFor(
|
||||
mxcUrl = contentUploadResponse.contentUri,
|
||||
|
@ -212,11 +260,13 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
originalFile = workingFile,
|
||||
encryptedFile = encryptedFile
|
||||
)
|
||||
Timber.v("## FileService: cache storage updated")
|
||||
Timber.v("## cache storage updated")
|
||||
} catch (failure: Throwable) {
|
||||
Timber.e(failure, "## FileService: Failed to update file cache")
|
||||
Timber.e(failure, "## Failed to update file cache")
|
||||
}
|
||||
|
||||
val uploadThumbnailResult = dealWithThumbnail(params)
|
||||
|
||||
handleSuccess(params,
|
||||
contentUploadResponse.contentUri,
|
||||
uploadedFileEncryptedFileInfo,
|
||||
|
@ -224,12 +274,12 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
uploadThumbnailResult?.uploadedThumbnailEncryptedFileInfo,
|
||||
newAttachmentAttributes)
|
||||
} catch (t: Throwable) {
|
||||
Timber.e(t, "## FileService: ERROR ${t.localizedMessage}")
|
||||
Timber.e(t, "## ERROR ${t.localizedMessage}")
|
||||
handleFailure(params, t)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "## FileService: ERROR")
|
||||
return handleFailure(params, e)
|
||||
Timber.e(e, "## ERROR")
|
||||
handleFailure(params, e)
|
||||
} finally {
|
||||
// Delete all temporary files
|
||||
filesToDelete.forEach {
|
||||
|
@ -260,19 +310,23 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
Timber.v("Encrypt thumbnail")
|
||||
notifyTracker(params) { contentUploadStateTracker.setEncryptingThumbnail(it) }
|
||||
val encryptionResult = MXEncryptedAttachments.encryptAttachment(thumbnailData.bytes.inputStream(), thumbnailData.mimeType)
|
||||
val contentUploadResponse = fileUploader.uploadByteArray(encryptionResult.encryptedByteArray,
|
||||
"thumb_${params.attachment.name}",
|
||||
MimeTypes.OctetStream,
|
||||
thumbnailProgressListener)
|
||||
val contentUploadResponse = fileUploader.uploadByteArray(
|
||||
byteArray = encryptionResult.encryptedByteArray,
|
||||
filename = "thumb_${params.attachment.name}",
|
||||
mimeType = MimeTypes.OctetStream,
|
||||
progressListener = thumbnailProgressListener
|
||||
)
|
||||
UploadThumbnailResult(
|
||||
contentUploadResponse.contentUri,
|
||||
encryptionResult.encryptedFileInfo
|
||||
)
|
||||
} else {
|
||||
val contentUploadResponse = fileUploader.uploadByteArray(thumbnailData.bytes,
|
||||
"thumb_${params.attachment.name}",
|
||||
thumbnailData.mimeType,
|
||||
thumbnailProgressListener)
|
||||
val contentUploadResponse = fileUploader.uploadByteArray(
|
||||
byteArray = thumbnailData.bytes,
|
||||
filename = "thumb_${params.attachment.name}",
|
||||
mimeType = thumbnailData.mimeType,
|
||||
progressListener = thumbnailProgressListener
|
||||
)
|
||||
UploadThumbnailResult(
|
||||
contentUploadResponse.contentUri,
|
||||
null
|
||||
|
@ -291,7 +345,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
return Result.success(
|
||||
WorkerParamsFactory.toData(
|
||||
params.copy(
|
||||
lastFailureMessage = failure.localizedMessage
|
||||
lastFailureMessage = failure.toMatrixErrorStr()
|
||||
)
|
||||
)
|
||||
)
|
||||
|
@ -328,8 +382,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
val messageContent: MessageContent? = event.asDomain().content.toModel()
|
||||
val updatedContent = when (messageContent) {
|
||||
is MessageImageContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes)
|
||||
is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo,
|
||||
newAttachmentAttributes.newFileSize)
|
||||
is MessageVideoContent -> messageContent.update(url, encryptedFileInfo, thumbnailUrl, thumbnailEncryptedFileInfo, newAttachmentAttributes)
|
||||
is MessageFileContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize)
|
||||
is MessageAudioContent -> messageContent.update(url, encryptedFileInfo, newAttachmentAttributes.newFileSize)
|
||||
else -> messageContent
|
||||
|
@ -351,7 +404,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
info = info?.copy(
|
||||
width = newAttachmentAttributes?.newWidth ?: info.width,
|
||||
height = newAttachmentAttributes?.newHeight ?: info.height,
|
||||
size = newAttachmentAttributes?.newFileSize?.toInt() ?: info.size
|
||||
size = newAttachmentAttributes?.newFileSize ?: info.size
|
||||
)
|
||||
)
|
||||
}
|
||||
|
@ -360,14 +413,16 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
encryptedFileInfo: EncryptedFileInfo?,
|
||||
thumbnailUrl: String?,
|
||||
thumbnailEncryptedFileInfo: EncryptedFileInfo?,
|
||||
size: Long): MessageVideoContent {
|
||||
newAttachmentAttributes: NewAttachmentAttributes?): MessageVideoContent {
|
||||
return copy(
|
||||
url = if (encryptedFileInfo == null) url else null,
|
||||
encryptedFileInfo = encryptedFileInfo?.copy(url = url),
|
||||
videoInfo = videoInfo?.copy(
|
||||
thumbnailUrl = if (thumbnailEncryptedFileInfo == null) thumbnailUrl else null,
|
||||
thumbnailFile = thumbnailEncryptedFileInfo?.copy(url = thumbnailUrl),
|
||||
size = size
|
||||
width = newAttachmentAttributes?.newWidth ?: videoInfo.width,
|
||||
height = newAttachmentAttributes?.newHeight ?: videoInfo.height,
|
||||
size = newAttachmentAttributes?.newFileSize ?: videoInfo.size
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -99,6 +99,7 @@ internal class EventEditor @Inject constructor(private val eventSenderProcessor:
|
|||
entity.age = editedEventEntity.age
|
||||
entity.originServerTs = editedEventEntity.originServerTs
|
||||
entity.sendState = editedEventEntity.sendState
|
||||
entity.sendStateDetails = editedEventEntity.sendStateDetails
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,14 +109,6 @@ internal class DefaultSendService @AssistedInject constructor(
|
|||
.let { sendEvent(it) }
|
||||
}
|
||||
|
||||
override fun sendMedias(attachments: List<ContentAttachmentData>,
|
||||
compressBeforeSending: Boolean,
|
||||
roomIds: Set<String>): Cancelable {
|
||||
return attachments.mapTo(CancelableBag()) {
|
||||
sendMedia(it, compressBeforeSending, roomIds)
|
||||
}
|
||||
}
|
||||
|
||||
override fun redactEvent(event: Event, reason: String?): Cancelable {
|
||||
// TODO manage media/attachements?
|
||||
val redactionEcho = localEchoEventFactory.createRedactEvent(roomId, event.eventId!!, reason)
|
||||
|
@ -149,7 +141,7 @@ internal class DefaultSendService @AssistedInject constructor(
|
|||
is MessageImageContent -> {
|
||||
// The image has not yet been sent
|
||||
val attachmentData = ContentAttachmentData(
|
||||
size = messageContent.info!!.size.toLong(),
|
||||
size = messageContent.info!!.size,
|
||||
mimeType = messageContent.info.mimeType!!,
|
||||
width = messageContent.info.width.toLong(),
|
||||
height = messageContent.info.height.toLong(),
|
||||
|
@ -240,6 +232,14 @@ internal class DefaultSendService @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun sendMedias(attachments: List<ContentAttachmentData>,
|
||||
compressBeforeSending: Boolean,
|
||||
roomIds: Set<String>): Cancelable {
|
||||
return attachments.mapTo(CancelableBag()) {
|
||||
sendMedia(it, compressBeforeSending, roomIds)
|
||||
}
|
||||
}
|
||||
|
||||
override fun sendMedia(attachment: ContentAttachmentData,
|
||||
compressBeforeSending: Boolean,
|
||||
roomIds: Set<String>): Cancelable {
|
||||
|
|
|
@ -244,7 +244,7 @@ internal class LocalEchoEventFactory @Inject constructor(
|
|||
mimeType = attachment.getSafeMimeType(),
|
||||
width = width?.toInt() ?: 0,
|
||||
height = height?.toInt() ?: 0,
|
||||
size = attachment.size.toInt()
|
||||
size = attachment.size
|
||||
),
|
||||
url = attachment.queryUri.toString()
|
||||
)
|
||||
|
|
|
@ -87,7 +87,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
|
|||
}
|
||||
}
|
||||
|
||||
fun updateSendState(eventId: String, roomId: String?, sendState: SendState) {
|
||||
fun updateSendState(eventId: String, roomId: String?, sendState: SendState, sendStateDetails: String? = null) {
|
||||
Timber.v("## SendEvent: [${System.currentTimeMillis()}] Update local state of $eventId to ${sendState.name}")
|
||||
timelineInput.onLocalEchoUpdated(roomId = roomId ?: "", eventId = eventId, sendState = sendState)
|
||||
updateEchoAsync(eventId) { realm, sendingEventEntity ->
|
||||
|
@ -96,6 +96,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
|
|||
} else {
|
||||
sendingEventEntity.sendState = sendState
|
||||
}
|
||||
sendingEventEntity.sendStateDetails = sendStateDetails
|
||||
roomSummaryUpdater.updateSendingInformation(realm, sendingEventEntity.roomId)
|
||||
}
|
||||
}
|
||||
|
@ -161,6 +162,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
|
|||
val timelineEvents = TimelineEventEntity.where(realm, roomId, eventIds).findAll()
|
||||
timelineEvents.forEach {
|
||||
it.root?.sendState = sendState
|
||||
it.root?.sendStateDetails = null
|
||||
}
|
||||
roomSummaryUpdater.updateSendingInformation(realm, roomId)
|
||||
}
|
||||
|
|
|
@ -55,7 +55,12 @@ internal class MultipleEventSendingDispatcherWorker(context: Context, params: Wo
|
|||
|
||||
override fun doOnError(params: Params): Result {
|
||||
params.localEchoIds.forEach { localEchoIds ->
|
||||
localEchoRepository.updateSendState(localEchoIds.eventId, localEchoIds.roomId, SendState.UNDELIVERED)
|
||||
localEchoRepository.updateSendState(
|
||||
eventId = localEchoIds.eventId,
|
||||
roomId = localEchoIds.roomId,
|
||||
sendState = SendState.UNDELIVERED,
|
||||
sendStateDetails = params.lastFailureMessage
|
||||
)
|
||||
}
|
||||
|
||||
return super.doOnError(params)
|
||||
|
|
|
@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState
|
|||
import org.matrix.android.sdk.internal.crypto.tasks.SendEventTask
|
||||
import org.matrix.android.sdk.internal.di.SessionDatabase
|
||||
import org.matrix.android.sdk.internal.session.SessionComponent
|
||||
import org.matrix.android.sdk.internal.util.toMatrixErrorStr
|
||||
import org.matrix.android.sdk.internal.worker.SessionSafeCoroutineWorker
|
||||
import org.matrix.android.sdk.internal.worker.SessionWorkerParams
|
||||
import timber.log.Timber
|
||||
|
@ -77,7 +78,12 @@ internal class SendEventWorker(context: Context,
|
|||
}
|
||||
|
||||
if (params.lastFailureMessage != null) {
|
||||
localEchoRepository.updateSendState(event.eventId, event.roomId, SendState.UNDELIVERED)
|
||||
localEchoRepository.updateSendState(
|
||||
eventId = event.eventId,
|
||||
roomId = event.roomId,
|
||||
sendState = SendState.UNDELIVERED,
|
||||
sendStateDetails = params.lastFailureMessage
|
||||
)
|
||||
// Transmit the error
|
||||
return Result.success(inputData)
|
||||
.also { Timber.e("Work cancelled due to input error from parent") }
|
||||
|
@ -90,7 +96,12 @@ internal class SendEventWorker(context: Context,
|
|||
} catch (exception: Throwable) {
|
||||
if (/*currentAttemptCount >= MAX_NUMBER_OF_RETRY_BEFORE_FAILING ||**/ !exception.shouldBeRetried()) {
|
||||
Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed cannot retry ${params.eventId} > ${exception.localizedMessage}")
|
||||
localEchoRepository.updateSendState(event.eventId, event.roomId, SendState.UNDELIVERED)
|
||||
localEchoRepository.updateSendState(
|
||||
eventId = event.eventId,
|
||||
roomId = event.roomId,
|
||||
sendState = SendState.UNDELIVERED,
|
||||
sendStateDetails = exception.toMatrixErrorStr()
|
||||
)
|
||||
Result.success()
|
||||
} else {
|
||||
Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed schedule retry ${params.eventId} > ${exception.localizedMessage}")
|
||||
|
|
|
@ -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"
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -18,9 +18,8 @@ package im.vector.lib.multipicker
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.provider.MediaStore
|
||||
import im.vector.lib.multipicker.entity.MultiPickerAudioType
|
||||
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
|
||||
|
||||
/**
|
||||
* Audio file picker implementation
|
||||
|
@ -32,48 +31,9 @@ class AudioPicker : Picker<MultiPickerAudioType>() {
|
|||
* Returns selected audio files or empty list if user did not select any files.
|
||||
*/
|
||||
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerAudioType> {
|
||||
val audioList = mutableListOf<MultiPickerAudioType>()
|
||||
|
||||
getSelectedUriList(data).forEach { selectedUri ->
|
||||
val projection = arrayOf(
|
||||
MediaStore.Audio.Media.DISPLAY_NAME,
|
||||
MediaStore.Audio.Media.SIZE
|
||||
)
|
||||
|
||||
context.contentResolver.query(
|
||||
selectedUri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val nameColumn = cursor.getColumnIndex(MediaStore.Audio.Media.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndex(MediaStore.Audio.Media.SIZE)
|
||||
|
||||
if (cursor.moveToNext()) {
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
var duration = 0L
|
||||
|
||||
context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd ->
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
|
||||
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
|
||||
}
|
||||
|
||||
audioList.add(
|
||||
MultiPickerAudioType(
|
||||
name,
|
||||
size,
|
||||
context.contentResolver.getType(selectedUri),
|
||||
selectedUri,
|
||||
duration
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return getSelectedUriList(data).mapNotNull { selectedUri ->
|
||||
selectedUri.toMultiPickerAudioType(context)
|
||||
}
|
||||
return audioList
|
||||
}
|
||||
|
||||
override fun createIntent(): Intent {
|
||||
|
|
|
@ -23,7 +23,7 @@ import android.provider.MediaStore
|
|||
import androidx.activity.result.ActivityResultLauncher
|
||||
import androidx.core.content.FileProvider
|
||||
import im.vector.lib.multipicker.entity.MultiPickerImageType
|
||||
import im.vector.lib.multipicker.utils.ImageUtils
|
||||
import im.vector.lib.multipicker.utils.toMultiPickerImageType
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
|
@ -54,40 +54,7 @@ class CameraPicker {
|
|||
* or user cancelled the operation.
|
||||
*/
|
||||
fun getTakenPhoto(context: Context, photoUri: Uri): MultiPickerImageType? {
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
MediaStore.Images.Media.SIZE
|
||||
)
|
||||
|
||||
context.contentResolver.query(
|
||||
photoUri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
|
||||
|
||||
if (cursor.moveToNext()) {
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
|
||||
val bitmap = ImageUtils.getBitmap(context, photoUri)
|
||||
val orientation = ImageUtils.getOrientation(context, photoUri)
|
||||
|
||||
return MultiPickerImageType(
|
||||
name,
|
||||
size,
|
||||
context.contentResolver.getType(photoUri),
|
||||
photoUri,
|
||||
bitmap?.width ?: 0,
|
||||
bitmap?.height ?: 0,
|
||||
orientation
|
||||
)
|
||||
}
|
||||
}
|
||||
return null
|
||||
return photoUri.toMultiPickerImageType(context)
|
||||
}
|
||||
|
||||
private fun createIntent(): Intent {
|
||||
|
|
|
@ -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 */
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -19,41 +19,55 @@ package im.vector.lib.multipicker
|
|||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.OpenableColumns
|
||||
import im.vector.lib.multipicker.entity.MultiPickerBaseType
|
||||
import im.vector.lib.multipicker.entity.MultiPickerFileType
|
||||
import im.vector.lib.multipicker.utils.isMimeTypeAudio
|
||||
import im.vector.lib.multipicker.utils.isMimeTypeImage
|
||||
import im.vector.lib.multipicker.utils.isMimeTypeVideo
|
||||
import im.vector.lib.multipicker.utils.toMultiPickerAudioType
|
||||
import im.vector.lib.multipicker.utils.toMultiPickerImageType
|
||||
import im.vector.lib.multipicker.utils.toMultiPickerVideoType
|
||||
|
||||
/**
|
||||
* Implementation of selecting any type of files
|
||||
*/
|
||||
class FilePicker : Picker<MultiPickerFileType>() {
|
||||
class FilePicker : Picker<MultiPickerBaseType>() {
|
||||
|
||||
/**
|
||||
* Call this function from onActivityResult(int, int, Intent).
|
||||
* Returns selected files or empty list if user did not select any files.
|
||||
*/
|
||||
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerFileType> {
|
||||
val fileList = mutableListOf<MultiPickerFileType>()
|
||||
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerBaseType> {
|
||||
return getSelectedUriList(data).mapNotNull { selectedUri ->
|
||||
val type = context.contentResolver.getType(selectedUri)
|
||||
|
||||
getSelectedUriList(data).forEach { selectedUri ->
|
||||
context.contentResolver.query(selectedUri, null, null, null, null)
|
||||
?.use { cursor ->
|
||||
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (cursor.moveToFirst()) {
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
when {
|
||||
type.isMimeTypeVideo() -> selectedUri.toMultiPickerVideoType(context)
|
||||
type.isMimeTypeImage() -> selectedUri.toMultiPickerImageType(context)
|
||||
type.isMimeTypeAudio() -> selectedUri.toMultiPickerAudioType(context)
|
||||
else -> {
|
||||
// Other files
|
||||
context.contentResolver.query(selectedUri, null, null, null, null)
|
||||
?.use { cursor ->
|
||||
val nameColumn = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndex(OpenableColumns.SIZE)
|
||||
if (cursor.moveToFirst()) {
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
|
||||
fileList.add(
|
||||
MultiPickerFileType(
|
||||
name,
|
||||
size,
|
||||
context.contentResolver.getType(selectedUri),
|
||||
selectedUri
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return fileList
|
||||
}
|
||||
|
||||
override fun createIntent(): Intent {
|
||||
|
|
|
@ -18,9 +18,8 @@ package im.vector.lib.multipicker
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.provider.MediaStore
|
||||
import im.vector.lib.multipicker.entity.MultiPickerImageType
|
||||
import im.vector.lib.multipicker.utils.ImageUtils
|
||||
import im.vector.lib.multipicker.utils.toMultiPickerImageType
|
||||
|
||||
/**
|
||||
* Image Picker implementation
|
||||
|
@ -32,46 +31,9 @@ class ImagePicker : Picker<MultiPickerImageType>() {
|
|||
* Returns selected image files or empty list if user did not select any files.
|
||||
*/
|
||||
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerImageType> {
|
||||
val imageList = mutableListOf<MultiPickerImageType>()
|
||||
|
||||
getSelectedUriList(data).forEach { selectedUri ->
|
||||
val projection = arrayOf(
|
||||
MediaStore.Images.Media.DISPLAY_NAME,
|
||||
MediaStore.Images.Media.SIZE
|
||||
)
|
||||
|
||||
context.contentResolver.query(
|
||||
selectedUri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE)
|
||||
|
||||
if (cursor.moveToNext()) {
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
|
||||
val bitmap = ImageUtils.getBitmap(context, selectedUri)
|
||||
val orientation = ImageUtils.getOrientation(context, selectedUri)
|
||||
|
||||
imageList.add(
|
||||
MultiPickerImageType(
|
||||
name,
|
||||
size,
|
||||
context.contentResolver.getType(selectedUri),
|
||||
selectedUri,
|
||||
bitmap?.width ?: 0,
|
||||
bitmap?.height ?: 0,
|
||||
orientation
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return getSelectedUriList(data).mapNotNull { selectedUri ->
|
||||
selectedUri.toMultiPickerImageType(context)
|
||||
}
|
||||
return imageList
|
||||
}
|
||||
|
||||
override fun createIntent(): Intent {
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -20,21 +20,25 @@ class MultiPicker<T> {
|
|||
|
||||
companion object Type {
|
||||
val IMAGE by lazy { MultiPicker<ImagePicker>() }
|
||||
val MEDIA by lazy { MultiPicker<MediaPicker>() }
|
||||
val FILE by lazy { MultiPicker<FilePicker>() }
|
||||
val VIDEO by lazy { MultiPicker<VideoPicker>() }
|
||||
val AUDIO by lazy { MultiPicker<AudioPicker>() }
|
||||
val CONTACT by lazy { MultiPicker<ContactPicker>() }
|
||||
val CAMERA by lazy { MultiPicker<CameraPicker>() }
|
||||
val CAMERA_VIDEO by lazy { MultiPicker<CameraVideoPicker>() }
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun <T> get(type: MultiPicker<T>): T {
|
||||
return when (type) {
|
||||
IMAGE -> ImagePicker() as T
|
||||
VIDEO -> VideoPicker() as T
|
||||
MEDIA -> MediaPicker() as T
|
||||
FILE -> FilePicker() as T
|
||||
AUDIO -> AudioPicker() as T
|
||||
CONTACT -> ContactPicker() as T
|
||||
CAMERA -> CameraPicker() as T
|
||||
CAMERA_VIDEO -> CameraVideoPicker() as T
|
||||
else -> throw IllegalArgumentException("Unsupported type $type")
|
||||
}
|
||||
}
|
||||
|
|
|
@ -18,9 +18,8 @@ package im.vector.lib.multipicker
|
|||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.media.MediaMetadataRetriever
|
||||
import android.provider.MediaStore
|
||||
import im.vector.lib.multipicker.entity.MultiPickerVideoType
|
||||
import im.vector.lib.multipicker.utils.toMultiPickerVideoType
|
||||
|
||||
/**
|
||||
* Video Picker implementation
|
||||
|
@ -32,57 +31,9 @@ class VideoPicker : Picker<MultiPickerVideoType>() {
|
|||
* Returns selected video files or empty list if user did not select any files.
|
||||
*/
|
||||
override fun getSelectedFiles(context: Context, data: Intent?): List<MultiPickerVideoType> {
|
||||
val videoList = mutableListOf<MultiPickerVideoType>()
|
||||
|
||||
getSelectedUriList(data).forEach { selectedUri ->
|
||||
val projection = arrayOf(
|
||||
MediaStore.Video.Media.DISPLAY_NAME,
|
||||
MediaStore.Video.Media.SIZE
|
||||
)
|
||||
|
||||
context.contentResolver.query(
|
||||
selectedUri,
|
||||
projection,
|
||||
null,
|
||||
null,
|
||||
null
|
||||
)?.use { cursor ->
|
||||
val nameColumn = cursor.getColumnIndex(MediaStore.Video.Media.DISPLAY_NAME)
|
||||
val sizeColumn = cursor.getColumnIndex(MediaStore.Video.Media.SIZE)
|
||||
|
||||
if (cursor.moveToNext()) {
|
||||
val name = cursor.getString(nameColumn)
|
||||
val size = cursor.getLong(sizeColumn)
|
||||
var duration = 0L
|
||||
var width = 0
|
||||
var height = 0
|
||||
var orientation = 0
|
||||
|
||||
context.contentResolver.openFileDescriptor(selectedUri, "r")?.use { pfd ->
|
||||
val mediaMetadataRetriever = MediaMetadataRetriever()
|
||||
mediaMetadataRetriever.setDataSource(pfd.fileDescriptor)
|
||||
duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L
|
||||
width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0
|
||||
height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0
|
||||
orientation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0
|
||||
}
|
||||
|
||||
videoList.add(
|
||||
MultiPickerVideoType(
|
||||
name,
|
||||
size,
|
||||
context.contentResolver.getType(selectedUri),
|
||||
selectedUri,
|
||||
width,
|
||||
height,
|
||||
orientation,
|
||||
duration
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
return getSelectedUriList(data).mapNotNull { selectedUri ->
|
||||
selectedUri.toMultiPickerVideoType(context)
|
||||
}
|
||||
return videoList
|
||||
}
|
||||
|
||||
override fun createIntent(): Intent {
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -23,7 +23,7 @@ data class MultiPickerImageType(
|
|||
override val size: Long,
|
||||
override val mimeType: String?,
|
||||
override val contentUri: Uri,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val orientation: Int
|
||||
) : MultiPickerBaseType
|
||||
override val width: Int,
|
||||
override val height: Int,
|
||||
override val orientation: Int
|
||||
) : MultiPickerBaseMediaType
|
||||
|
|
|
@ -23,8 +23,8 @@ data class MultiPickerVideoType(
|
|||
override val size: Long,
|
||||
override val mimeType: String?,
|
||||
override val contentUri: Uri,
|
||||
val width: Int,
|
||||
val height: Int,
|
||||
val orientation: Int,
|
||||
override val width: Int,
|
||||
override val height: Int,
|
||||
override val orientation: Int,
|
||||
val duration: Long
|
||||
) : MultiPickerBaseType
|
||||
) : MultiPickerBaseMediaType
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -46,6 +46,9 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
|
|||
@EpoxyAttribute
|
||||
lateinit var body: CharSequence
|
||||
|
||||
@EpoxyAttribute
|
||||
var bodyDetails: CharSequence? = null
|
||||
|
||||
@EpoxyAttribute
|
||||
var imageContentRenderer: ImageContentRenderer? = null
|
||||
|
||||
|
@ -73,6 +76,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
|
|||
holder.imagePreview.isVisible = data != null
|
||||
holder.body.movementMethod = movementMethod
|
||||
holder.body.text = body
|
||||
holder.bodyDetails.setTextOrHide(bodyDetails)
|
||||
body.findPillsAndProcess(coroutineScope) { it.bind(holder.body) }
|
||||
holder.timestamp.setTextOrHide(time)
|
||||
}
|
||||
|
@ -86,6 +90,7 @@ abstract class BottomSheetMessagePreviewItem : VectorEpoxyModel<BottomSheetMessa
|
|||
val avatar by bind<ImageView>(R.id.bottom_sheet_message_preview_avatar)
|
||||
val sender by bind<TextView>(R.id.bottom_sheet_message_preview_sender)
|
||||
val body by bind<TextView>(R.id.bottom_sheet_message_preview_body)
|
||||
val bodyDetails by bind<TextView>(R.id.bottom_sheet_message_preview_body_details)
|
||||
val timestamp by bind<TextView>(R.id.bottom_sheet_message_preview_timestamp)
|
||||
val imagePreview by bind<ImageView>(R.id.bottom_sheet_message_preview_image)
|
||||
}
|
||||
|
|
|
@ -78,6 +78,9 @@ class DefaultErrorFormatter @Inject constructor(
|
|||
throwable.error.code == MatrixError.M_LIMIT_EXCEEDED -> {
|
||||
limitExceededError(throwable.error)
|
||||
}
|
||||
throwable.error.code == MatrixError.M_TOO_LARGE -> {
|
||||
stringProvider.getString(R.string.error_file_too_big_simple)
|
||||
}
|
||||
throwable.error.code == MatrixError.M_THREEPID_NOT_FOUND -> {
|
||||
stringProvider.getString(R.string.login_reset_password_error_not_found)
|
||||
}
|
||||
|
|
|
@ -17,11 +17,13 @@
|
|||
package im.vector.app.core.ui.views
|
||||
|
||||
import android.content.Context
|
||||
import android.content.res.ColorStateList
|
||||
import android.util.AttributeSet
|
||||
import androidx.appcompat.widget.AppCompatImageView
|
||||
import androidx.core.view.isVisible
|
||||
import im.vector.app.R
|
||||
import im.vector.app.features.home.room.detail.timeline.item.SendStateDecoration
|
||||
import im.vector.app.features.themes.ThemeUtils
|
||||
|
||||
class SendStateImageView @JvmOverloads constructor(
|
||||
context: Context,
|
||||
|
@ -39,16 +41,19 @@ class SendStateImageView @JvmOverloads constructor(
|
|||
isVisible = when (sendState) {
|
||||
SendStateDecoration.SENDING_NON_MEDIA -> {
|
||||
setImageResource(R.drawable.ic_sending_message)
|
||||
imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.riotx_text_tertiary))
|
||||
contentDescription = context.getString(R.string.event_status_a11y_sending)
|
||||
true
|
||||
}
|
||||
SendStateDecoration.SENT -> {
|
||||
setImageResource(R.drawable.ic_message_sent)
|
||||
imageTintList = ColorStateList.valueOf(ThemeUtils.getColor(context, R.attr.riotx_text_tertiary))
|
||||
contentDescription = context.getString(R.string.event_status_a11y_sent)
|
||||
true
|
||||
}
|
||||
SendStateDecoration.FAILED -> {
|
||||
setImageResource(R.drawable.ic_sending_message_failed)
|
||||
imageTintList = null
|
||||
contentDescription = context.getString(R.string.event_status_a11y_failed)
|
||||
true
|
||||
}
|
||||
|
|
|
@ -19,6 +19,7 @@ package im.vector.app.core.utils
|
|||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.text.format.Formatter
|
||||
import org.threeten.bp.Duration
|
||||
import java.util.TreeMap
|
||||
|
||||
object TextUtils {
|
||||
|
@ -68,4 +69,15 @@ object TextUtils {
|
|||
Formatter.formatFileSize(context, normalizedSize)
|
||||
}
|
||||
}
|
||||
|
||||
fun formatDuration(duration: Duration): String {
|
||||
val hours = duration.seconds / 3600
|
||||
val minutes = (duration.seconds % 3600) / 60
|
||||
val seconds = duration.seconds % 60
|
||||
return if (hours > 0) {
|
||||
String.format("%d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
String.format("%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -15,12 +15,15 @@
|
|||
*/
|
||||
package im.vector.app.features.attachments
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import androidx.activity.result.ActivityResultLauncher
|
||||
import im.vector.app.core.dialogs.PhotoOrVideoDialog
|
||||
import im.vector.app.core.platform.Restorable
|
||||
import im.vector.app.features.settings.VectorPreferences
|
||||
import im.vector.lib.multipicker.MultiPicker
|
||||
import org.matrix.android.sdk.BuildConfig
|
||||
import org.matrix.android.sdk.api.session.content.ContentAttachmentData
|
||||
|
@ -77,10 +80,10 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab
|
|||
}
|
||||
|
||||
/**
|
||||
* Starts the process for handling image picking
|
||||
* Starts the process for handling image/video picking
|
||||
*/
|
||||
fun selectGallery(activityResultLauncher: ActivityResultLauncher<Intent>) {
|
||||
MultiPicker.get(MultiPicker.IMAGE).startWith(activityResultLauncher)
|
||||
MultiPicker.get(MultiPicker.MEDIA).startWith(activityResultLauncher)
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -91,10 +94,21 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab
|
|||
}
|
||||
|
||||
/**
|
||||
* Starts the process for handling capture image picking
|
||||
* Starts the process for handling image/video capture. Can open a dialog
|
||||
*/
|
||||
fun openCamera(context: Context, activityResultLauncher: ActivityResultLauncher<Intent>) {
|
||||
captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, activityResultLauncher)
|
||||
fun openCamera(activity: Activity,
|
||||
vectorPreferences: VectorPreferences,
|
||||
cameraActivityResultLauncher: ActivityResultLauncher<Intent>,
|
||||
cameraVideoActivityResultLauncher: ActivityResultLauncher<Intent>) {
|
||||
PhotoOrVideoDialog(activity, vectorPreferences).show(object : PhotoOrVideoDialog.PhotoOrVideoDialogListener {
|
||||
override fun takePhoto() {
|
||||
captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, cameraActivityResultLauncher)
|
||||
}
|
||||
|
||||
override fun takeVideo() {
|
||||
captureUri = MultiPicker.get(MultiPicker.CAMERA_VIDEO).startWithExpectingFile(context, cameraVideoActivityResultLauncher)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -133,15 +147,15 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab
|
|||
}
|
||||
}
|
||||
|
||||
fun onImageResult(data: Intent?) {
|
||||
fun onMediaResult(data: Intent?) {
|
||||
callback.onContentAttachmentsReady(
|
||||
MultiPicker.get(MultiPicker.IMAGE)
|
||||
MultiPicker.get(MultiPicker.MEDIA)
|
||||
.getSelectedFiles(context, data)
|
||||
.map { it.toContentAttachmentData() }
|
||||
)
|
||||
}
|
||||
|
||||
fun onPhotoResult() {
|
||||
fun onCameraResult() {
|
||||
captureUri?.let { captureUri ->
|
||||
MultiPicker.get(MultiPicker.CAMERA)
|
||||
.getTakenPhoto(context, captureUri)
|
||||
|
@ -153,6 +167,18 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab
|
|||
}
|
||||
}
|
||||
|
||||
fun onCameraVideoResult() {
|
||||
captureUri?.let { captureUri ->
|
||||
MultiPicker.get(MultiPicker.CAMERA_VIDEO)
|
||||
.getTakenVideo(context, captureUri)
|
||||
?.let {
|
||||
callback.onContentAttachmentsReady(
|
||||
listOf(it).map { it.toContentAttachmentData() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onVideoResult(data: Intent?) {
|
||||
callback.onContentAttachmentsReady(
|
||||
MultiPicker.get(MultiPicker.VIDEO)
|
||||
|
|
|
@ -17,6 +17,7 @@
|
|||
package im.vector.app.features.attachments
|
||||
|
||||
import im.vector.lib.multipicker.entity.MultiPickerAudioType
|
||||
import im.vector.lib.multipicker.entity.MultiPickerBaseMediaType
|
||||
import im.vector.lib.multipicker.entity.MultiPickerBaseType
|
||||
import im.vector.lib.multipicker.entity.MultiPickerContactType
|
||||
import im.vector.lib.multipicker.entity.MultiPickerFileType
|
||||
|
@ -69,6 +70,24 @@ private fun MultiPickerBaseType.mapType(): ContentAttachmentData.Type {
|
|||
}
|
||||
}
|
||||
|
||||
fun MultiPickerBaseType.toContentAttachmentData(): ContentAttachmentData {
|
||||
return when (this) {
|
||||
is MultiPickerImageType -> toContentAttachmentData()
|
||||
is MultiPickerVideoType -> toContentAttachmentData()
|
||||
is MultiPickerAudioType -> toContentAttachmentData()
|
||||
is MultiPickerFileType -> toContentAttachmentData()
|
||||
else -> throw IllegalStateException("Unknown file type")
|
||||
}
|
||||
}
|
||||
|
||||
fun MultiPickerBaseMediaType.toContentAttachmentData(): ContentAttachmentData {
|
||||
return when (this) {
|
||||
is MultiPickerImageType -> toContentAttachmentData()
|
||||
is MultiPickerVideoType -> toContentAttachmentData()
|
||||
else -> throw IllegalStateException("Unknown media type")
|
||||
}
|
||||
}
|
||||
|
||||
fun MultiPickerImageType.toContentAttachmentData(): ContentAttachmentData {
|
||||
if (mimeType == null) Timber.w("No mimeType")
|
||||
return ContentAttachmentData(
|
||||
|
|
|
@ -21,15 +21,15 @@ import org.matrix.android.sdk.api.util.MimeTypes
|
|||
|
||||
private val listOfPreviewableMimeTypes = listOf(
|
||||
MimeTypes.Jpeg,
|
||||
MimeTypes.BadJpg,
|
||||
MimeTypes.Png,
|
||||
MimeTypes.Gif
|
||||
)
|
||||
|
||||
fun ContentAttachmentData.isPreviewable(): Boolean {
|
||||
// For now the preview only supports still image
|
||||
return type == ContentAttachmentData.Type.IMAGE
|
||||
&& listOfPreviewableMimeTypes.contains(getSafeMimeType() ?: "")
|
||||
// Preview supports image and video
|
||||
return (type == ContentAttachmentData.Type.IMAGE
|
||||
&& listOfPreviewableMimeTypes.contains(getSafeMimeType() ?: ""))
|
||||
|| type == ContentAttachmentData.Type.VIDEO
|
||||
}
|
||||
|
||||
data class GroupedContentAttachmentData(
|
||||
|
|
|
@ -18,6 +18,7 @@ package im.vector.app.features.attachments.preview
|
|||
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import com.bumptech.glide.Glide
|
||||
|
@ -65,6 +66,7 @@ abstract class AttachmentMiniaturePreviewItem : AttachmentPreviewItem<Attachment
|
|||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
holder.imageView.isChecked = checked
|
||||
holder.miniatureVideoIndicator.isVisible = attachment.type == ContentAttachmentData.Type.VIDEO
|
||||
holder.view.setOnClickListener(clickListener)
|
||||
}
|
||||
|
||||
|
@ -72,6 +74,7 @@ abstract class AttachmentMiniaturePreviewItem : AttachmentPreviewItem<Attachment
|
|||
override val imageView: CheckableImageView
|
||||
get() = miniatureImageView
|
||||
private val miniatureImageView by bind<CheckableImageView>(R.id.attachmentMiniatureImageView)
|
||||
val miniatureVideoIndicator by bind<ImageView>(R.id.attachmentMiniatureVideoIndicator)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -139,7 +139,17 @@ class AttachmentsPreviewFragment @Inject constructor(
|
|||
attachmentBigPreviewController.setData(state)
|
||||
views.attachmentPreviewerBigList.scrollToPosition(state.currentAttachmentIndex)
|
||||
views.attachmentPreviewerMiniatureList.scrollToPosition(state.currentAttachmentIndex)
|
||||
views.attachmentPreviewerSendImageOriginalSize.text = resources.getQuantityString(R.plurals.send_images_with_original_size, state.attachments.size)
|
||||
views.attachmentPreviewerSendImageOriginalSize.text = getCheckboxText(state)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getCheckboxText(state: AttachmentsPreviewViewState): CharSequence {
|
||||
val nbImages = state.attachments.count { it.type == ContentAttachmentData.Type.IMAGE }
|
||||
val nbVideos = state.attachments.count { it.type == ContentAttachmentData.Type.VIDEO }
|
||||
return when {
|
||||
nbVideos == 0 -> resources.getQuantityString(R.plurals.send_images_with_original_size, nbImages)
|
||||
nbImages == 0 -> resources.getQuantityString(R.plurals.send_videos_with_original_size, nbVideos)
|
||||
else -> getString(R.string.send_images_and_video_with_original_size)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -21,6 +21,7 @@ import android.hardware.camera2.CameraManager
|
|||
import androidx.core.content.getSystemService
|
||||
import im.vector.app.core.services.CallService
|
||||
import im.vector.app.core.utils.CountUpTimer
|
||||
import im.vector.app.core.utils.TextUtils.formatDuration
|
||||
import im.vector.app.features.call.CameraEventsHandlerAdapter
|
||||
import im.vector.app.features.call.CameraProxy
|
||||
import im.vector.app.features.call.CameraType
|
||||
|
@ -829,17 +830,6 @@ class WebRtcCall(val mxCall: MxCall,
|
|||
}
|
||||
}
|
||||
|
||||
private fun formatDuration(duration: Duration): String {
|
||||
val hours = duration.seconds / 3600
|
||||
val minutes = (duration.seconds % 3600) / 60
|
||||
val seconds = duration.seconds % 60
|
||||
return if (hours > 0) {
|
||||
String.format("%d:%02d:%02d", hours, minutes, seconds)
|
||||
} else {
|
||||
String.format("%02d:%02d", minutes, seconds)
|
||||
}
|
||||
}
|
||||
|
||||
// MxCall.StateListener
|
||||
|
||||
override fun onStateUpdate(call: MxCall) {
|
||||
|
|
|
@ -101,7 +101,6 @@ import im.vector.app.core.utils.Debouncer
|
|||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.app.core.utils.KeyboardStateUtils
|
||||
import im.vector.app.core.utils.PERMISSIONS_FOR_WRITING_FILES
|
||||
import im.vector.app.core.utils.TextUtils
|
||||
import im.vector.app.core.utils.checkPermissions
|
||||
import im.vector.app.core.utils.colorizeMatchingText
|
||||
import im.vector.app.core.utils.copyToClipboard
|
||||
|
@ -381,7 +380,6 @@ class RoomDetailFragment @Inject constructor(
|
|||
is RoomDetailViewEvents.ActionFailure -> displayRoomDetailActionFailure(it)
|
||||
is RoomDetailViewEvents.ShowMessage -> showSnackWithMessage(it.message)
|
||||
is RoomDetailViewEvents.NavigateToEvent -> navigateToEvent(it)
|
||||
is RoomDetailViewEvents.FileTooBigError -> displayFileTooBigError(it)
|
||||
is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it)
|
||||
is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
|
||||
is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it)
|
||||
|
@ -749,18 +747,6 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun displayFileTooBigError(action: RoomDetailViewEvents.FileTooBigError) {
|
||||
AlertDialog.Builder(requireActivity())
|
||||
.setTitle(R.string.dialog_title_error)
|
||||
.setMessage(getString(R.string.error_file_too_big,
|
||||
action.filename,
|
||||
TextUtils.formatFileSize(requireContext(), action.fileSizeInBytes),
|
||||
TextUtils.formatFileSize(requireContext(), action.homeServerLimitInBytes)
|
||||
))
|
||||
.setPositiveButton(R.string.ok, null)
|
||||
.show()
|
||||
}
|
||||
|
||||
private fun handleDownloadFileState(action: RoomDetailViewEvents.DownloadFileState) {
|
||||
val activity = requireActivity()
|
||||
if (action.throwable != null) {
|
||||
|
@ -986,7 +972,7 @@ class RoomDetailFragment @Inject constructor(
|
|||
|
||||
private val attachmentFileActivityResultLauncher = registerStartForActivityResult {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
attachmentsHelper.onImageResult(it.data)
|
||||
attachmentsHelper.onFileResult(it.data)
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -1002,15 +988,21 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private val attachmentImageActivityResultLauncher = registerStartForActivityResult {
|
||||
private val attachmentMediaActivityResultLauncher = registerStartForActivityResult {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
attachmentsHelper.onImageResult(it.data)
|
||||
attachmentsHelper.onMediaResult(it.data)
|
||||
}
|
||||
}
|
||||
|
||||
private val attachmentPhotoActivityResultLauncher = registerStartForActivityResult {
|
||||
private val attachmentCameraActivityResultLauncher = registerStartForActivityResult {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
attachmentsHelper.onPhotoResult()
|
||||
attachmentsHelper.onCameraResult()
|
||||
}
|
||||
}
|
||||
|
||||
private val attachmentCameraVideoActivityResultLauncher = registerStartForActivityResult {
|
||||
if (it.resultCode == Activity.RESULT_OK) {
|
||||
attachmentsHelper.onCameraVideoResult()
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -2003,9 +1995,14 @@ class RoomDetailFragment @Inject constructor(
|
|||
|
||||
private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) {
|
||||
when (type) {
|
||||
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(requireContext(), attachmentPhotoActivityResultLauncher)
|
||||
AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(
|
||||
activity = requireActivity(),
|
||||
vectorPreferences = vectorPreferences,
|
||||
cameraActivityResultLauncher = attachmentCameraActivityResultLauncher,
|
||||
cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher
|
||||
)
|
||||
AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher)
|
||||
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentImageActivityResultLauncher)
|
||||
AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher)
|
||||
AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher)
|
||||
AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher)
|
||||
AttachmentTypeSelectorView.Type.STICKER -> roomDetailViewModel.handle(RoomDetailAction.SelectStickerAttachment)
|
||||
|
|
|
@ -54,12 +54,6 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
|
|||
object ShowWaitingView : RoomDetailViewEvents()
|
||||
object HideWaitingView : RoomDetailViewEvents()
|
||||
|
||||
data class FileTooBigError(
|
||||
val filename: String,
|
||||
val fileSizeInBytes: Long,
|
||||
val homeServerLimitInBytes: Long
|
||||
) : RoomDetailViewEvents()
|
||||
|
||||
data class DownloadFileState(
|
||||
val mimeType: String?,
|
||||
val file: File?,
|
||||
|
|
|
@ -79,7 +79,6 @@ import org.matrix.android.sdk.api.session.events.model.isTextMessage
|
|||
import org.matrix.android.sdk.api.session.events.model.toContent
|
||||
import org.matrix.android.sdk.api.session.events.model.toModel
|
||||
import org.matrix.android.sdk.api.session.file.FileService
|
||||
import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
|
||||
import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState
|
||||
import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams
|
||||
import org.matrix.android.sdk.api.session.room.model.Membership
|
||||
|
@ -292,7 +291,6 @@ class RoomDetailViewModel @AssistedInject constructor(
|
|||
is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action)
|
||||
is RoomDetailAction.ResendMessage -> handleResendEvent(action)
|
||||
is RoomDetailAction.RemoveFailedEcho -> handleRemove(action)
|
||||
is RoomDetailAction.ResendAll -> handleResendAll()
|
||||
is RoomDetailAction.MarkAllAsRead -> handleMarkAllAsRead()
|
||||
is RoomDetailAction.ReportContent -> handleReportContent(action)
|
||||
is RoomDetailAction.IgnoreUser -> handleIgnoreUser(action)
|
||||
|
@ -1107,23 +1105,7 @@ class RoomDetailViewModel @AssistedInject constructor(
|
|||
}
|
||||
|
||||
private fun handleSendMedia(action: RoomDetailAction.SendMedia) {
|
||||
val attachments = action.attachments
|
||||
val homeServerCapabilities = session.getHomeServerCapabilities()
|
||||
val maxUploadFileSize = homeServerCapabilities.maxUploadFileSize
|
||||
|
||||
if (maxUploadFileSize == HomeServerCapabilities.MAX_UPLOAD_FILE_SIZE_UNKNOWN) {
|
||||
// Unknown limitation
|
||||
room.sendMedias(attachments, action.compressBeforeSending, emptySet())
|
||||
} else {
|
||||
when (val tooBigFile = attachments.find { it.size > maxUploadFileSize }) {
|
||||
null -> room.sendMedias(attachments, action.compressBeforeSending, emptySet())
|
||||
else -> _viewEvents.post(RoomDetailViewEvents.FileTooBigError(
|
||||
tooBigFile.name ?: tooBigFile.queryUri.toString(),
|
||||
tooBigFile.size,
|
||||
maxUploadFileSize
|
||||
))
|
||||
}
|
||||
}
|
||||
room.sendMedias(action.attachments, action.compressBeforeSending, emptySet())
|
||||
}
|
||||
|
||||
private fun handleEventVisible(action: RoomDetailAction.TimelineEventTurnsVisible) {
|
||||
|
|
|
@ -28,16 +28,19 @@ import im.vector.app.core.epoxy.bottomsheet.bottomSheetMessagePreviewItem
|
|||
import im.vector.app.core.epoxy.bottomsheet.bottomSheetQuickReactionsItem
|
||||
import im.vector.app.core.epoxy.bottomsheet.bottomSheetSendStateItem
|
||||
import im.vector.app.core.epoxy.dividerItem
|
||||
import im.vector.app.core.error.ErrorFormatter
|
||||
import im.vector.app.core.resources.StringProvider
|
||||
import im.vector.app.core.utils.DimensionConverter
|
||||
import im.vector.app.features.home.AvatarRenderer
|
||||
import im.vector.app.features.home.room.detail.timeline.TimelineEventController
|
||||
import im.vector.app.features.home.room.detail.timeline.format.EventDetailsFormatter
|
||||
import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData
|
||||
import im.vector.app.features.home.room.detail.timeline.item.E2EDecoration
|
||||
import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod
|
||||
import im.vector.app.features.home.room.detail.timeline.tools.linkify
|
||||
import im.vector.app.features.media.ImageContentRenderer
|
||||
import org.matrix.android.sdk.api.extensions.orFalse
|
||||
import org.matrix.android.sdk.api.failure.Failure
|
||||
import org.matrix.android.sdk.api.session.room.send.SendState
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -50,6 +53,8 @@ class MessageActionsEpoxyController @Inject constructor(
|
|||
private val fontProvider: EmojiCompatFontProvider,
|
||||
private val imageContentRenderer: ImageContentRenderer,
|
||||
private val dimensionConverter: DimensionConverter,
|
||||
private val errorFormatter: ErrorFormatter,
|
||||
private val eventDetailsFormatter: EventDetailsFormatter,
|
||||
private val dateFormatter: VectorDateFormatter
|
||||
) : TypedEpoxyController<MessageActionState>() {
|
||||
|
||||
|
@ -68,16 +73,21 @@ class MessageActionsEpoxyController @Inject constructor(
|
|||
data(state.timelineEvent()?.buildImageContentRendererData(dimensionConverter.dpToPx(66)))
|
||||
userClicked { listener?.didSelectMenuAction(EventSharedAction.OpenUserProfile(state.informationData.senderId)) }
|
||||
body(state.messageBody.linkify(listener))
|
||||
bodyDetails(eventDetailsFormatter.format(state.timelineEvent()?.root))
|
||||
time(formattedDate)
|
||||
}
|
||||
|
||||
// Send state
|
||||
val sendState = state.sendState()
|
||||
if (sendState?.hasFailed().orFalse()) {
|
||||
// Get more details about the error
|
||||
val errorMessage = state.timelineEvent()?.root?.sendStateError()
|
||||
?.let { errorFormatter.toHumanReadable(Failure.ServerError(it, 0)) }
|
||||
?: stringProvider.getString(R.string.unable_to_send_message)
|
||||
bottomSheetSendStateItem {
|
||||
id("send_state")
|
||||
showProgress(false)
|
||||
text(stringProvider.getString(R.string.unable_to_send_message))
|
||||
text(errorMessage)
|
||||
drawableStart(R.drawable.ic_warning_badge)
|
||||
}
|
||||
} else if (sendState?.isSending().orFalse()) {
|
||||
|
|
|
@ -85,6 +85,7 @@ import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_BUTTONS
|
|||
import org.matrix.android.sdk.api.session.room.model.message.OPTION_TYPE_POLL
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getFileName
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
|
||||
import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent
|
||||
import org.matrix.android.sdk.api.util.MimeTypes
|
||||
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
|
||||
|
@ -337,8 +338,7 @@ class MessageItemFactory @Inject constructor(
|
|||
eventId = informationData.eventId,
|
||||
filename = messageContent.body,
|
||||
mimeType = messageContent.mimeType,
|
||||
url = messageContent.videoInfo?.thumbnailFile?.url
|
||||
?: messageContent.videoInfo?.thumbnailUrl,
|
||||
url = messageContent.videoInfo?.getThumbnailUrl(),
|
||||
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
|
||||
height = messageContent.videoInfo?.height,
|
||||
maxHeight = maxHeight,
|
||||
|
|
|
@ -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()))
|
||||
}
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package im.vector.app.features.home.room.detail.timeline.helper
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ProgressBar
|
||||
|
@ -25,6 +26,7 @@ import im.vector.app.R
|
|||
import im.vector.app.core.di.ActiveSessionHolder
|
||||
import im.vector.app.core.di.ScreenScope
|
||||
import im.vector.app.core.error.ErrorFormatter
|
||||
import im.vector.app.core.extensions.exhaustive
|
||||
import im.vector.app.core.utils.TextUtils
|
||||
import im.vector.app.features.home.room.detail.timeline.MessageColorProvider
|
||||
import org.matrix.android.sdk.api.session.content.ContentUploadStateTracker
|
||||
|
@ -70,6 +72,9 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
|
|||
private val messageColorProvider: MessageColorProvider,
|
||||
private val errorFormatter: ErrorFormatter) : ContentUploadStateTracker.UpdateListener {
|
||||
|
||||
private val progressBar: ProgressBar = progressLayout.findViewById(R.id.mediaProgressBar)
|
||||
private val progressTextView: TextView = progressLayout.findViewById(R.id.mediaProgressTextView)
|
||||
|
||||
override fun onUpdate(state: ContentUploadStateTracker.State) {
|
||||
when (state) {
|
||||
is ContentUploadStateTracker.State.Idle -> handleIdle()
|
||||
|
@ -79,19 +84,19 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
|
|||
is ContentUploadStateTracker.State.Uploading -> handleProgress(state)
|
||||
is ContentUploadStateTracker.State.Failure -> handleFailure(/*state*/)
|
||||
is ContentUploadStateTracker.State.Success -> handleSuccess()
|
||||
}
|
||||
is ContentUploadStateTracker.State.CompressingImage -> handleCompressingImage()
|
||||
is ContentUploadStateTracker.State.CompressingVideo -> handleCompressingVideo(state)
|
||||
}.exhaustive
|
||||
}
|
||||
|
||||
private fun handleIdle() {
|
||||
if (isLocalFile) {
|
||||
progressLayout.isVisible = true
|
||||
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
|
||||
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
|
||||
progressBar?.isVisible = true
|
||||
progressBar?.isIndeterminate = true
|
||||
progressBar?.progress = 0
|
||||
progressTextView?.text = progressLayout.context.getString(R.string.send_file_step_idle)
|
||||
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT))
|
||||
progressBar.isVisible = true
|
||||
progressBar.isIndeterminate = true
|
||||
progressBar.progress = 0
|
||||
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_idle)
|
||||
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNSENT))
|
||||
} else {
|
||||
progressLayout.isVisible = false
|
||||
}
|
||||
|
@ -113,38 +118,54 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
|
|||
doHandleProgress(R.string.send_file_step_sending_file, state.current, state.total)
|
||||
}
|
||||
|
||||
private fun handleCompressingImage() {
|
||||
progressLayout.visibility = View.VISIBLE
|
||||
progressBar.isVisible = true
|
||||
progressBar.isIndeterminate = true
|
||||
progressTextView.isVisible = true
|
||||
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_compressing_image)
|
||||
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
|
||||
}
|
||||
|
||||
// Add SuppressLint to fix a false positive
|
||||
@SuppressLint("StringFormatMatches")
|
||||
private fun handleCompressingVideo(state: ContentUploadStateTracker.State.CompressingVideo) {
|
||||
progressLayout.visibility = View.VISIBLE
|
||||
progressBar.isVisible = true
|
||||
progressBar.isIndeterminate = false
|
||||
progressBar.progress = state.percent.toInt()
|
||||
progressTextView.isVisible = true
|
||||
// False positive is here...
|
||||
progressTextView.text = progressLayout.context.getString(R.string.send_file_step_compressing_video, state.percent.toInt())
|
||||
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
|
||||
}
|
||||
|
||||
private fun doHandleEncrypting(resId: Int, current: Long, total: Long) {
|
||||
progressLayout.visibility = View.VISIBLE
|
||||
val percent = if (total > 0) (100L * (current.toFloat() / total.toFloat())) else 0f
|
||||
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
|
||||
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
|
||||
progressBar?.isIndeterminate = false
|
||||
progressBar?.progress = percent.toInt()
|
||||
progressBar.isIndeterminate = false
|
||||
progressBar.progress = percent.toInt()
|
||||
progressTextView.isVisible = true
|
||||
progressTextView?.text = progressLayout.context.getString(resId)
|
||||
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING))
|
||||
progressTextView.text = progressLayout.context.getString(resId)
|
||||
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING))
|
||||
}
|
||||
|
||||
private fun doHandleProgress(resId: Int, current: Long, total: Long) {
|
||||
progressLayout.visibility = View.VISIBLE
|
||||
val percent = 100L * (current.toFloat() / total.toFloat())
|
||||
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
|
||||
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
|
||||
progressBar?.isVisible = true
|
||||
progressBar?.isIndeterminate = false
|
||||
progressBar?.progress = percent.toInt()
|
||||
progressBar.isVisible = true
|
||||
progressBar.isIndeterminate = false
|
||||
progressBar.progress = percent.toInt()
|
||||
progressTextView.isVisible = true
|
||||
progressTextView?.text = progressLayout.context.getString(resId,
|
||||
progressTextView.text = progressLayout.context.getString(resId,
|
||||
TextUtils.formatFileSize(progressLayout.context, current, true),
|
||||
TextUtils.formatFileSize(progressLayout.context, total, true))
|
||||
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
|
||||
progressTextView.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
|
||||
}
|
||||
|
||||
private fun handleFailure(/*state: ContentUploadStateTracker.State.Failure*/) {
|
||||
progressLayout.visibility = View.VISIBLE
|
||||
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
|
||||
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
|
||||
progressBar?.isVisible = false
|
||||
progressBar.isVisible = false
|
||||
// Do not show the message it's too technical for users, and unfortunate when upload is cancelled
|
||||
// in the middle by turning airplane mode for example
|
||||
progressTextView.isVisible = false
|
||||
|
|
|
@ -23,6 +23,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel
|
|||
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
|
||||
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
|
||||
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
|
||||
|
||||
|
@ -45,15 +46,16 @@ fun TimelineEvent.buildImageContentRendererData(maxHeight: Int): ImageContentRen
|
|||
}
|
||||
root.isVideoMessage() -> root.getClearContent().toModel<MessageVideoContent>()
|
||||
?.let { messageVideoContent ->
|
||||
val videoInfo = messageVideoContent.videoInfo
|
||||
ImageContentRenderer.Data(
|
||||
eventId = eventId,
|
||||
filename = messageVideoContent.body,
|
||||
mimeType = messageVideoContent.mimeType,
|
||||
url = messageVideoContent.getFileUrl(),
|
||||
elementToDecrypt = messageVideoContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
height = messageVideoContent.videoInfo?.height,
|
||||
mimeType = videoInfo?.thumbnailInfo?.mimeType,
|
||||
url = videoInfo?.getThumbnailUrl(),
|
||||
elementToDecrypt = videoInfo?.thumbnailFile?.toElementToDecrypt(),
|
||||
height = videoInfo?.thumbnailInfo?.height,
|
||||
maxHeight = maxHeight,
|
||||
width = messageVideoContent.videoInfo?.width,
|
||||
width = videoInfo?.thumbnailInfo?.width,
|
||||
maxWidth = maxHeight * 2,
|
||||
allowNonMxcUrls = false
|
||||
)
|
||||
|
|
|
@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
|
|||
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
|
||||
import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
|
||||
import org.matrix.android.sdk.api.util.MimeTypes
|
||||
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
|
||||
|
@ -78,8 +79,7 @@ class RoomEventsAttachmentProvider(
|
|||
eventId = it.eventId,
|
||||
filename = content.body,
|
||||
mimeType = content.mimeType,
|
||||
url = content.videoInfo?.thumbnailFile?.url
|
||||
?: content.videoInfo?.thumbnailUrl,
|
||||
url = content.videoInfo?.getThumbnailUrl(),
|
||||
elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
|
||||
height = content.videoInfo?.height,
|
||||
maxHeight = -1,
|
||||
|
@ -102,8 +102,7 @@ class RoomEventsAttachmentProvider(
|
|||
data = data,
|
||||
thumbnail = AttachmentInfo.Image(
|
||||
uid = it.eventId,
|
||||
url = content.videoInfo?.thumbnailFile?.url
|
||||
?: content.videoInfo?.thumbnailUrl ?: "",
|
||||
url = content.videoInfo?.getThumbnailUrl() ?: "",
|
||||
data = thumbnailData
|
||||
|
||||
)
|
||||
|
|
|
@ -50,6 +50,7 @@ import im.vector.app.features.roomprofile.uploads.RoomUploadsViewState
|
|||
import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
|
||||
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
|
||||
import javax.inject.Inject
|
||||
|
||||
|
@ -141,8 +142,7 @@ class RoomUploadsMediaFragment @Inject constructor(
|
|||
eventId = it.eventId,
|
||||
filename = content.body,
|
||||
mimeType = content.mimeType,
|
||||
url = content.videoInfo?.thumbnailFile?.url
|
||||
?: content.videoInfo?.thumbnailUrl,
|
||||
url = content.videoInfo?.getThumbnailUrl(),
|
||||
elementToDecrypt = content.videoInfo?.thumbnailFile?.toElementToDecrypt(),
|
||||
height = content.videoInfo?.height,
|
||||
maxHeight = -1,
|
||||
|
|
|
@ -30,6 +30,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageImageContent
|
|||
import org.matrix.android.sdk.api.session.room.model.message.MessageType
|
||||
import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getFileUrl
|
||||
import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl
|
||||
import org.matrix.android.sdk.api.session.room.uploads.UploadEvent
|
||||
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
|
||||
import javax.inject.Inject
|
||||
|
@ -131,7 +132,7 @@ class UploadsMediaController @Inject constructor(
|
|||
eventId = eventId,
|
||||
filename = messageContent.body,
|
||||
mimeType = messageContent.mimeType,
|
||||
url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl,
|
||||
url = messageContent.videoInfo?.getThumbnailUrl(),
|
||||
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
|
||||
height = messageContent.videoInfo?.height,
|
||||
maxHeight = itemSize,
|
||||
|
|
|
@ -193,6 +193,13 @@ class VectorPreferences @Inject constructor(private val context: Context) {
|
|||
|
||||
private const val SETTINGS_UNKNOWN_DEVICE_DISMISSED_LIST = "SETTINGS_UNKNWON_DEVICE_DISMISSED_LIST"
|
||||
|
||||
private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE"
|
||||
|
||||
// Possible values for TAKE_PHOTO_VIDEO_MODE
|
||||
const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0
|
||||
const val TAKE_PHOTO_VIDEO_MODE_PHOTO = 1
|
||||
const val TAKE_PHOTO_VIDEO_MODE_VIDEO = 2
|
||||
|
||||
// Background sync modes
|
||||
|
||||
// some preferences keys must be kept after a logout
|
||||
|
@ -948,4 +955,17 @@ class VectorPreferences @Inject constructor(private val context: Context) {
|
|||
fun labsUseExperimentalRestricted(): Boolean {
|
||||
return defaultPrefs.getBoolean(SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE, false)
|
||||
}
|
||||
|
||||
/*
|
||||
* Photo / video picker
|
||||
*/
|
||||
fun getTakePhotoVideoMode(): Int {
|
||||
return defaultPrefs.getInt(TAKE_PHOTO_VIDEO_MODE, TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK)
|
||||
}
|
||||
|
||||
fun setTakePhotoVideoMode(mode: Int) {
|
||||
return defaultPrefs.edit {
|
||||
putInt(TAKE_PHOTO_VIDEO_MODE, mode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -23,6 +23,7 @@ import androidx.appcompat.app.AlertDialog
|
|||
import androidx.core.view.children
|
||||
import androidx.preference.Preference
|
||||
import im.vector.app.R
|
||||
import im.vector.app.core.dialogs.PhotoOrVideoDialog
|
||||
import im.vector.app.core.extensions.restart
|
||||
import im.vector.app.core.preference.VectorListPreference
|
||||
import im.vector.app.core.preference.VectorPreference
|
||||
|
@ -45,6 +46,9 @@ class VectorSettingsPreferencesFragment @Inject constructor(
|
|||
private val textSizePreference by lazy {
|
||||
findPreference<VectorPreference>(VectorPreferences.SETTINGS_INTERFACE_TEXT_SIZE_KEY)!!
|
||||
}
|
||||
private val takePhotoOrVideoPreference by lazy {
|
||||
findPreference<VectorPreference>("SETTINGS_INTERFACE_TAKE_PHOTO_VIDEO")!!
|
||||
}
|
||||
|
||||
override fun bindPref() {
|
||||
// user interface preferences
|
||||
|
@ -123,6 +127,28 @@ class VectorSettingsPreferencesFragment @Inject constructor(
|
|||
false
|
||||
}
|
||||
}
|
||||
|
||||
// Take photo or video
|
||||
updateTakePhotoOrVideoPreferenceSummary()
|
||||
takePhotoOrVideoPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener {
|
||||
PhotoOrVideoDialog(requireActivity(), vectorPreferences).showForSettings(object: PhotoOrVideoDialog.PhotoOrVideoDialogSettingsListener {
|
||||
override fun onUpdated() {
|
||||
updateTakePhotoOrVideoPreferenceSummary()
|
||||
}
|
||||
})
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateTakePhotoOrVideoPreferenceSummary() {
|
||||
takePhotoOrVideoPreference.summary = getString(
|
||||
when (vectorPreferences.getTakePhotoVideoMode()) {
|
||||
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> R.string.option_take_photo
|
||||
VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> R.string.option_take_video
|
||||
/* VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK */
|
||||
else -> R.string.option_always_ask
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// ==============================================================================================================
|
||||
|
|
|
@ -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>
|
|
@ -1,5 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.cardview.widget.CardView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:card_view="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="wrap_content"
|
||||
|
@ -17,4 +18,14 @@
|
|||
android:scaleType="centerCrop"
|
||||
tools:src="@tools:sample/backgrounds/scenic" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/attachmentMiniatureVideoIndicator"
|
||||
android:layout_width="32dp"
|
||||
android:layout_height="32dp"
|
||||
android:layout_gravity="center"
|
||||
android:contentDescription="@string/a11y_video"
|
||||
app:srcCompat="@drawable/ic_play_arrow"
|
||||
app:tint="@color/white"
|
||||
tools:ignore="MissingPrefix" />
|
||||
|
||||
</androidx.cardview.widget.CardView>
|
|
@ -76,10 +76,29 @@
|
|||
android:textColor="?riotx_text_secondary"
|
||||
android:textIsSelectable="false"
|
||||
android:textSize="14sp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintBottom_toTopOf="@+id/bottom_sheet_message_preview_body_details"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toEndOf="@id/bottom_sheet_message_preview_avatar"
|
||||
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_image"
|
||||
tools:text="Quis harum id autem cumque consequatur laboriosam aliquam sed. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. " />
|
||||
app:layout_goneMarginBottom="4dp"
|
||||
tools:text="Quis harum id autem cumque consequatur laboriosam aliquam sed. Sint accusamus dignissimos nobis ullam earum debitis aspernatur. Sint accusamus dignissimos nobis ullam earum debitis aspernatur." />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/bottom_sheet_message_preview_body_details"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:textColor="?riotx_text_tertiary"
|
||||
android:textIsSelectable="false"
|
||||
android:textSize="12sp"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="@id/bottom_sheet_message_preview_body"
|
||||
app:layout_constraintStart_toStartOf="@id/bottom_sheet_message_preview_body"
|
||||
app:layout_constraintTop_toBottomOf="@id/bottom_sheet_message_preview_body"
|
||||
tools:text="1080 x 1024 - 43s - 12kB"
|
||||
tools:visibility="visible" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
|
|
@ -145,8 +145,8 @@
|
|||
android:layout_marginBottom="4dp"
|
||||
android:contentDescription="@string/event_status_a11y_sending"
|
||||
android:src="@drawable/ic_sending_message"
|
||||
android:tint="?riotx_text_tertiary"
|
||||
android:visibility="gone"
|
||||
tools:tint="?riotx_text_tertiary"
|
||||
tools:visibility="visible" />
|
||||
|
||||
<ProgressBar
|
||||
|
@ -154,11 +154,11 @@
|
|||
android:layout_width="@dimen/item_event_message_state_size"
|
||||
android:layout_height="@dimen/item_event_message_state_size"
|
||||
android:layout_alignBottom="@+id/viewStubContainer"
|
||||
android:indeterminateTint="?riotx_text_secondary"
|
||||
android:layout_alignParentEnd="true"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_marginEnd="8dp"
|
||||
android:layout_marginBottom="4dp"
|
||||
android:indeterminateTint="?riotx_text_secondary"
|
||||
android:tint="?riotx_text_tertiary"
|
||||
android:visibility="gone"
|
||||
tools:visibility="visible" />
|
||||
|
|
|
@ -575,6 +575,9 @@
|
|||
<string name="option_take_photo_video">Take photo or video</string>
|
||||
<string name="option_take_photo">Take photo</string>
|
||||
<string name="option_take_video">Take video</string>
|
||||
<string name="option_always_ask">Always ask</string>
|
||||
|
||||
<string name="use_as_default_and_do_not_ask_again">Use as default and do not ask again</string>
|
||||
|
||||
<!-- No sticker application dialog -->
|
||||
<string name="no_sticker_application_dialog_content">You don’t currently have any stickerpacks enabled.\n\nAdd some now?</string>
|
||||
|
@ -2174,6 +2177,8 @@
|
|||
<string name="send_file_step_sending_thumbnail">Sending thumbnail (%1$s / %2$s)</string>
|
||||
<string name="send_file_step_encrypting_file">Encrypting file…</string>
|
||||
<string name="send_file_step_sending_file">Sending file (%1$s / %2$s)</string>
|
||||
<string name="send_file_step_compressing_image">Compressing image…</string>
|
||||
<string name="send_file_step_compressing_video">Compressing video %d%%</string>
|
||||
|
||||
<string name="downloading_file">Downloading file %1$s…</string>
|
||||
<string name="downloaded_file">File %1$s has been downloaded!</string>
|
||||
|
@ -2295,6 +2300,7 @@
|
|||
<item quantity="other">%d users read</item>
|
||||
</plurals>
|
||||
|
||||
<string name="error_file_too_big_simple">"The file is too large to upload."</string>
|
||||
<string name="error_file_too_big">"The file '%1$s' (%2$s) is too large to upload. The limit is %3$s."</string>
|
||||
|
||||
<string name="error_attachment">"An error occurred while retrieving the attachment."</string>
|
||||
|
@ -2785,6 +2791,11 @@
|
|||
<item quantity="one">Send image with the original size</item>
|
||||
<item quantity="other">Send images with the original size</item>
|
||||
</plurals>
|
||||
<plurals name="send_videos_with_original_size">
|
||||
<item quantity="one">Send video with the original size</item>
|
||||
<item quantity="other">Send videos with the original size</item>
|
||||
</plurals>
|
||||
<string name="send_images_and_video_with_original_size">Send media with the original size</string>
|
||||
|
||||
<string name="delete_event_dialog_title">Confirm Removal</string>
|
||||
<string name="delete_event_dialog_content">Are you sure you wish to remove (delete) this event? Note that if you delete a room name or topic change, it could undo the change.</string>
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
<!-- DARK THEME COLORS -->
|
||||
|
||||
<style name="AppTheme.Base.Dark" parent="Theme.MaterialComponents.NoActionBar.Bridge">
|
||||
<style name="AppTheme.Base.Dark" parent="Theme.MaterialComponents.NoActionBar">
|
||||
<!-- Riotx attribute for palette -->
|
||||
<item name="riotx_background">@color/riotx_background_dark</item>
|
||||
<item name="vctr_home_drawer_header_background">@color/vctr_home_drawer_header_background_dark</item>
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<androidx.preference.PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto">
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<im.vector.app.core.preference.VectorPreferenceCategory
|
||||
android:key="SETTINGS_USER_INTERFACE_KEY"
|
||||
|
@ -55,6 +56,12 @@
|
|||
android:summary="@string/settings_show_emoji_keyboard_summary"
|
||||
android:title="@string/settings_show_emoji_keyboard" />
|
||||
|
||||
<im.vector.app.core.preference.VectorPreference
|
||||
android:key="SETTINGS_INTERFACE_TAKE_PHOTO_VIDEO"
|
||||
android:persistent="false"
|
||||
android:title="@string/option_take_photo_video"
|
||||
tools:summary="@string/option_always_ask" />
|
||||
|
||||
</im.vector.app.core.preference.VectorPreferenceCategory>
|
||||
|
||||
<im.vector.app.core.preference.VectorPreferenceCategory android:title="@string/settings_category_timeline">
|
||||
|
@ -185,10 +192,10 @@
|
|||
|
||||
<im.vector.app.core.preference.VectorPreferenceCategory android:title="@string/settings_category_room_directory">
|
||||
|
||||
<im.vector.app.core.preference.VectorSwitchPreference
|
||||
android:key="SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS"
|
||||
android:summary="@string/settings_room_directory_show_all_rooms_summary"
|
||||
android:title="@string/settings_room_directory_show_all_rooms" />
|
||||
<im.vector.app.core.preference.VectorSwitchPreference
|
||||
android:key="SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS"
|
||||
android:summary="@string/settings_room_directory_show_all_rooms_summary"
|
||||
android:title="@string/settings_room_directory_show_all_rooms" />
|
||||
|
||||
</im.vector.app.core.preference.VectorPreferenceCategory>
|
||||
</androidx.preference.PreferenceScreen>
|
Loading…
Reference in New Issue