diff --git a/CHANGES.md b/CHANGES.md index 3a2cbcf336..db98189499 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -7,6 +7,7 @@ Features ✨: Improvements 🙌: - You can now join room through permalink and within room directory search - Add long click gesture to copy userId, user display name, room name, room topic and room alias (#1774) + - Fix several issues when uploading bug files (#1889) - Do not propose to verify session if there is only one session and 4S is not configured (#1901) Bugfix 🐛: diff --git a/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoLoaderTarget.kt b/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoLoaderTarget.kt index 65e37ed39e..d88faba110 100644 --- a/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoLoaderTarget.kt +++ b/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoLoaderTarget.kt @@ -35,6 +35,7 @@ interface VideoLoaderTarget { fun onVideoFileLoading(uid: String) fun onVideoFileLoadFailed(uid: String) fun onVideoFileReady(uid: String, file: File) + fun onVideoURLReady(uid: String, path: String) } internal class DefaultVideoLoaderTarget(val holder: VideoViewHolder, private val contextView: ImageView) : VideoLoaderTarget { @@ -70,9 +71,19 @@ internal class DefaultVideoLoaderTarget(val holder: VideoViewHolder, private val override fun onVideoFileReady(uid: String, file: File) { if (holder.boundResourceUid != uid) return + arrangeForVideoReady() + holder.videoReady(file) + } + + override fun onVideoURLReady(uid: String, path: String) { + if (holder.boundResourceUid != uid) return + arrangeForVideoReady() + holder.videoReady(path) + } + + private fun arrangeForVideoReady() { holder.thumbnailImage.isVisible = false holder.loaderProgressBar.isVisible = false holder.videoView.isVisible = true - holder.videoReady(file) } } diff --git a/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt b/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt index 8c5dff07f8..4d8be6468b 100644 --- a/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt +++ b/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt @@ -66,6 +66,13 @@ class VideoViewHolder constructor(itemView: View) : } } + fun videoReady(path: String) { + mVideoPath = path + if (isSelected) { + startPlaying() + } + } + fun videoFileLoadError() { } diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 90bdf02243..5be3330ed8 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -115,7 +115,7 @@ dependencies { def coroutines_version = "1.3.8" def markwon_version = '3.1.0' def daggerVersion = '2.25.4' - def work_version = '2.3.3' + def work_version = '2.4.0' def retrofit_version = '2.6.2' implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/Matrix.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/Matrix.kt index df359f2adc..df26bb1227 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/Matrix.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/api/Matrix.kt @@ -23,15 +23,12 @@ import androidx.work.WorkManager import com.zhuinden.monarchy.Monarchy import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.auth.AuthenticationService -import org.matrix.android.sdk.common.DaggerTestMatrixComponent import org.matrix.android.sdk.api.legacy.LegacySessionImporter +import org.matrix.android.sdk.common.DaggerTestMatrixComponent import org.matrix.android.sdk.internal.SessionManager -import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt -import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments import org.matrix.android.sdk.internal.network.UserAgentHolder import org.matrix.android.sdk.internal.util.BackgroundDetectionObserver import org.matrix.olm.OlmManager -import java.io.InputStream import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -96,9 +93,5 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo fun getSdkVersion(): String { return BuildConfig.VERSION_NAME + " (" + BuildConfig.GIT_SDK_REVISION + ")" } - - fun decryptStream(inputStream: InputStream?, elementToDecrypt: ElementToDecrypt): InputStream? { - return MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt) - } } } diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/AttachmentEncryptionTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/AttachmentEncryptionTest.kt index 80e7b6dbbb..1e109f11ae 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/AttachmentEncryptionTest.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/crypto/AttachmentEncryptionTest.kt @@ -21,7 +21,6 @@ import android.util.Base64 import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals -import org.junit.Assert.assertNotNull import org.junit.FixMethodOrder import org.junit.Test import org.junit.runner.RunWith @@ -29,6 +28,8 @@ import org.junit.runners.MethodSorters import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileKey +import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt +import java.io.ByteArrayOutputStream import java.io.InputStream /** @@ -52,17 +53,14 @@ class AttachmentEncryptionTest { memoryFile.inputStream } - val decryptedStream = MXEncryptedAttachments.decryptAttachment(inputStream, encryptedFileInfo) + val decryptedStream = ByteArrayOutputStream() + val result = MXEncryptedAttachments.decryptAttachment(inputStream, encryptedFileInfo.toElementToDecrypt()!!, decryptedStream) - assertNotNull(decryptedStream) + assert(result) - val buffer = ByteArray(100) + val toByteArray = decryptedStream.toByteArray() - val len = decryptedStream!!.read(buffer) - - decryptedStream.close() - - return Base64.encodeToString(buffer, 0, len, Base64.DEFAULT).replace("\n".toRegex(), "").replace("=".toRegex(), "") + return Base64.encodeToString(toByteArray, 0, toByteArray.size, Base64.DEFAULT).replace("\n".toRegex(), "").replace("=".toRegex(), "") } @Test diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt index 6cd003ddae..aafefa2048 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/Matrix.kt @@ -26,13 +26,10 @@ import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.legacy.LegacySessionImporter import org.matrix.android.sdk.internal.SessionManager -import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt -import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments import org.matrix.android.sdk.internal.di.DaggerMatrixComponent import org.matrix.android.sdk.internal.network.UserAgentHolder import org.matrix.android.sdk.internal.util.BackgroundDetectionObserver import org.matrix.olm.OlmManager -import java.io.InputStream import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject @@ -97,9 +94,5 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo fun getSdkVersion(): String { return BuildConfig.VERSION_NAME + " (" + BuildConfig.GIT_SDK_REVISION + ")" } - - fun decryptStream(inputStream: InputStream?, elementToDecrypt: ElementToDecrypt): InputStream? { - return MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt) - } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentUploadStateTracker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentUploadStateTracker.kt index a29e7110e2..a216770939 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentUploadStateTracker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/content/ContentUploadStateTracker.kt @@ -33,7 +33,7 @@ interface ContentUploadStateTracker { object Idle : State() object EncryptingThumbnail : State() data class UploadingThumbnail(val current: Long, val total: Long) : State() - object Encrypting : State() + data class Encrypting(val current: Long, val total: Long) : State() data class Uploading(val current: Long, val total: Long) : State() object Success : State() data class Failure(val throwable: Throwable) : State() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt index fdd3e66703..1068b92019 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt @@ -239,6 +239,14 @@ fun Event.isVideoMessage(): Boolean { } } +fun Event.isAudioMessage(): Boolean { + return getClearType() == EventType.MESSAGE + && when (getClearContent()?.toModel()?.msgType) { + MessageType.MSGTYPE_AUDIO -> true + else -> false + } +} + fun Event.isFileMessage(): Boolean { return getClearType() == EventType.MESSAGE && when (getClearContent()?.toModel()?.msgType) { @@ -246,6 +254,16 @@ fun Event.isFileMessage(): Boolean { else -> false } } +fun Event.isAttachmentMessage(): Boolean { + return getClearType() == EventType.MESSAGE + && when (getClearContent()?.toModel()?.msgType) { + MessageType.MSGTYPE_IMAGE, + MessageType.MSGTYPE_AUDIO, + MessageType.MSGTYPE_VIDEO, + MessageType.MSGTYPE_FILE -> true + else -> false + } +} fun Event.getRelationContent(): RelationDefaultContent? { return if (isEncrypted()) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt index e84b75d0af..b8e536cb33 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendService.kt @@ -110,13 +110,13 @@ interface SendService { * Schedule this message to be resent * @param localEcho the unsent local echo */ - fun resendTextMessage(localEcho: TimelineEvent): Cancelable? + fun resendTextMessage(localEcho: TimelineEvent): Cancelable /** * Schedule this message to be resent * @param localEcho the unsent local echo */ - fun resendMediaMessage(localEcho: TimelineEvent): Cancelable? + fun resendMediaMessage(localEcho: TimelineEvent): Cancelable /** * Remove this failed message from the timeline @@ -124,8 +124,16 @@ interface SendService { */ fun deleteFailedEcho(localEcho: TimelineEvent) + /** + * Delete all the events in one of the sending states + */ fun clearSendingQueue() + /** + * Cancel sending a specific event. It has to be in one of the sending states + */ + fun cancelSend(eventId: String) + /** * Resend all failed messages one by one (and keep order) */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendState.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendState.kt index f0dd2f3025..be8849b20e 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendState.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/send/SendState.kt @@ -37,7 +37,8 @@ enum class SendState { internal companion object { val HAS_FAILED_STATES = listOf(UNDELIVERED, FAILED_UNKNOWN_DEVICES) val IS_SENT_STATES = listOf(SENT, SYNCED) - val IS_SENDING_STATES = listOf(UNSENT, ENCRYPTING, SENDING) + val IS_PROGRESSING_STATES = listOf(ENCRYPTING, SENDING) + val IS_SENDING_STATES = IS_PROGRESSING_STATES + UNSENT val PENDING_STATES = IS_SENDING_STATES + HAS_FAILED_STATES } @@ -45,5 +46,7 @@ enum class SendState { fun hasFailed() = HAS_FAILED_STATES.contains(this) + fun isInProgress() = IS_PROGRESSING_STATES.contains(this) + fun isSending() = IS_SENDING_STATES.contains(this) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/attachments/MXEncryptedAttachments.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/attachments/MXEncryptedAttachments.kt index 9e1ef19b3a..11d5b4796a 100755 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/attachments/MXEncryptedAttachments.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/attachments/MXEncryptedAttachments.kt @@ -20,9 +20,14 @@ package org.matrix.android.sdk.internal.crypto.attachments import android.util.Base64 import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileKey +import org.matrix.android.sdk.internal.util.base64ToBase64Url +import org.matrix.android.sdk.internal.util.base64ToUnpaddedBase64 +import org.matrix.android.sdk.internal.util.base64UrlToBase64 import timber.log.Timber import java.io.ByteArrayOutputStream +import java.io.File import java.io.InputStream +import java.io.OutputStream import java.security.MessageDigest import java.security.SecureRandom import javax.crypto.Cipher @@ -35,8 +40,121 @@ internal object MXEncryptedAttachments { private const val SECRET_KEY_SPEC_ALGORITHM = "AES" private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" + fun encrypt(clearStream: InputStream, mimetype: String?, outputFile: File, progress: ((current: Int, total: Int) -> Unit)): EncryptedFileInfo { + val t0 = System.currentTimeMillis() + val secureRandom = SecureRandom() + val initVectorBytes = ByteArray(16) { 0.toByte() } + + val ivRandomPart = ByteArray(8) + secureRandom.nextBytes(ivRandomPart) + + System.arraycopy(ivRandomPart, 0, initVectorBytes, 0, ivRandomPart.size) + + val key = ByteArray(32) + secureRandom.nextBytes(key) + + val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + + outputFile.outputStream().use { outputStream -> + val encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) + val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) + val ivParameterSpec = IvParameterSpec(initVectorBytes) + encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) + + val data = ByteArray(CRYPTO_BUFFER_SIZE) + var read: Int + var encodedBytes: ByteArray + clearStream.use { inputStream -> + val estimatedSize = inputStream.available() + progress.invoke(0, estimatedSize) + read = inputStream.read(data) + var totalRead = read + while (read != -1) { + progress.invoke(totalRead, estimatedSize) + encodedBytes = encryptCipher.update(data, 0, read) + messageDigest.update(encodedBytes, 0, encodedBytes.size) + outputStream.write(encodedBytes) + read = inputStream.read(data) + totalRead += read + } + } + + // encrypt the latest chunk + encodedBytes = encryptCipher.doFinal() + messageDigest.update(encodedBytes, 0, encodedBytes.size) + outputStream.write(encodedBytes) + } + + return EncryptedFileInfo( + url = null, + mimetype = mimetype, + key = EncryptedFileKey( + alg = "A256CTR", + ext = true, + keyOps = listOf("encrypt", "decrypt"), + kty = "oct", + k = base64ToBase64Url(Base64.encodeToString(key, Base64.DEFAULT)) + ), + iv = Base64.encodeToString(initVectorBytes, Base64.DEFAULT).replace("\n", "").replace("=", ""), + hashes = mapOf("sha256" to base64ToUnpaddedBase64(Base64.encodeToString(messageDigest.digest(), Base64.DEFAULT))), + v = "v2" + ) + .also { Timber.v("Encrypt in ${System.currentTimeMillis() - t0}ms") } + } + +// fun cipherInputStream(attachmentStream: InputStream, mimetype: String?): Pair { +// val secureRandom = SecureRandom() +// +// // generate a random iv key +// // Half of the IV is random, the lower order bits are zeroed +// // such that the counter never wraps. +// // See https://github.com/matrix-org/matrix-ios-kit/blob/3dc0d8e46b4deb6669ed44f72ad79be56471354c/MatrixKit/Models/Room/MXEncryptedAttachments.m#L75 +// val initVectorBytes = ByteArray(16) { 0.toByte() } +// +// val ivRandomPart = ByteArray(8) +// secureRandom.nextBytes(ivRandomPart) +// +// System.arraycopy(ivRandomPart, 0, initVectorBytes, 0, ivRandomPart.size) +// +// val key = ByteArray(32) +// secureRandom.nextBytes(key) +// +// val encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) +// val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) +// val ivParameterSpec = IvParameterSpec(initVectorBytes) +// encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) +// +// val cipherInputStream = CipherInputStream(attachmentStream, encryptCipher) +// +// // Could it be possible to get the digest on the fly instead of +// val info = EncryptedFileInfo( +// url = null, +// mimetype = mimetype, +// key = EncryptedFileKey( +// alg = "A256CTR", +// ext = true, +// key_ops = listOf("encrypt", "decrypt"), +// kty = "oct", +// k = base64ToBase64Url(Base64.encodeToString(key, Base64.DEFAULT)) +// ), +// iv = Base64.encodeToString(initVectorBytes, Base64.DEFAULT).replace("\n", "").replace("=", ""), +// //hashes = mapOf("sha256" to base64ToUnpaddedBase64(Base64.encodeToString(messageDigest.digest(), Base64.DEFAULT))), +// v = "v2" +// ) +// +// val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) +// return DigestInputStream(cipherInputStream, messageDigest) to info +// } +// +// fun updateInfoWithDigest(digestInputStream: DigestInputStream, info: EncryptedFileInfo): EncryptedFileInfo { +// return info.copy( +// hashes = mapOf("sha256" to base64ToUnpaddedBase64(Base64.encodeToString(digestInputStream.messageDigest.digest(), Base64.DEFAULT))) +// ) +// } + /*** * Encrypt an attachment stream. + * DO NOT USE for big files, it will load all in memory * @param attachmentStream the attachment stream. Will be closed after this method call. * @param mimetype the mime type * @return the encryption file info @@ -59,14 +177,14 @@ internal object MXEncryptedAttachments { val key = ByteArray(32) secureRandom.nextBytes(key) - ByteArrayOutputStream().use { outputStream -> + val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + val byteArrayOutputStream = ByteArrayOutputStream() + byteArrayOutputStream.use { outputStream -> val encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) val ivParameterSpec = IvParameterSpec(initVectorBytes) encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) - val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) - val data = ByteArray(CRYPTO_BUFFER_SIZE) var read: Int var encodedBytes: ByteArray @@ -85,44 +203,26 @@ internal object MXEncryptedAttachments { encodedBytes = encryptCipher.doFinal() messageDigest.update(encodedBytes, 0, encodedBytes.size) outputStream.write(encodedBytes) - - return EncryptionResult( - encryptedFileInfo = EncryptedFileInfo( - url = null, - mimetype = mimetype, - key = EncryptedFileKey( - alg = "A256CTR", - ext = true, - keyOps = listOf("encrypt", "decrypt"), - kty = "oct", - k = base64ToBase64Url(Base64.encodeToString(key, Base64.DEFAULT)) - ), - iv = Base64.encodeToString(initVectorBytes, Base64.DEFAULT).replace("\n", "").replace("=", ""), - hashes = mapOf("sha256" to base64ToUnpaddedBase64(Base64.encodeToString(messageDigest.digest(), Base64.DEFAULT))), - v = "v2" - ), - encryptedByteArray = outputStream.toByteArray() - ) - .also { Timber.v("Encrypt in ${System.currentTimeMillis() - t0}ms") } - } - } - - /** - * Decrypt an attachment - * - * @param attachmentStream the attachment stream. Will be closed after this method call. - * @param encryptedFileInfo the encryption file info - * @return the decrypted attachment stream - */ - fun decryptAttachment(attachmentStream: InputStream?, encryptedFileInfo: EncryptedFileInfo?): InputStream? { - if (encryptedFileInfo?.isValid() != true) { - Timber.e("## decryptAttachment() : some fields are not defined, or invalid key fields") - return null } - val elementToDecrypt = encryptedFileInfo.toElementToDecrypt() - - return decryptAttachment(attachmentStream, elementToDecrypt) + return EncryptionResult( + encryptedFileInfo = EncryptedFileInfo( + url = null, + mimetype = mimetype, + key = EncryptedFileKey( + alg = "A256CTR", + ext = true, + keyOps = listOf("encrypt", "decrypt"), + kty = "oct", + k = base64ToBase64Url(Base64.encodeToString(key, Base64.DEFAULT)) + ), + iv = Base64.encodeToString(initVectorBytes, Base64.DEFAULT).replace("\n", "").replace("=", ""), + hashes = mapOf("sha256" to base64ToUnpaddedBase64(Base64.encodeToString(messageDigest.digest(), Base64.DEFAULT))), + v = "v2" + ), + encryptedByteArray = byteArrayOutputStream.toByteArray() + ) + .also { Timber.v("Encrypt in ${System.currentTimeMillis() - t0}ms") } } /** @@ -130,84 +230,61 @@ internal object MXEncryptedAttachments { * * @param attachmentStream the attachment stream. Will be closed after this method call. * @param elementToDecrypt the elementToDecrypt info - * @return the decrypted attachment stream + * @param outputStream the outputStream where the decrypted attachment will be write. + * @return true in case of success, false in case of error */ - fun decryptAttachment(attachmentStream: InputStream?, elementToDecrypt: ElementToDecrypt?): InputStream? { + fun decryptAttachment(attachmentStream: InputStream?, elementToDecrypt: ElementToDecrypt?, outputStream: OutputStream): Boolean { // sanity checks if (null == attachmentStream || elementToDecrypt == null) { Timber.e("## decryptAttachment() : null stream") - return null + return false } val t0 = System.currentTimeMillis() - ByteArrayOutputStream().use { outputStream -> - try { - val key = Base64.decode(base64UrlToBase64(elementToDecrypt.k), Base64.DEFAULT) - val initVectorBytes = Base64.decode(elementToDecrypt.iv, Base64.DEFAULT) + try { + val key = Base64.decode(base64UrlToBase64(elementToDecrypt.k), Base64.DEFAULT) + val initVectorBytes = Base64.decode(elementToDecrypt.iv, Base64.DEFAULT) - val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) - val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) - val ivParameterSpec = IvParameterSpec(initVectorBytes) - decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) + val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) + val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) + val ivParameterSpec = IvParameterSpec(initVectorBytes) + decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) - val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) + val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) - var read: Int - val data = ByteArray(CRYPTO_BUFFER_SIZE) - var decodedBytes: ByteArray + var read: Int + val data = ByteArray(CRYPTO_BUFFER_SIZE) + var decodedBytes: ByteArray - attachmentStream.use { inputStream -> + attachmentStream.use { inputStream -> + read = inputStream.read(data) + while (read != -1) { + messageDigest.update(data, 0, read) + decodedBytes = decryptCipher.update(data, 0, read) + outputStream.write(decodedBytes) read = inputStream.read(data) - while (read != -1) { - messageDigest.update(data, 0, read) - decodedBytes = decryptCipher.update(data, 0, read) - outputStream.write(decodedBytes) - read = inputStream.read(data) - } } - - // decrypt the last chunk - decodedBytes = decryptCipher.doFinal() - outputStream.write(decodedBytes) - - val currentDigestValue = base64ToUnpaddedBase64(Base64.encodeToString(messageDigest.digest(), Base64.DEFAULT)) - - if (elementToDecrypt.sha256 != currentDigestValue) { - Timber.e("## decryptAttachment() : Digest value mismatch") - return null - } - - return outputStream.toByteArray().inputStream() - .also { Timber.v("Decrypt in ${System.currentTimeMillis() - t0}ms") } - } catch (oom: OutOfMemoryError) { - Timber.e(oom, "## decryptAttachment() failed: OOM") - } catch (e: Exception) { - Timber.e(e, "## decryptAttachment() failed") } + + // decrypt the last chunk + decodedBytes = decryptCipher.doFinal() + outputStream.write(decodedBytes) + + val currentDigestValue = base64ToUnpaddedBase64(Base64.encodeToString(messageDigest.digest(), Base64.DEFAULT)) + + if (elementToDecrypt.sha256 != currentDigestValue) { + Timber.e("## decryptAttachment() : Digest value mismatch") + return false + } + + return true.also { Timber.v("Decrypt in ${System.currentTimeMillis() - t0}ms") } + } catch (oom: OutOfMemoryError) { + Timber.e(oom, "## decryptAttachment() failed: OOM") + } catch (e: Exception) { + Timber.e(e, "## decryptAttachment() failed") } - return null - } - - /** - * Base64 URL conversion methods - */ - - private fun base64UrlToBase64(base64Url: String): String { - return base64Url.replace('-', '+') - .replace('_', '/') - } - - internal fun base64ToBase64Url(base64: String): String { - return base64.replace("\n".toRegex(), "") - .replace("\\+".toRegex(), "-") - .replace('/', '_') - .replace("=", "") - } - - private fun base64ToUnpaddedBase64(base64: String): String { - return base64.replace("\n".toRegex(), "") - .replace("=", "") + return false } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/attachments/MatrixDigestCheckInputStream.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/attachments/MatrixDigestCheckInputStream.kt new file mode 100644 index 0000000000..7ca5158f64 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/attachments/MatrixDigestCheckInputStream.kt @@ -0,0 +1,69 @@ +/* + * 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 org.matrix.android.sdk.internal.crypto.attachments + +import android.util.Base64 +import org.matrix.android.sdk.internal.util.base64ToUnpaddedBase64 +import java.io.FilterInputStream +import java.io.IOException +import java.io.InputStream +import java.security.MessageDigest + +class MatrixDigestCheckInputStream( + inputStream: InputStream?, + private val expectedDigest: String +) : FilterInputStream(inputStream) { + + private val digest = MessageDigest.getInstance("SHA-256") + + @Throws(IOException::class) + override fun read(): Int { + val b = `in`.read() + if (b >= 0) { + digest.update(b.toByte()) + } + + if (b == -1) { + ensureDigest() + } + return b + } + + @Throws(IOException::class) + override fun read( + b: ByteArray, + off: Int, + len: Int): Int { + val n = `in`.read(b, off, len) + if (n > 0) { + digest.update(b, off, n) + } + + if (n == -1) { + ensureDigest() + } + return n + } + + @Throws(IOException::class) + private fun ensureDigest() { + val currentDigestValue = base64ToUnpaddedBase64(Base64.encodeToString(digest.digest(), Base64.DEFAULT)) + if (currentDigestValue != expectedDigest) { + throw IOException("Bad digest") + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ProgressRequestBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ProgressRequestBody.kt index 7ce260e54e..98dec301ee 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ProgressRequestBody.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/ProgressRequestBody.kt @@ -24,6 +24,7 @@ import okio.BufferedSink import okio.ForwardingSink import okio.Sink import okio.buffer +import org.matrix.android.sdk.api.extensions.tryThis import java.io.IOException internal class ProgressRequestBody(private val delegate: RequestBody, @@ -35,15 +36,13 @@ internal class ProgressRequestBody(private val delegate: RequestBody, return delegate.contentType() } - override fun contentLength(): Long { - try { - return delegate.contentLength() - } catch (e: IOException) { - e.printStackTrace() - } + override fun isOneShot() = delegate.isOneShot() - return -1 - } + override fun isDuplex() = delegate.isDuplex() + + val length = tryThis { delegate.contentLength() } ?: -1 + + override fun contentLength() = length @Throws(IOException::class) override fun writeTo(sink: BufferedSink) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultFileService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultFileService.kt index 97ebe943ec..aa4114c8c2 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultFileService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultFileService.kt @@ -143,20 +143,22 @@ internal class DefaultFileService @Inject constructor( Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${!source.exhausted()}") if (elementToDecrypt != null) { - Timber.v("## decrypt file") - val decryptedStream = MXEncryptedAttachments.decryptAttachment(source.inputStream(), elementToDecrypt) + Timber.v("## FileService: decrypt file") + val decryptSuccess = MXEncryptedAttachments.decryptAttachment( + source.inputStream(), + elementToDecrypt, + destFile.outputStream().buffered() + ) response.close() - if (decryptedStream == null) { + if (!decryptSuccess) { return@flatMap Try.Failure(IllegalStateException("Decryption error")) - } else { - decryptedStream.use { - writeToFile(decryptedStream, destFile) - } } } else { writeToFile(source.inputStream(), destFile) response.close() } + } else { + Timber.v("## FileService: cache hit for $url") } Try.just(copyFile(destFile, downloadMode)) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/DefaultContentUploadStateTracker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/DefaultContentUploadStateTracker.kt index aa8b98ae62..951c24ccb7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/DefaultContentUploadStateTracker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/DefaultContentUploadStateTracker.kt @@ -74,8 +74,8 @@ internal class DefaultContentUploadStateTracker @Inject constructor() : ContentU updateState(key, progressData) } - internal fun setEncrypting(key: String) { - val progressData = ContentUploadStateTracker.State.Encrypting + internal fun setEncrypting(key: String, current: Long, total: Long) { + val progressData = ContentUploadStateTracker.State.Encrypting(current, total) updateState(key, progressData) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt index 5e5380fce1..4ddf394b00 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/FileUploader.kt @@ -23,13 +23,16 @@ import com.squareup.moshi.Moshi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.MediaType import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.OkHttpClient import okhttp3.Request import okhttp3.RequestBody -import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.RequestBody.Companion.toRequestBody +import okio.BufferedSink +import okio.source import org.greenrobot.eventbus.EventBus +import org.matrix.android.sdk.api.extensions.tryThis import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.internal.di.Authenticated import org.matrix.android.sdk.internal.network.ProgressRequestBody @@ -38,6 +41,7 @@ import org.matrix.android.sdk.internal.network.toFailure import java.io.File import java.io.FileNotFoundException import java.io.IOException +import java.util.UUID import javax.inject.Inject internal class FileUploader @Inject constructor(@Authenticated @@ -54,7 +58,21 @@ internal class FileUploader @Inject constructor(@Authenticated filename: String?, mimeType: String?, progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { - val uploadBody = file.asRequestBody(mimeType?.toMediaTypeOrNull()) + val uploadBody = object : RequestBody() { + override fun contentLength() = file.length() + + // Disable okhttp auto resend for 'large files' + override fun isOneShot() = contentLength() == 0L || contentLength() >= 1_000_000 + + override fun contentType(): MediaType? { + return mimeType?.toMediaTypeOrNull() + } + + override fun writeTo(sink: BufferedSink) { + file.source().use { sink.writeAll(it) } + } + } + return upload(uploadBody, filename, progressListener) } @@ -70,14 +88,18 @@ internal class FileUploader @Inject constructor(@Authenticated filename: String?, mimeType: String?, progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { - return withContext(Dispatchers.IO) { - val inputStream = context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException() - - inputStream.use { - uploadByteArray(it.readBytes(), filename, mimeType, progressListener) - } + val inputStream = withContext(Dispatchers.IO) { + context.contentResolver.openInputStream(uri) + } ?: throw FileNotFoundException() + val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) + workingFile.outputStream().use { + inputStream.copyTo(it) + } + return uploadFile(workingFile, filename, mimeType, progressListener).also { + tryThis { workingFile.delete() } } } + private suspend fun upload(uploadBody: RequestBody, filename: String?, progressListener: ProgressRequestBody.Listener?): ContentUploadResponse { val urlBuilder = uploadUrl.toHttpUrlOrNull()?.newBuilder() ?: throw RuntimeException() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt index 720269404f..015ad3a1e4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt @@ -24,6 +24,7 @@ import androidx.work.WorkerParameters import com.squareup.moshi.JsonClass import id.zelory.compressor.Compressor import id.zelory.compressor.constraint.default +import org.matrix.android.sdk.api.extensions.tryThis import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.toContent @@ -37,6 +38,7 @@ import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo import org.matrix.android.sdk.internal.network.ProgressRequestBody import org.matrix.android.sdk.internal.session.DefaultFileService +import org.matrix.android.sdk.internal.session.room.send.CancelSendTracker import org.matrix.android.sdk.internal.session.room.send.MultipleEventSendingDispatcherWorker import org.matrix.android.sdk.internal.worker.SessionWorkerParams import org.matrix.android.sdk.internal.worker.WorkerParamsFactory @@ -71,6 +73,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter @Inject lateinit var fileUploader: FileUploader @Inject lateinit var contentUploadStateTracker: DefaultContentUploadStateTracker @Inject lateinit var fileService: DefaultFileService + @Inject lateinit var cancelSendTracker: CancelSendTracker override suspend fun doWork(): Result { val params = WorkerParamsFactory.fromData(inputData) @@ -102,6 +105,13 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter var newImageAttributes: NewImageAttributes? = null + val allCancelled = params.events.all { cancelSendTracker.isCancelRequestedFor(it.eventId, it.roomId) } + if (allCancelled) { + // there is no point in uploading the image! + return Result.success(inputData) + .also { Timber.e("## Send: Work cancelled by user") } + } + try { val inputStream = context.contentResolver.openInputStream(attachment.queryUri) ?: return Result.success( @@ -112,16 +122,22 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter ) ) - inputStream.use { - var uploadedThumbnailUrl: String? = null - var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null + // 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) + workingFile.outputStream().use { + inputStream.copyTo(it) + } - ThumbnailExtractor.extractThumbnail(context, params.attachment)?.let { thumbnailData -> - val thumbnailProgressListener = object : ProgressRequestBody.Listener { - override fun onProgress(current: Long, total: Long) { - notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) } - } +// inputStream.use { + var uploadedThumbnailUrl: String? = null + var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null + + ThumbnailExtractor.extractThumbnail(context, params.attachment)?.let { thumbnailData -> + val thumbnailProgressListener = object : ProgressRequestBody.Listener { + override fun onProgress(current: Long, total: Long) { + notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) } } + } try { val contentUploadResponse = if (params.isEncrypted) { @@ -140,96 +156,102 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter thumbnailProgressListener) } - uploadedThumbnailUrl = contentUploadResponse.contentUri - } catch (t: Throwable) { - Timber.e(t, "Thumbnail update failed") - } + uploadedThumbnailUrl = contentUploadResponse.contentUri + } catch (t: Throwable) { + Timber.e(t, "Thumbnail update failed") } + } - val progressListener = object : ProgressRequestBody.Listener { - override fun onProgress(current: Long, total: Long) { - notifyTracker(params) { - if (isStopped) { - contentUploadStateTracker.setFailure(it, Throwable("Cancelled")) - } else { - contentUploadStateTracker.setProgress(it, current, total) - } + val progressListener = object : ProgressRequestBody.Listener { + override fun onProgress(current: Long, total: Long) { + notifyTracker(params) { + if (isStopped) { + contentUploadStateTracker.setFailure(it, Throwable("Cancelled")) + } else { + contentUploadStateTracker.setProgress(it, current, total) } } } + } - var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null + var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null - return try { + return try { + val fileToUplaod: File + + if (attachment.type == ContentAttachmentData.Type.IMAGE && params.compressBeforeSending) { // Compressor library works with File instead of Uri for now. Since Scoped Storage doesn't allow us to access files directly, we should // copy it to a cache folder by using InputStream and OutputStream. // https://github.com/zetbaitsu/Compressor/pull/150 // As soon as the above PR is merged, we can use attachment.queryUri instead of creating a cacheFile. - var cacheFile = File.createTempFile(attachment.name ?: UUID.randomUUID().toString(), ".jpg", context.cacheDir) - cacheFile.parentFile?.mkdirs() - if (cacheFile.exists()) { - cacheFile.delete() - } - cacheFile.createNewFile() - cacheFile.deleteOnExit() - - val outputStream = cacheFile.outputStream() - outputStream.use { - inputStream.copyTo(outputStream) + val compressedFile = Compressor.compress(context, workingFile) { + default( + width = MAX_IMAGE_SIZE, + height = MAX_IMAGE_SIZE + ) } - if (attachment.type == ContentAttachmentData.Type.IMAGE && params.compressBeforeSending) { - cacheFile = Compressor.compress(context, cacheFile) { - default( - width = MAX_IMAGE_SIZE, - height = MAX_IMAGE_SIZE - ) - }.also { compressedFile -> - val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } - BitmapFactory.decodeFile(compressedFile.absolutePath, options) - val fileSize = compressedFile.length().toInt() - newImageAttributes = NewImageAttributes( - options.outWidth, - options.outHeight, - fileSize - ) - } - } - - val contentUploadResponse = if (params.isEncrypted) { - Timber.v("Encrypt file") - notifyTracker(params) { contentUploadStateTracker.setEncrypting(it) } - - val encryptionResult = MXEncryptedAttachments.encryptAttachment(cacheFile.inputStream(), attachment.getSafeMimeType()) - uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo - - fileUploader - .uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener) - } else { - fileUploader - .uploadFile(cacheFile, attachment.name, attachment.getSafeMimeType(), progressListener) - } - - // If it's a file update the file service so that it does not redownload? - if (params.attachment.type == ContentAttachmentData.Type.FILE) { - context.contentResolver.openInputStream(attachment.queryUri)?.let { - fileService.storeDataFor(contentUploadResponse.contentUri, params.attachment.getSafeMimeType(), it) - } - } - - handleSuccess(params, - contentUploadResponse.contentUri, - uploadedFileEncryptedFileInfo, - uploadedThumbnailUrl, - uploadedThumbnailEncryptedFileInfo, - newImageAttributes) - } catch (t: Throwable) { - Timber.e(t) - handleFailure(params, t) + val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeFile(compressedFile.absolutePath, options) + val fileSize = compressedFile.length().toInt() + newImageAttributes = NewImageAttributes( + options.outWidth, + options.outHeight, + fileSize + ) + fileToUplaod = compressedFile + } else { + fileToUplaod = workingFile } + + val contentUploadResponse = if (params.isEncrypted) { + Timber.v("## FileService: Encrypt file") + + val tmpEncrypted = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir) + + uploadedFileEncryptedFileInfo = + MXEncryptedAttachments.encrypt(fileToUplaod.inputStream(), attachment.getSafeMimeType(), tmpEncrypted) { read, total -> + notifyTracker(params) { + contentUploadStateTracker.setEncrypting(it, read.toLong(), total.toLong()) + } + } + + Timber.v("## FileService: Uploading file") + + fileUploader + .uploadFile(tmpEncrypted, attachment.name, "application/octet-stream", progressListener) + .also { + // we can delete? + tryThis { tmpEncrypted.delete() } + } + } else { + Timber.v("## FileService: Clear file") + fileUploader + .uploadFile(fileToUplaod, attachment.name, attachment.getSafeMimeType(), progressListener) + } + + Timber.v("## FileService: Update cache storage for ${contentUploadResponse.contentUri}") + try { + context.contentResolver.openInputStream(attachment.queryUri)?.let { + fileService.storeDataFor(contentUploadResponse.contentUri, params.attachment.getSafeMimeType(), it) + } + Timber.v("## FileService: cache storage updated") + } catch (failure: Throwable) { + Timber.e(failure, "## FileService: Failed to update fileservice cache") + } + + handleSuccess(params, + contentUploadResponse.contentUri, + uploadedFileEncryptedFileInfo, + uploadedThumbnailUrl, + uploadedThumbnailEncryptedFileInfo, + newImageAttributes) + } catch (t: Throwable) { + Timber.e(t, "## FileService: ERROR ${t.localizedMessage}") + handleFailure(params, t) } } catch (e: Exception) { - Timber.e(e) + Timber.e(e, "## FileService: ERROR") notifyTracker(params) { contentUploadStateTracker.setFailure(it, e) } return Result.success( WorkerParamsFactory.toData( @@ -259,7 +281,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter thumbnailUrl: String?, thumbnailEncryptedFileInfo: EncryptedFileInfo?, newImageAttributes: NewImageAttributes?): Result { - Timber.v("handleSuccess $attachmentUrl, work is stopped $isStopped") notifyTracker(params) { contentUploadStateTracker.setSuccess(it) } val updatedEvents = params.events @@ -268,7 +289,9 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter } val sendParams = MultipleEventSendingDispatcherWorker.Params(params.sessionId, updatedEvents, params.isEncrypted) - return Result.success(WorkerParamsFactory.toData(sendParams)) + return Result.success(WorkerParamsFactory.toData(sendParams)).also { + Timber.v("## handleSuccess $attachmentUrl, work is stopped $isStopped") + } } private fun updateEvent(event: Event, diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/download/DefaultContentDownloadStateTracker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/download/DefaultContentDownloadStateTracker.kt index 295a829b08..c4ba95af84 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/download/DefaultContentDownloadStateTracker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/download/DefaultContentDownloadStateTracker.kt @@ -61,19 +61,23 @@ internal class DefaultContentDownloadStateTracker @Inject constructor() : Progre // private fun URL.toKey() = toString() override fun update(url: String, bytesRead: Long, contentLength: Long, done: Boolean) { - Timber.v("## DL Progress url:$url read:$bytesRead total:$contentLength done:$done") - if (done) { - updateState(url, ContentDownloadStateTracker.State.Success) - } else { - updateState(url, ContentDownloadStateTracker.State.Downloading(bytesRead, contentLength, contentLength == -1L)) + mainHandler.post { + Timber.v("## DL Progress url:$url read:$bytesRead total:$contentLength done:$done") + if (done) { + updateState(url, ContentDownloadStateTracker.State.Success) + } else { + updateState(url, ContentDownloadStateTracker.State.Downloading(bytesRead, contentLength, contentLength == -1L)) + } } } override fun error(url: String, errorCode: Int) { - Timber.v("## DL Progress Error code:$errorCode") - updateState(url, ContentDownloadStateTracker.State.Failure(errorCode)) - listeners[url]?.forEach { - tryThis { it.onDownloadStateUpdate(ContentDownloadStateTracker.State.Failure(errorCode)) } + mainHandler.post { + Timber.v("## DL Progress Error code:$errorCode") + updateState(url, ContentDownloadStateTracker.State.Failure(errorCode)) + listeners[url]?.forEach { + tryThis { it.onDownloadStateUpdate(ContentDownloadStateTracker.State.Failure(errorCode)) } + } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityBulkLookupTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityBulkLookupTask.kt index ac33c2666f..45d7d48a18 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityBulkLookupTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/identity/IdentityBulkLookupTask.kt @@ -23,7 +23,6 @@ import org.matrix.android.sdk.api.session.identity.FoundThreePid import org.matrix.android.sdk.api.session.identity.IdentityServiceError import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.identity.toMedium -import org.matrix.android.sdk.internal.crypto.attachments.MXEncryptedAttachments.base64ToBase64Url import org.matrix.android.sdk.internal.crypto.tools.withOlmUtility import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.network.executeRequest @@ -32,6 +31,7 @@ import org.matrix.android.sdk.internal.session.identity.model.IdentityHashDetail import org.matrix.android.sdk.internal.session.identity.model.IdentityLookUpParams import org.matrix.android.sdk.internal.session.identity.model.IdentityLookUpResponse import org.matrix.android.sdk.internal.task.Task +import org.matrix.android.sdk.internal.util.base64ToBase64Url import java.util.Locale import javax.inject.Inject diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/CancelSendTracker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/CancelSendTracker.kt new file mode 100644 index 0000000000..0b79b93cf6 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/CancelSendTracker.kt @@ -0,0 +1,62 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright 2020 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.room.send + +import org.matrix.android.sdk.internal.session.SessionScope +import javax.inject.Inject + +/** + * We cannot use work manager cancellation mechanism because cancelling a work will just ignore + * any follow up send that was already queued. + * We use this class to track cancel requests, the workers will look for this to check for cancellation request + * and just ignore the work request and continue by returning success. + * + * Known limitation, for now requests are not persisted + */ +@SessionScope +internal class CancelSendTracker @Inject constructor() { + + data class Request( + val localId: String, + val roomId: String + ) + + private val cancellingRequests = ArrayList() + + fun markLocalEchoForCancel(eventId: String, roomId: String) { + synchronized(cancellingRequests) { + cancellingRequests.add(Request(eventId, roomId)) + } + } + + fun isCancelRequestedFor(eventId: String?, roomId: String?): Boolean { + val index = synchronized(cancellingRequests) { + cancellingRequests.indexOfFirst { it.localId == eventId && it.roomId == roomId } + } + return index != -1 + } + + fun markCancelled(eventId: String, roomId: String) { + synchronized(cancellingRequests) { + val index = cancellingRequests.indexOfFirst { it.localId == eventId && it.roomId == roomId } + if (index != -1) { + cancellingRequests.removeAt(index) + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt index d6fa6775ee..95cd1c699c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/DefaultSendService.kt @@ -17,24 +17,35 @@ package org.matrix.android.sdk.internal.session.room.send +import android.net.Uri import androidx.work.BackoffPolicy import androidx.work.ExistingWorkPolicy import androidx.work.OneTimeWorkRequest import androidx.work.Operation import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.AssistedInject +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.crypto.CryptoService import org.matrix.android.sdk.api.session.events.model.Event -import org.matrix.android.sdk.api.session.events.model.isImageMessage +import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage +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.MessageContent +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.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent import org.matrix.android.sdk.api.session.room.model.message.OptionItem +import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.send.SendService import org.matrix.android.sdk.api.session.room.send.SendState import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.CancelableBag import org.matrix.android.sdk.api.util.JsonDict +import org.matrix.android.sdk.api.util.NoOpCancellable import org.matrix.android.sdk.internal.di.SessionId import org.matrix.android.sdk.internal.di.WorkManagerProvider import org.matrix.android.sdk.internal.session.content.UploadContentWorker @@ -44,7 +55,6 @@ import org.matrix.android.sdk.internal.util.CancelableWork import org.matrix.android.sdk.internal.worker.AlwaysSuccessfulWorker import org.matrix.android.sdk.internal.worker.WorkerParamsFactory import org.matrix.android.sdk.internal.worker.startChain -import kotlinx.coroutines.launch import timber.log.Timber import java.util.concurrent.Executors import java.util.concurrent.TimeUnit @@ -60,7 +70,8 @@ internal class DefaultSendService @AssistedInject constructor( private val cryptoService: CryptoService, private val taskExecutor: TaskExecutor, private val localEchoRepository: LocalEchoRepository, - private val roomEventSender: RoomEventSender + private val roomEventSender: RoomEventSender, + private val cancelSendTracker: CancelSendTracker ) : SendService { @AssistedInject.Factory @@ -127,48 +138,83 @@ internal class DefaultSendService @AssistedInject constructor( .let { timelineSendEventWorkCommon.postWork(roomId, it) } } - override fun resendTextMessage(localEcho: TimelineEvent): Cancelable? { + override fun resendTextMessage(localEcho: TimelineEvent): Cancelable { if (localEcho.root.isTextMessage() && localEcho.root.sendState.hasFailed()) { localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT) return sendEvent(localEcho.root) } - return null + return NoOpCancellable } - override fun resendMediaMessage(localEcho: TimelineEvent): Cancelable? { - if (localEcho.root.isImageMessage() && localEcho.root.sendState.hasFailed()) { - // TODO this need a refactoring of attachement sending -// val clearContent = localEcho.root.getClearContent() -// val messageContent = clearContent?.toModel() ?: return null -// when (messageContent.type) { -// MessageType.MSGTYPE_IMAGE -> { -// val imageContent = clearContent.toModel() ?: return null -// val url = imageContent.url ?: return null -// if (url.startsWith("mxc://")) { -// //TODO -// } else { -// //The image has not yet been sent -// val attachmentData = ContentAttachmentData( -// size = imageContent.info!!.size.toLong(), -// mimeType = imageContent.info.mimeType!!, -// width = imageContent.info.width.toLong(), -// height = imageContent.info.height.toLong(), -// name = imageContent.body, -// path = imageContent.url, -// type = ContentAttachmentData.Type.IMAGE -// ) -// monarchy.runTransactionSync { -// EventEntity.where(it,eventId = localEcho.root.eventId ?: "").findFirst()?.let { -// it.sendState = SendState.UNSENT -// } -// } -// return internalSendMedia(localEcho.root,attachmentData) -// } -// } -// } - return null + override fun resendMediaMessage(localEcho: TimelineEvent): Cancelable { + if (localEcho.root.sendState.hasFailed()) { + val clearContent = localEcho.root.getClearContent() + val messageContent = clearContent?.toModel() as? MessageWithAttachmentContent ?: return NoOpCancellable + + val url = messageContent.getFileUrl() ?: return NoOpCancellable + if (url.startsWith("mxc://")) { + // We need to resend only the message as the attachment is ok + localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT) + return sendEvent(localEcho.root) + } + + // we need to resend the media + return when (messageContent) { + is MessageImageContent -> { + // The image has not yet been sent + val attachmentData = ContentAttachmentData( + size = messageContent.info!!.size.toLong(), + mimeType = messageContent.info.mimeType!!, + width = messageContent.info.width.toLong(), + height = messageContent.info.height.toLong(), + name = messageContent.body, + queryUri = Uri.parse(messageContent.url), + type = ContentAttachmentData.Type.IMAGE + ) + localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT) + internalSendMedia(listOf(localEcho.root), attachmentData, true) + } + is MessageVideoContent -> { + val attachmentData = ContentAttachmentData( + size = messageContent.videoInfo?.size ?: 0L, + mimeType = messageContent.mimeType, + width = messageContent.videoInfo?.width?.toLong(), + height = messageContent.videoInfo?.height?.toLong(), + duration = messageContent.videoInfo?.duration?.toLong(), + name = messageContent.body, + queryUri = Uri.parse(messageContent.url), + type = ContentAttachmentData.Type.VIDEO + ) + localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT) + internalSendMedia(listOf(localEcho.root), attachmentData, true) + } + is MessageFileContent -> { + val attachmentData = ContentAttachmentData( + size = messageContent.info!!.size, + mimeType = messageContent.info.mimeType!!, + name = messageContent.body, + queryUri = Uri.parse(messageContent.url), + type = ContentAttachmentData.Type.FILE + ) + localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT) + internalSendMedia(listOf(localEcho.root), attachmentData, true) + } + is MessageAudioContent -> { + val attachmentData = ContentAttachmentData( + size = messageContent.audioInfo?.size ?: 0, + duration = messageContent.audioInfo?.duration?.toLong() ?: 0L, + mimeType = messageContent.audioInfo?.mimeType, + name = messageContent.body, + queryUri = Uri.parse(messageContent.url), + type = ContentAttachmentData.Type.AUDIO + ) + localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT) + internalSendMedia(listOf(localEcho.root), attachmentData, true) + } + else -> NoOpCancellable + } } - return null + return NoOpCancellable } override fun deleteFailedEcho(localEcho: TimelineEvent) { @@ -196,16 +242,34 @@ internal class DefaultSendService @AssistedInject constructor( } } + override fun cancelSend(eventId: String) { + cancelSendTracker.markLocalEchoForCancel(eventId, roomId) + taskExecutor.executorScope.launch { + localEchoRepository.deleteFailedEcho(roomId, eventId) + } + } + override fun resendAllFailedMessages() { taskExecutor.executorScope.launch { val eventsToResend = localEchoRepository.getAllFailedEventsToResend(roomId) eventsToResend.forEach { - sendEvent(it) + if (it.root.isTextMessage()) { + resendTextMessage(it) + } else if (it.root.isAttachmentMessage()) { + resendMediaMessage(it) + } } - localEchoRepository.updateSendState(roomId, eventsToResend.mapNotNull { it.eventId }, SendState.UNSENT) + localEchoRepository.updateSendState(roomId, eventsToResend.map { it.eventId }, SendState.UNSENT) } } +// override fun failAllPendingMessages() { +// taskExecutor.executorScope.launch { +// val eventsToResend = localEchoRepository.getAllEventsWithStates(roomId, SendState.PENDING_STATES) +// localEchoRepository.updateSendState(roomId, eventsToResend.map { it.eventId }, SendState.UNDELIVERED) +// } +// } + override fun sendMedia(attachment: ContentAttachmentData, compressBeforeSending: Boolean, roomIds: Set): Cancelable { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/EncryptEventWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/EncryptEventWorker.kt index f878df52b2..6b2a2ab115 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/EncryptEventWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/EncryptEventWorker.kt @@ -54,6 +54,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters) @Inject lateinit var crypto: CryptoService @Inject lateinit var localEchoRepository: LocalEchoRepository + @Inject lateinit var cancelSendTracker: CancelSendTracker override suspend fun doWork(): Result { Timber.v("Start Encrypt work") @@ -61,7 +62,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters) ?: return Result.success() .also { Timber.e("Unable to parse work parameters") } - Timber.v("Start Encrypt work for event ${params.event.eventId}") + Timber.v("## SendEvent: Start Encrypt work for event ${params.event.eventId}") if (params.lastFailureMessage != null) { // Transmit the error return Result.success(inputData) @@ -75,6 +76,12 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters) if (localEvent.eventId == null) { return Result.success() } + + if (cancelSendTracker.isCancelRequestedFor(localEvent.eventId, localEvent.roomId)) { + return Result.success() + .also { Timber.e("## SendEvent: Event sending has been cancelled ${localEvent.eventId}") } + } + localEchoRepository.updateSendState(localEvent.eventId, SendState.ENCRYPTING) val localMutableContent = localEvent.content?.toMutableMap() ?: mutableMapOf() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt index a9859136ad..b3188883c0 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoRepository.kt @@ -30,7 +30,6 @@ import org.matrix.android.sdk.internal.crypto.MXEventDecryptionResult import org.matrix.android.sdk.internal.database.helper.nextId import org.matrix.android.sdk.internal.database.mapper.ContentMapper import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper -import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertEntity @@ -88,7 +87,7 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } fun updateSendState(eventId: String, sendState: SendState) { - Timber.v("Update local state of $eventId to ${sendState.name}") + Timber.v("## SendEvent: [${System.currentTimeMillis()}] Update local state of $eventId to ${sendState.name}") monarchy.writeAsync { realm -> val sendingEventEntity = EventEntity.where(realm, eventId).findFirst() if (sendingEventEntity != null) { @@ -114,9 +113,13 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } suspend fun deleteFailedEcho(roomId: String, localEcho: TimelineEvent) { + deleteFailedEcho(roomId, localEcho.eventId) + } + + suspend fun deleteFailedEcho(roomId: String, eventId: String?) { monarchy.awaitTransaction { realm -> - TimelineEventEntity.where(realm, roomId = roomId, eventId = localEcho.root.eventId ?: "").findFirst()?.deleteFromRealm() - EventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.deleteFromRealm() + TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId ?: "").findFirst()?.deleteFromRealm() + EventEntity.where(realm, eventId = eventId ?: "").findFirst()?.deleteFromRealm() roomSummaryUpdater.updateSendingInformation(realm, roomId) } } @@ -142,45 +145,47 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private } } - fun getAllFailedEventsToResend(roomId: String): List { + fun getAllFailedEventsToResend(roomId: String): List { + return getAllEventsWithStates(roomId, SendState.HAS_FAILED_STATES) + } + + fun getAllEventsWithStates(roomId: String, states : List): List { return Realm.getInstance(monarchy.realmConfiguration).use { realm -> TimelineEventEntity - .findAllInRoomWithSendStates(realm, roomId, SendState.HAS_FAILED_STATES) + .findAllInRoomWithSendStates(realm, roomId, states) .sortedByDescending { it.displayIndex } - .mapNotNull { it.root?.asDomain() } + .mapNotNull { it?.let { timelineEventMapper.map(it) } } .filter { event -> - when (event.getClearType()) { + when (event.root.getClearType()) { EventType.MESSAGE, EventType.REDACTION, EventType.REACTION -> { - val content = event.getClearContent().toModel() + val content = event.root.getClearContent().toModel() if (content != null) { when (content.msgType) { MessageType.MSGTYPE_EMOTE, MessageType.MSGTYPE_NOTICE, MessageType.MSGTYPE_LOCATION, - MessageType.MSGTYPE_TEXT -> { - true - } + MessageType.MSGTYPE_TEXT, MessageType.MSGTYPE_FILE, MessageType.MSGTYPE_VIDEO, MessageType.MSGTYPE_IMAGE, MessageType.MSGTYPE_AUDIO -> { // need to resend the attachment - false + true } else -> { - Timber.e("Cannot resend message ${event.type} / ${content.msgType}") + Timber.e("Cannot resend message ${event.root.getClearType()} / ${content.msgType}") false } } } else { - Timber.e("Unsupported message to resend ${event.type}") + Timber.e("Unsupported message to resend ${event.root.getClearType()}") false } } else -> { - Timber.e("Unsupported message to resend ${event.type}") + Timber.e("Unsupported message to resend ${event.root.getClearType()}") false } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MultipleEventSendingDispatcherWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MultipleEventSendingDispatcherWorker.kt index ead2dc9377..8e8d24c227 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MultipleEventSendingDispatcherWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/MultipleEventSendingDispatcherWorker.kt @@ -58,7 +58,7 @@ internal class MultipleEventSendingDispatcherWorker(context: Context, params: Wo @Inject lateinit var localEchoRepository: LocalEchoRepository override suspend fun doWork(): Result { - Timber.v("Start dispatch sending multiple event work") + Timber.v("## SendEvent: Start dispatch sending multiple event work") val params = WorkerParamsFactory.fromData(inputData) ?: return Result.success() .also { Timber.e("Unable to parse work parameters") } @@ -72,18 +72,21 @@ internal class MultipleEventSendingDispatcherWorker(context: Context, params: Wo } // Transmit the error if needed? return Result.success(inputData) - .also { Timber.e("Work cancelled due to input error from parent") } + .also { Timber.e("## SendEvent: Work cancelled due to input error from parent ${params.lastFailureMessage}") } } // Create a work for every event params.events.forEach { event -> if (params.isEncrypted) { - Timber.v("Send event in encrypted room") + localEchoRepository.updateSendState(event.eventId ?: "", SendState.ENCRYPTING) + Timber.v("## SendEvent: [${System.currentTimeMillis()}] Schedule encrypt and send event ${event.eventId}") val encryptWork = createEncryptEventWork(params.sessionId, event, true) // Note that event will be replaced by the result of the previous work val sendWork = createSendEventWork(params.sessionId, event, false) timelineSendEventWorkCommon.postSequentialWorks(event.roomId!!, encryptWork, sendWork) } else { + localEchoRepository.updateSendState(event.eventId ?: "", SendState.SENDING) + Timber.v("## SendEvent: [${System.currentTimeMillis()}] Schedule send event ${event.eventId}") val sendWork = createSendEventWork(params.sessionId, event, true) timelineSendEventWorkCommon.postWork(event.roomId!!, sendWork) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/RoomEventSender.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/RoomEventSender.kt index e46adeb9c1..6085459a08 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/RoomEventSender.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/RoomEventSender.kt @@ -39,13 +39,16 @@ internal class RoomEventSender @Inject constructor( ) { fun sendEvent(event: Event): Cancelable { // Encrypted room handling - return if (cryptoService.isRoomEncrypted(event.roomId ?: "")) { - Timber.v("Send event in encrypted room") + return if (cryptoService.isRoomEncrypted(event.roomId ?: "") + && !event.isEncrypted() // In case of resend where it's already encrypted so skip to send + ) { + Timber.v("## SendEvent: [${System.currentTimeMillis()}] Schedule encrypt and send event ${event.eventId}") val encryptWork = createEncryptEventWork(event, true) // Note that event will be replaced by the result of the previous work val sendWork = createSendEventWork(event, false) timelineSendEventWorkCommon.postSequentialWorks(event.roomId ?: "", encryptWork, sendWork) } else { + Timber.v("## SendEvent: [${System.currentTimeMillis()}] Schedule send event ${event.eventId}") val sendWork = createSendEventWork(event, true) timelineSendEventWorkCommon.postWork(event.roomId ?: "", sendWork) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/SendEventWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/SendEventWorker.kt index 5da14f0a41..16acde7d16 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/SendEventWorker.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/SendEventWorker.kt @@ -34,7 +34,7 @@ import org.matrix.android.sdk.internal.worker.getSessionComponent import timber.log.Timber import javax.inject.Inject -private const val MAX_NUMBER_OF_RETRY_BEFORE_FAILING = 3 +// private const val MAX_NUMBER_OF_RETRY_BEFORE_FAILING = 3 /** * Possible previous worker: [EncryptEventWorker] or first worker @@ -56,12 +56,12 @@ internal class SendEventWorker(context: Context, @Inject lateinit var localEchoRepository: LocalEchoRepository @Inject lateinit var roomAPI: RoomAPI @Inject lateinit var eventBus: EventBus + @Inject lateinit var cancelSendTracker: CancelSendTracker override suspend fun doWork(): Result { val params = WorkerParamsFactory.fromData(inputData) ?: return Result.success() - .also { Timber.e("Unable to parse work parameters") } - + .also { Timber.e("## SendEvent: Unable to parse work parameters") } val sessionComponent = getSessionComponent(params.sessionId) ?: return Result.success() sessionComponent.inject(this) @@ -75,22 +75,32 @@ internal class SendEventWorker(context: Context, .also { Timber.e("Work cancelled due to bad input data") } } + if (cancelSendTracker.isCancelRequestedFor(params.eventId, event.roomId)) { + return Result.success() + .also { + cancelSendTracker.markCancelled(event.eventId, event.roomId) + Timber.e("## SendEvent: Event sending has been cancelled ${params.eventId}") + } + } + if (params.lastFailureMessage != null) { localEchoRepository.updateSendState(event.eventId, SendState.UNDELIVERED) // Transmit the error return Result.success(inputData) .also { Timber.e("Work cancelled due to input error from parent") } } + + Timber.v("## SendEvent: [${System.currentTimeMillis()}] Send event ${params.eventId}") return try { sendEvent(event.eventId, event.roomId, event.type, event.content) Result.success() } catch (exception: Throwable) { - // It does start from 0, we want it to stop if it fails the third time - val currentAttemptCount = runAttemptCount + 1 - 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}") localEchoRepository.updateSendState(event.eventId, SendState.UNDELIVERED) return Result.success() } else { + Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed schedule retry ${params.eventId} > ${exception.localizedMessage}") Result.retry() } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index b4c32c045e..a569b775a4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -115,6 +115,7 @@ internal class DefaultTimeline( if (!results.isLoaded || !results.isValid) { return@OrderedRealmCollectionChangeListener } + Timber.v("## SendEvent: [${System.currentTimeMillis()}] DB update for room $roomId") handleUpdates(results, changeSet) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineSendEventWorkCommon.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineSendEventWorkCommon.kt index d3124b68ca..3bc6a85cfb 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineSendEventWorkCommon.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/TimelineSendEventWorkCommon.kt @@ -57,7 +57,7 @@ internal class TimelineSendEventWorkCommon @Inject constructor( } } - fun postWork(roomId: String, workRequest: OneTimeWorkRequest, policy: ExistingWorkPolicy = ExistingWorkPolicy.APPEND): Cancelable { + fun postWork(roomId: String, workRequest: OneTimeWorkRequest, policy: ExistingWorkPolicy = ExistingWorkPolicy.APPEND_OR_REPLACE): Cancelable { workManagerProvider.workManager .beginUniqueWork(buildWorkName(roomId), policy, workRequest) .enqueue() diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Base64.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Base64.kt new file mode 100644 index 0000000000..76e24c4e31 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Base64.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2020 New Vector Ltd + * Copyright 2020 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 + +/** + * Base64 URL conversion methods + */ + +internal fun base64UrlToBase64(base64Url: String): String { + return base64Url.replace('-', '+') + .replace('_', '/') +} + +internal fun base64ToBase64Url(base64: String): String { + return base64.replace("\n".toRegex(), "") + .replace("\\+".toRegex(), "-") + .replace('/', '_') + .replace("=", "") +} + +internal fun base64ToUnpaddedBase64(base64: String): String { + return base64.replace("\n".toRegex(), "") + .replace("=", "") +} diff --git a/vector/build.gradle b/vector/build.gradle index e55ad31ef3..52ff0dd23c 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -274,7 +274,7 @@ dependencies { def moshi_version = '1.8.0' def daggerVersion = '2.25.4' def autofill_version = "1.0.0" - def work_version = '2.3.4' + def work_version = '2.4.0' def arch_version = '2.1.0' def lifecycle_version = '2.2.0' diff --git a/vector/src/main/java/im/vector/app/core/glide/VectorGlideModelLoader.kt b/vector/src/main/java/im/vector/app/core/glide/VectorGlideModelLoader.kt index 9ac8a4d3bc..2a17c2ca1b 100644 --- a/vector/src/main/java/im/vector/app/core/glide/VectorGlideModelLoader.kt +++ b/vector/src/main/java/im/vector/app/core/glide/VectorGlideModelLoader.kt @@ -26,13 +26,15 @@ import com.bumptech.glide.load.model.MultiModelLoaderFactory import com.bumptech.glide.signature.ObjectKey import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.features.media.ImageContentRenderer -import org.matrix.android.sdk.api.Matrix import okhttp3.OkHttpClient -import okhttp3.Request +import org.matrix.android.sdk.api.MatrixCallback +import org.matrix.android.sdk.api.session.file.FileService import timber.log.Timber import java.io.File import java.io.IOException import java.io.InputStream +import java.lang.Exception +import java.lang.IllegalArgumentException class VectorGlideModelLoaderFactory(private val activeSessionHolder: ActiveSessionHolder) : ModelLoaderFactory { @@ -84,10 +86,14 @@ class VectorGlideDataFetcher(private val activeSessionHolder: ActiveSessionHolde override fun cancel() { if (stream != null) { try { + // This is often called on main thread, and this could be a network Stream.. + // on close will throw android.os.NetworkOnMainThreadException, so we catch throwable stream?.close() // interrupts decode if any stream = null - } catch (ignore: IOException) { - Timber.e(ignore) + } catch (ignore: Throwable) { + Timber.e("Failed to close stream ${ignore.localizedMessage}") + } finally { + stream = null } } } @@ -99,26 +105,48 @@ class VectorGlideDataFetcher(private val activeSessionHolder: ActiveSessionHolde callback.onDataReady(initialFile.inputStream()) return } - val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() - val url = contentUrlResolver.resolveFullSize(data.url) - ?: return +// val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() - val request = Request.Builder() - .url(url) - .build() + val fileService = activeSessionHolder.getSafeActiveSession()?.fileService() ?: return Unit.also { + callback.onLoadFailed(IllegalArgumentException("No File service")) + } + // Use the file vector service, will avoid flickering and redownload after upload + fileService.downloadFile( + downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE, + mimeType = data.mimeType, + id = data.eventId, + url = data.url, + fileName = data.filename, + elementToDecrypt = data.elementToDecrypt, + callback = object: MatrixCallback { + override fun onSuccess(data: File) { + callback.onDataReady(data.inputStream()) + } - val response = client.newCall(request).execute() - val inputStream = response.body?.byteStream() - Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${inputStream?.available()}") - if (!response.isSuccessful) { - callback.onLoadFailed(IOException("Unexpected code $response")) - return - } - stream = if (data.elementToDecrypt != null && data.elementToDecrypt.k.isNotBlank()) { - Matrix.decryptStream(inputStream, data.elementToDecrypt) - } else { - inputStream - } - callback.onDataReady(stream) + override fun onFailure(failure: Throwable) { + callback.onLoadFailed(failure as? Exception ?: IOException(failure.localizedMessage)) + } + } + ) +// val url = contentUrlResolver.resolveFullSize(data.url) +// ?: return +// +// val request = Request.Builder() +// .url(url) +// .build() +// +// val response = client.newCall(request).execute() +// val inputStream = response.body?.byteStream() +// Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${inputStream?.available()}") +// if (!response.isSuccessful) { +// callback.onLoadFailed(IOException("Unexpected code $response")) +// return +// } +// stream = if (data.elementToDecrypt != null && data.elementToDecrypt.k.isNotBlank()) { +// Matrix.decryptStream(inputStream, data.elementToDecrypt) +// } else { +// inputStream +// } +// callback.onDataReady(stream) } } diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ReadReceiptsView.kt b/vector/src/main/java/im/vector/app/core/ui/views/ReadReceiptsView.kt index 2ce3396d0c..b1ef746ee7 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/ReadReceiptsView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/ReadReceiptsView.kt @@ -23,7 +23,6 @@ import android.widget.ImageView import android.widget.LinearLayout import androidx.core.view.isVisible import im.vector.app.R -import im.vector.app.core.glide.GlideApp import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.toMatrixItem @@ -113,9 +112,9 @@ class ReadReceiptsView @JvmOverloads constructor( } } - fun unbind() { + fun unbind(avatarRenderer: AvatarRenderer?) { receiptAvatars.forEach { - GlideApp.with(context.applicationContext).clear(it) + avatarRenderer?.clear(it) } isVisible = false } diff --git a/vector/src/main/java/im/vector/app/features/attachments/ContentAttachmentData.kt b/vector/src/main/java/im/vector/app/features/attachments/ContentAttachmentData.kt index b2e46b1abf..bd13c0dac4 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/ContentAttachmentData.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/ContentAttachmentData.kt @@ -19,7 +19,9 @@ package im.vector.app.features.attachments import org.matrix.android.sdk.api.session.content.ContentAttachmentData fun ContentAttachmentData.isPreviewable(): Boolean { - return type == ContentAttachmentData.Type.IMAGE || type == ContentAttachmentData.Type.VIDEO + // For now the preview only supports still image + return type == ContentAttachmentData.Type.IMAGE + && listOf("image/jpeg", "image/png", "image/jpg").contains(getSafeMimeType() ?: "") } data class GroupedContentAttachmentData( diff --git a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt index f53fa77791..e15f1e90cb 100644 --- a/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/home/AvatarRenderer.kt @@ -34,6 +34,7 @@ import im.vector.app.core.glide.GlideApp import im.vector.app.core.glide.GlideRequest import im.vector.app.core.glide.GlideRequests import im.vector.app.core.utils.getColorFromUserId +import org.matrix.android.sdk.api.extensions.tryThis import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.api.util.MatrixItem import javax.inject.Inject @@ -56,6 +57,11 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active DrawableImageViewTarget(imageView)) } + fun clear(imageView: ImageView) { + // It can be called after recycler view is destroyed, just silently catch + tryThis { GlideApp.with(imageView).clear(imageView) } + } + @UiThread fun render(matrixItem: MatrixItem, imageView: ImageView, glideRequests: GlideRequests) { render(imageView.context, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index f287017b02..4bdf2e7e57 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -40,7 +40,7 @@ sealed class RoomDetailAction : VectorViewModelAction { data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailAction() data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction() object MarkAllAsRead : RoomDetailAction() - data class DownloadOrOpen(val eventId: String, val messageFileContent: MessageWithAttachmentContent) : RoomDetailAction() + data class DownloadOrOpen(val eventId: String, val senderId: String?, val messageFileContent: MessageWithAttachmentContent) : RoomDetailAction() data class HandleTombstoneEvent(val event: Event) : RoomDetailAction() object AcceptInvite : RoomDetailAction() object RejectInvite : RoomDetailAction() @@ -55,6 +55,7 @@ sealed class RoomDetailAction : VectorViewModelAction { data class ResendMessage(val eventId: String) : RoomDetailAction() data class RemoveFailedEcho(val eventId: String) : RoomDetailAction() + data class CancelSend(val eventId: String) : RoomDetailAction() data class ReplyToOptions(val eventId: String, val optionIndex: Int, val optionValue: String) : RoomDetailAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index 040a5191e4..7635c3042d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -1423,7 +1423,7 @@ class RoomDetailFragment @Inject constructor( roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null)) } is MessageWithAttachmentContent -> { - val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, messageContent) + val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, informationData.senderId, messageContent) roomDetailViewModel.handle(action) } is EncryptedEventContent -> { @@ -1617,6 +1617,9 @@ class RoomDetailFragment @Inject constructor( is EventSharedAction.Remove -> { roomDetailViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId)) } + is EventSharedAction.Cancel -> { + roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId)) + } is EventSharedAction.ReportContentSpam -> { roomDetailViewModel.handle(RoomDetailAction.ReportContent( action.eventId, action.senderId, "This message is spam", spam = true)) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt index 4bad9c6ed0..0120cbb2bb 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewModel.kt @@ -57,11 +57,12 @@ import org.commonmark.renderer.html.HtmlRenderer import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.NoOpMatrixCallback +import org.matrix.android.sdk.api.extensions.tryThis import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.events.model.EventType -import org.matrix.android.sdk.api.session.events.model.isImageMessage +import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel @@ -282,6 +283,7 @@ class RoomDetailViewModel @AssistedInject constructor( is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) + is RoomDetailAction.CancelSend -> handleCancel(action) }.exhaustive } @@ -989,8 +991,18 @@ class RoomDetailViewModel @AssistedInject constructor( private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) { val mxcUrl = action.messageFileContent.getFileUrl() + val isLocalSendingFile = action.senderId == session.myUserId + && mxcUrl?.startsWith("content://") ?: false val isDownloaded = mxcUrl?.let { session.fileService().isFileInCache(it, action.messageFileContent.mimeType) } ?: false - if (isDownloaded) { + if (isLocalSendingFile) { + tryThis { Uri.parse(mxcUrl) }?.let { + _viewEvents.post(RoomDetailViewEvents.OpenFile( + action.messageFileContent.mimeType, + it, + null + )) + } + } else if (isDownloaded) { // we can open it session.fileService().getTemporarySharableURI(mxcUrl!!, action.messageFileContent.mimeType)?.let { uri -> _viewEvents.post(RoomDetailViewEvents.OpenFile( @@ -1051,9 +1063,9 @@ class RoomDetailViewModel @AssistedInject constructor( return } when { - it.root.isTextMessage() -> room.resendTextMessage(it) - it.root.isImageMessage() -> room.resendMediaMessage(it) - else -> { + it.root.isTextMessage() -> room.resendTextMessage(it) + it.root.isAttachmentMessage() -> room.resendMediaMessage(it) + else -> { // TODO } } @@ -1072,6 +1084,18 @@ class RoomDetailViewModel @AssistedInject constructor( } } + private fun handleCancel(action: RoomDetailAction.CancelSend) { + val targetEventId = action.eventId + room.getTimeLineEvent(targetEventId)?.let { + // State must be in one of the sending states + if (!it.root.sendState.isSending()) { + Timber.e("Cannot cancel message, it is not sending") + return + } + room.cancelSend(targetEventId) + } + } + private fun handleClearSendQueue() { room.clearSendingQueue() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index b51f41d573..a49b74c243 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -35,6 +35,7 @@ import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupState import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isTextMessage import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.message.MessageContent @@ -50,6 +51,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent import org.matrix.android.sdk.api.session.room.timeline.hasBeenEdited import org.matrix.android.sdk.rx.rx import org.matrix.android.sdk.rx.unwrap +import java.util.ArrayList /** * Information related to an event and used to display preview in contextual bottom sheet. @@ -230,6 +232,9 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted add(EventSharedAction.Resend(eventId)) } add(EventSharedAction.Remove(eventId)) + if (vectorPreferences.developerMode()) { + addViewSourceItems(timelineEvent) + } } else if (timelineEvent.root.sendState.isSending()) { // TODO is uploading attachment? if (canCancel(timelineEvent)) { @@ -298,13 +303,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted add(EventSharedAction.ReRequestKey(timelineEvent.eventId)) } } - - add(EventSharedAction.ViewSource(timelineEvent.root.toContentStringWithIndent())) - if (timelineEvent.isEncrypted() && timelineEvent.root.mxDecryptionResult != null) { - val decryptedContent = timelineEvent.root.toClearContentStringWithIndent() - ?: stringProvider.getString(R.string.encryption_information_decryption_error) - add(EventSharedAction.ViewDecryptedSource(decryptedContent)) - } + addViewSourceItems(timelineEvent) } add(EventSharedAction.CopyPermalink(eventId)) if (session.myUserId != timelineEvent.root.senderId) { @@ -320,8 +319,17 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } } + private fun ArrayList.addViewSourceItems(timelineEvent: TimelineEvent) { + add(EventSharedAction.ViewSource(timelineEvent.root.toContentStringWithIndent())) + if (timelineEvent.isEncrypted() && timelineEvent.root.mxDecryptionResult != null) { + val decryptedContent = timelineEvent.root.toClearContentStringWithIndent() + ?: stringProvider.getString(R.string.encryption_information_decryption_error) + add(EventSharedAction.ViewDecryptedSource(decryptedContent)) + } + } + private fun canCancel(@Suppress("UNUSED_PARAMETER") event: TimelineEvent): Boolean { - return false + return true } private fun canReply(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { @@ -365,7 +373,9 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted } private fun canRetry(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean { - return event.root.sendState.hasFailed() && event.root.isTextMessage() && actionPermissions.canSendMessage + return event.root.sendState.hasFailed() + && actionPermissions.canSendMessage + && (event.root.isAttachmentMessage() || event.root.isTextMessage()) } private fun canViewReactions(event: TimelineEvent): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index cce3248551..97bc693f26 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -187,10 +187,18 @@ class MessageItemFactory @Inject constructor( informationData: MessageInformationData, highlight: Boolean, attributes: AbsMessageItem.Attributes): MessageFileItem? { + val fileUrl = messageContent.getFileUrl()?.let { + if (informationData.sentByMe && !informationData.sendState.isSent()) { + it + } else { + it.takeIf { it.startsWith("mxc://") } + } + } ?: "" return MessageFileItem_() .attributes(attributes) - .izLocalFile(messageContent.getFileUrl().isLocalFile()) - .mxcUrl(messageContent.getFileUrl() ?: "") + .izLocalFile(fileUrl.isLocalFile()) + .izDownloaded(session.fileService().isFileInCache(fileUrl, messageContent.mimeType)) + .mxcUrl(fileUrl) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder) .highlighted(highlight) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt index f149b440e0..8216d36ac9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt @@ -75,9 +75,9 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup, is ContentUploadStateTracker.State.Idle -> handleIdle() is ContentUploadStateTracker.State.EncryptingThumbnail -> handleEncryptingThumbnail() is ContentUploadStateTracker.State.UploadingThumbnail -> handleProgressThumbnail(state) - is ContentUploadStateTracker.State.Encrypting -> handleEncrypting() + is ContentUploadStateTracker.State.Encrypting -> handleEncrypting(state) is ContentUploadStateTracker.State.Uploading -> handleProgress(state) - is ContentUploadStateTracker.State.Failure -> handleFailure(state) + is ContentUploadStateTracker.State.Failure -> handleFailure(/*state*/) is ContentUploadStateTracker.State.Success -> handleSuccess() } } @@ -98,26 +98,29 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup, } private fun handleEncryptingThumbnail() { - doHandleEncrypting(R.string.send_file_step_encrypting_thumbnail) + doHandleEncrypting(R.string.send_file_step_encrypting_thumbnail, 0, 0) } private fun handleProgressThumbnail(state: ContentUploadStateTracker.State.UploadingThumbnail) { doHandleProgress(R.string.send_file_step_sending_thumbnail, state.current, state.total) } - private fun handleEncrypting() { - doHandleEncrypting(R.string.send_file_step_encrypting_file) + private fun handleEncrypting(state: ContentUploadStateTracker.State.Encrypting) { + doHandleEncrypting(R.string.send_file_step_encrypting_file, state.current, state.total) } private fun handleProgress(state: ContentUploadStateTracker.State.Uploading) { doHandleProgress(R.string.send_file_step_sending_file, state.current, state.total) } - private fun doHandleEncrypting(resId: Int) { + private fun doHandleEncrypting(resId: Int, current: Long, total: Long) { progressLayout.visibility = View.VISIBLE + val percent = if (total > 0) (100L * (current.toFloat() / total.toFloat())) else 0f val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) - progressBar?.isIndeterminate = true + progressBar?.isIndeterminate = false + progressBar?.progress = percent.toInt() + progressTextView.isVisible = true progressTextView?.text = progressLayout.context.getString(resId) progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING)) } @@ -130,19 +133,23 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup, progressBar?.isVisible = true progressBar?.isIndeterminate = false progressBar?.progress = percent.toInt() + progressTextView.isVisible = true progressTextView?.text = progressLayout.context.getString(resId, TextUtils.formatFileSize(progressLayout.context, current, true), TextUtils.formatFileSize(progressLayout.context, total, true)) progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING)) } - private fun handleFailure(state: ContentUploadStateTracker.State.Failure) { + private fun handleFailure(/*state: ContentUploadStateTracker.State.Failure*/) { progressLayout.visibility = View.VISIBLE val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) progressBar?.isVisible = false - progressTextView?.text = errorFormatter.toHumanReadable(state.throwable) - progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNDELIVERED)) + // Do not show the message it's too technical for users, and unfortunate when upload is cancelled + // in the middle by turning airplane mode for example + progressTextView.isVisible = false +// progressTextView?.text = errorFormatter.toHumanReadable(state.throwable) +// progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNDELIVERED)) } private fun handleSuccess() { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt index 1aec2db9a4..631cd9ff66 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsBaseMessageItem.kt @@ -110,7 +110,7 @@ abstract class AbsBaseMessageItem : BaseEventItem override fun unbind(holder: H) { holder.reactionsContainer.setOnLongClickListener(null) - holder.readReceiptsView.unbind() + holder.readReceiptsView.unbind(baseAttributes.avatarRenderer) super.unbind(holder) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt index fa216c15a4..7c2a6286b9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageItem.kt @@ -77,6 +77,7 @@ abstract class AbsMessageItem : AbsBaseMessageItem } override fun unbind(holder: H) { + attributes.avatarRenderer.clear(holder.avatarImageView) holder.avatarImageView.setOnClickListener(null) holder.avatarImageView.setOnLongClickListener(null) holder.memberNameView.setOnClickListener(null) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultItem.kt index e82c4191b9..8cd3c95141 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/DefaultItem.kt @@ -44,6 +44,12 @@ abstract class DefaultItem : BaseEventItem() { holder.readReceiptsView.render(attributes.informationData.readReceipts, attributes.avatarRenderer, _readReceiptsClickListener) } + override fun unbind(holder: Holder) { + attributes.avatarRenderer.clear(holder.avatarImageView) + holder.readReceiptsView.unbind(attributes.avatarRenderer) + super.unbind(holder) + } + override fun getEventIds(): List { return listOf(attributes.informationData.eventId) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt index b215fa5dd5..5160a7cd84 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageFileItem.kt @@ -28,6 +28,7 @@ import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder +import org.matrix.android.sdk.api.session.room.send.SendState @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageFileItem : AbsMessageItem() { @@ -86,6 +87,13 @@ abstract class MessageFileItem : AbsMessageItem() { holder.fileImageWrapper.setOnClickListener(attributes.itemClickListener) holder.fileImageWrapper.setOnLongClickListener(attributes.itemLongClickListener) holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG) + + holder.eventSendingIndicator.isVisible = when (attributes.informationData.sendState) { + SendState.UNSENT, + SendState.ENCRYPTING, + SendState.SENDING -> true + else -> false + } } override fun unbind(holder: Holder) { @@ -103,6 +111,7 @@ abstract class MessageFileItem : AbsMessageItem() { val fileImageWrapper by bind(R.id.messageFileImageView) val fileDownloadProgress by bind(R.id.messageFileProgressbar) val filenameView by bind(R.id.messageFilenameView) + val eventSendingIndicator by bind(R.id.eventSendingIndicator) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt index e8a840918f..9ada087207 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageImageVideoItem.kt @@ -19,6 +19,7 @@ package im.vector.app.features.home.room.detail.timeline.item import android.view.View import android.view.ViewGroup import android.widget.ImageView +import android.widget.ProgressBar import androidx.core.view.ViewCompat import androidx.core.view.isVisible import com.airbnb.epoxy.EpoxyAttribute @@ -27,6 +28,7 @@ import im.vector.app.R import im.vector.app.core.glide.GlideApp import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.app.features.media.ImageContentRenderer +import org.matrix.android.sdk.api.session.room.send.SendState @EpoxyModelClass(layout = R.layout.item_timeline_event_base) abstract class MessageImageVideoItem : AbsMessageItem() { @@ -60,10 +62,18 @@ abstract class MessageImageVideoItem : AbsMessageItem true + else -> false + } } override fun unbind(holder: Holder) { GlideApp.with(holder.view.context.applicationContext).clear(holder.imageView) + imageContentRenderer.clear(holder.imageView) contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId) holder.imageView.setOnClickListener(null) holder.imageView.setOnLongClickListener(null) @@ -79,6 +89,7 @@ abstract class MessageImageVideoItem : AbsMessageItem(R.id.messageContentMedia) val failedToSendIndicator by bind(R.id.messageFailToSendIndicator) + val eventSendingIndicator by bind(R.id.eventSendingIndicator) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt index 7205ea278b..34ee8c69fa 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/NoticeItem.kt @@ -60,6 +60,12 @@ abstract class NoticeItem : BaseEventItem() { } } + override fun unbind(holder: Holder) { + attributes.avatarRenderer.clear(holder.avatarImageView) + holder.readReceiptsView.unbind(attributes.avatarRenderer) + super.unbind(holder) + } + override fun getEventIds(): List { return listOf(attributes.informationData.eventId) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItem.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItem.kt index ce572ed96d..4cac11af21 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItem.kt @@ -82,6 +82,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel() { override fun unbind(holder: Holder) { holder.rootView.setOnClickListener(null) holder.rootView.setOnLongClickListener(null) + avatarRenderer.clear(holder.avatarImageView) super.unbind(holder) } diff --git a/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt b/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt index 966ee985f0..3846e56ecf 100644 --- a/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt +++ b/vector/src/main/java/im/vector/app/features/media/BaseAttachmentProvider.kt @@ -101,11 +101,7 @@ abstract class BaseAttachmentProvider(val imageContentRenderer: ImageContentRend override fun loadVideo(target: VideoLoaderTarget, info: AttachmentInfo.Video) { val data = info.data as? VideoContentRenderer.Data ?: return -// videoContentRenderer.render(data, -// holder.thumbnailImage, -// holder.loaderProgressBar, -// holder.videoView, -// holder.errorTextView) + imageContentRenderer.render(data.thumbnailMediaData, target.contextView(), object : CustomViewTarget(target.contextView()) { override fun onLoadFailed(errorDrawable: Drawable?) { target.onThumbnailLoadFailed(info.uid, errorDrawable) @@ -120,24 +116,28 @@ abstract class BaseAttachmentProvider(val imageContentRenderer: ImageContentRend } }) - target.onVideoFileLoading(info.uid) - fileService.downloadFile( - downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE, - id = data.eventId, - mimeType = data.mimeType, - elementToDecrypt = data.elementToDecrypt, - fileName = data.filename, - url = data.url, - callback = object : MatrixCallback { - override fun onSuccess(data: File) { - target.onVideoFileReady(info.uid, data) - } + if (data.url?.startsWith("content://") == true && data.allowNonMxcUrls) { + target.onVideoURLReady(info.uid, data.url) + } else { + target.onVideoFileLoading(info.uid) + fileService.downloadFile( + downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE, + id = data.eventId, + mimeType = data.mimeType, + elementToDecrypt = data.elementToDecrypt, + fileName = data.filename, + url = data.url, + callback = object : MatrixCallback { + override fun onSuccess(data: File) { + target.onVideoFileReady(info.uid, data) + } - override fun onFailure(failure: Throwable) { - target.onVideoFileLoadFailed(info.uid) + override fun onFailure(failure: Throwable) { + target.onVideoFileLoadFailed(info.uid) + } } - } - ) + ) + } } override fun clear(id: String) { diff --git a/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt index 2976f4b4b1..5c43b25d51 100644 --- a/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/media/ImageContentRenderer.kt @@ -39,6 +39,7 @@ import im.vector.app.core.utils.isLocalFile import org.matrix.android.sdk.api.session.content.ContentUrlResolver import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt import kotlinx.android.parcel.Parcelize +import org.matrix.android.sdk.api.extensions.tryThis import timber.log.Timber import java.io.File import javax.inject.Inject @@ -50,6 +51,8 @@ interface AttachmentData : Parcelable { val mimeType: String? val url: String? val elementToDecrypt: ElementToDecrypt? + // If true will load non mxc url, be careful to set it only for attachments sent by you + val allowNonMxcUrls: Boolean } class ImageContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, @@ -65,7 +68,9 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: val height: Int?, val maxHeight: Int, val width: Int?, - val maxWidth: Int + val maxWidth: Int, + // If true will load non mxc url, be careful to set it only for images sent by you + override val allowNonMxcUrls: Boolean = false ) : AttachmentData { fun isLocalFile() = url.isLocalFile() @@ -103,6 +108,15 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: .into(imageView) } + fun clear(imageView: ImageView) { + // It can be called after recycler view is destroyed, just silently catch + // We'd better keep ref to requestManager, but we don't have it + tryThis { + GlideApp + .with(imageView).clear(imageView) + } + } + fun render(data: Data, contextView: View, target: CustomViewTarget<*, Drawable>) { val req = if (data.elementToDecrypt != null) { // Encrypted image @@ -111,7 +125,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: .load(data) } else { // Clear image - val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url) + val resolvedUrl = resolveUrl(data) GlideApp .with(contextView) .load(resolvedUrl) @@ -165,7 +179,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: .load(data) } else { // Clear image - val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url) + val resolvedUrl = resolveUrl(data) GlideApp .with(imageView) .load(resolvedUrl) @@ -205,7 +219,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() val resolvedUrl = when (mode) { Mode.FULL_SIZE, - Mode.STICKER -> contentUrlResolver.resolveFullSize(data.url) + Mode.STICKER -> resolveUrl(data) Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE) } // Fallback to base url @@ -219,7 +233,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: error( GlideApp .with(imageView) - .load(contentUrlResolver.resolveFullSize(data.url)) + .load(resolveUrl(data)) ) } } @@ -232,7 +246,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: val (width, height) = processSize(data, Mode.THUMBNAIL) val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() - val fullSize = contentUrlResolver.resolveFullSize(data.url) + val fullSize = resolveUrl(data) val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) if (fullSize.isNullOrBlank() || thumbnail.isNullOrBlank()) { @@ -252,6 +266,10 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: ) } + private fun resolveUrl(data: Data) = + (activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url) + ?: data.url?.takeIf { data.isLocalFile() && data.allowNonMxcUrls }) + private fun processSize(data: Data, mode: Mode): Size { val maxImageWidth = data.maxWidth val maxImageHeight = data.maxHeight diff --git a/vector/src/main/java/im/vector/app/features/media/RoomEventsAttachmentProvider.kt b/vector/src/main/java/im/vector/app/features/media/RoomEventsAttachmentProvider.kt index b4fa38eefc..e5f0f481bf 100644 --- a/vector/src/main/java/im/vector/app/features/media/RoomEventsAttachmentProvider.kt +++ b/vector/src/main/java/im/vector/app/features/media/RoomEventsAttachmentProvider.kt @@ -63,7 +63,9 @@ class RoomEventsAttachmentProvider( maxHeight = -1, maxWidth = -1, width = null, - height = null + height = null, + allowNonMxcUrls = it.root.sendState.isSending() + ) if (content.mimeType == "image/gif") { AttachmentInfo.AnimatedImage( @@ -89,7 +91,8 @@ class RoomEventsAttachmentProvider( height = content.videoInfo?.height, maxHeight = -1, width = content.videoInfo?.width, - maxWidth = -1 + maxWidth = -1, + allowNonMxcUrls = it.root.sendState.isSending() ) val data = VideoContentRenderer.Data( eventId = it.eventId, @@ -97,7 +100,8 @@ class RoomEventsAttachmentProvider( mimeType = content.mimeType, url = content.getFileUrl(), elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(), - thumbnailMediaData = thumbnailData + thumbnailMediaData = thumbnailData, + allowNonMxcUrls = it.root.sendState.isSending() ) AttachmentInfo.Video( uid = it.eventId, diff --git a/vector/src/main/java/im/vector/app/features/media/VideoContentRenderer.kt b/vector/src/main/java/im/vector/app/features/media/VideoContentRenderer.kt index 45fc238e93..4eb14592e0 100644 --- a/vector/src/main/java/im/vector/app/features/media/VideoContentRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/media/VideoContentRenderer.kt @@ -24,12 +24,14 @@ import androidx.core.view.isVisible import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.error.ErrorFormatter +import im.vector.app.core.utils.isLocalFile import org.matrix.android.sdk.api.MatrixCallback import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt import kotlinx.android.parcel.Parcelize import timber.log.Timber import java.io.File +import java.net.URLEncoder import javax.inject.Inject class VideoContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, @@ -42,7 +44,9 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder: override val mimeType: String?, override val url: String?, override val elementToDecrypt: ElementToDecrypt?, - val thumbnailMediaData: ImageContentRenderer.Data + val thumbnailMediaData: ImageContentRenderer.Data, + // If true will load non mxc url, be careful to set it only for video sent by you + override val allowNonMxcUrls: Boolean = false ) : AttachmentData fun render(data: Data, @@ -60,6 +64,12 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder: loadingView.isVisible = false errorView.isVisible = true errorView.setText(R.string.unknown_error) + } else if (data.url.isLocalFile() && data.allowNonMxcUrls) { + thumbnailView.isVisible = false + loadingView.isVisible = false + videoView.isVisible = true + videoView.setVideoPath(URLEncoder.encode(data.url, Charsets.US_ASCII.displayName())) + videoView.start() } else { thumbnailView.isVisible = true loadingView.isVisible = true @@ -91,6 +101,7 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder: } } else { val resolvedUrl = contentUrlResolver.resolveFullSize(data.url) + ?: data.url?.takeIf { data.url.isLocalFile() && data.allowNonMxcUrls } if (resolvedUrl == null) { thumbnailView.isVisible = false diff --git a/vector/src/main/res/layout/item_timeline_event_file_stub.xml b/vector/src/main/res/layout/item_timeline_event_file_stub.xml index a67d14168b..72a6cd6ec6 100644 --- a/vector/src/main/res/layout/item_timeline_event_file_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_file_stub.xml @@ -42,18 +42,31 @@ + + + + +