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