Merge pull request #1889 from vector-im/feature/enhance_big_files

Feature/enhance big files
This commit is contained in:
Valere 2020-09-03 17:09:55 +02:00 committed by GitHub
commit f6c7f3eed1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
54 changed files with 1007 additions and 408 deletions

View File

@ -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 🐛:

View File

@ -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)
}
}

View File

@ -66,6 +66,13 @@ class VideoViewHolder constructor(itemView: View) :
}
}
fun videoReady(path: String) {
mVideoPath = path
if (isSelected) {
startPlaying()
}
}
fun videoFileLoadError() {
}

View File

@ -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"

View File

@ -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)
}
}
}

View File

@ -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

View File

@ -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)
}
}
}

View File

@ -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()

View File

@ -239,6 +239,14 @@ fun Event.isVideoMessage(): Boolean {
}
}
fun Event.isAudioMessage(): Boolean {
return getClearType() == EventType.MESSAGE
&& when (getClearContent()?.toModel<MessageContent>()?.msgType) {
MessageType.MSGTYPE_AUDIO -> true
else -> false
}
}
fun Event.isFileMessage(): Boolean {
return getClearType() == EventType.MESSAGE
&& when (getClearContent()?.toModel<MessageContent>()?.msgType) {
@ -246,6 +254,16 @@ fun Event.isFileMessage(): Boolean {
else -> false
}
}
fun Event.isAttachmentMessage(): Boolean {
return getClearType() == EventType.MESSAGE
&& when (getClearContent()?.toModel<MessageContent>()?.msgType) {
MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO,
MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_FILE -> true
else -> false
}
}
fun Event.getRelationContent(): RelationDefaultContent? {
return if (isEncrypted()) {

View File

@ -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)
*/

View File

@ -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)
}

View File

@ -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<DigestInputStream, EncryptedFileInfo> {
// 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
}
}

View File

@ -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")
}
}
}

View File

@ -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) {

View File

@ -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))

View File

@ -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)
}

View File

@ -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()

View File

@ -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<Params>(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,

View File

@ -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)) }
}
}
}

View File

@ -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

View File

@ -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<Request>()
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)
}
}
}
}

View File

@ -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<MessageContent>() ?: return null
// when (messageContent.type) {
// MessageType.MSGTYPE_IMAGE -> {
// val imageContent = clearContent.toModel<MessageImageContent>() ?: 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<MessageContent>() 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<String>): Cancelable {

View File

@ -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()

View File

@ -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<Event> {
fun getAllFailedEventsToResend(roomId: String): List<TimelineEvent> {
return getAllEventsWithStates(roomId, SendState.HAS_FAILED_STATES)
}
fun getAllEventsWithStates(roomId: String, states : List<SendState>): List<TimelineEvent> {
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<MessageContent>()
val content = event.root.getClearContent().toModel<MessageContent>()
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
}
}

View File

@ -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<Params>(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)
}

View File

@ -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)
}

View File

@ -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<Params>(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()
}
}

View File

@ -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)
}

View File

@ -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()

View File

@ -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("=", "")
}

View File

@ -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'

View File

@ -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<ImageContentRenderer.Data, InputStream> {
@ -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<File> {
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)
}
}

View File

@ -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
}

View File

@ -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(

View File

@ -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,

View File

@ -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()

View File

@ -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))

View File

@ -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()
}

View File

@ -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<EventSharedAction>.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 {

View File

@ -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)

View File

@ -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<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(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<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(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() {

View File

@ -110,7 +110,7 @@ abstract class AbsBaseMessageItem<H : AbsBaseMessageItem.Holder> : BaseEventItem
override fun unbind(holder: H) {
holder.reactionsContainer.setOnLongClickListener(null)
holder.readReceiptsView.unbind()
holder.readReceiptsView.unbind(baseAttributes.avatarRenderer)
super.unbind(holder)
}

View File

@ -77,6 +77,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
}
override fun unbind(holder: H) {
attributes.avatarRenderer.clear(holder.avatarImageView)
holder.avatarImageView.setOnClickListener(null)
holder.avatarImageView.setOnLongClickListener(null)
holder.memberNameView.setOnClickListener(null)

View File

@ -44,6 +44,12 @@ abstract class DefaultItem : BaseEventItem<DefaultItem.Holder>() {
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<String> {
return listOf(attributes.informationData.eventId)
}

View File

@ -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<MessageFileItem.Holder>() {
@ -86,6 +87,13 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
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<MessageFileItem.Holder>() {
val fileImageWrapper by bind<ViewGroup>(R.id.messageFileImageView)
val fileDownloadProgress by bind<ProgressBar>(R.id.messageFileProgressbar)
val filenameView by bind<TextView>(R.id.messageFilenameView)
val eventSendingIndicator by bind<ProgressBar>(R.id.eventSendingIndicator)
}
companion object {

View File

@ -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<MessageImageVideoItem.Holder>() {
@ -60,10 +62,18 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
// The sending state color will be apply to the progress text
renderSendState(holder.imageView, null, holder.failedToSendIndicator)
holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE
holder.eventSendingIndicator.isVisible = when (attributes.informationData.sendState) {
SendState.UNSENT,
SendState.ENCRYPTING,
SendState.SENDING -> 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<MessageImageVideoItem.Hold
val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia)
val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator)
val eventSendingIndicator by bind<ProgressBar>(R.id.eventSendingIndicator)
}
companion object {

View File

@ -60,6 +60,12 @@ abstract class NoticeItem : BaseEventItem<NoticeItem.Holder>() {
}
}
override fun unbind(holder: Holder) {
attributes.avatarRenderer.clear(holder.avatarImageView)
holder.readReceiptsView.unbind(attributes.avatarRenderer)
super.unbind(holder)
}
override fun getEventIds(): List<String> {
return listOf(attributes.informationData.eventId)
}

View File

@ -82,6 +82,7 @@ abstract class RoomSummaryItem : VectorEpoxyModel<RoomSummaryItem.Holder>() {
override fun unbind(holder: Holder) {
holder.rootView.setOnClickListener(null)
holder.rootView.setOnLongClickListener(null)
avatarRenderer.clear(holder.avatarImageView)
super.unbind(holder)
}

View File

@ -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<ImageView, Drawable>(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<File> {
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<File> {
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) {

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -42,18 +42,31 @@
<!-- the media -->
<TextView
android:id="@+id/messageFilenameView"
android:layout_width="0dp"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="4dp"
android:layout_marginEnd="32dp"
android:autoLink="none"
android:gravity="center_vertical"
android:minHeight="@dimen/chat_avatar_size"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@+id/messageFileImageView"
app:layout_constraintTop_toTopOf="parent"
tools:text="A filename here" />
<ProgressBar
android:id="@+id/eventSendingIndicator"
style="?android:attr/progressBarStyleSmall"
android:layout_width="16dp"
android:layout_height="16dp"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/messageFilenameView"
app:layout_constraintTop_toTopOf="@id/messageFilenameView"
tools:visibility="visible" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/horizontalBarrier"
android:layout_width="wrap_content"

View File

@ -28,6 +28,15 @@
app:layout_constraintTop_toTopOf="@id/messageThumbnailView"
tools:visibility="visible" />
<ProgressBar
android:id="@+id/eventSendingIndicator"
style="?android:attr/progressBarStyleSmall"
android:layout_width="16dp"
android:layout_height="16dp"
android:visibility="gone"
app:layout_constraintStart_toEndOf="@id/messageThumbnailView"
app:layout_constraintTop_toBottomOf="@id/messageFailToSendIndicator" />
<ImageView
android:id="@+id/messageMediaPlayView"
android:layout_width="40dp"