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 🙌: Improvements 🙌:
- You can now join room through permalink and within room directory search - 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) - 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) - Do not propose to verify session if there is only one session and 4S is not configured (#1901)
Bugfix 🐛: Bugfix 🐛:

View File

@ -35,6 +35,7 @@ interface VideoLoaderTarget {
fun onVideoFileLoading(uid: String) fun onVideoFileLoading(uid: String)
fun onVideoFileLoadFailed(uid: String) fun onVideoFileLoadFailed(uid: String)
fun onVideoFileReady(uid: String, file: File) fun onVideoFileReady(uid: String, file: File)
fun onVideoURLReady(uid: String, path: String)
} }
internal class DefaultVideoLoaderTarget(val holder: VideoViewHolder, private val contextView: ImageView) : VideoLoaderTarget { 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) { override fun onVideoFileReady(uid: String, file: File) {
if (holder.boundResourceUid != uid) return 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.thumbnailImage.isVisible = false
holder.loaderProgressBar.isVisible = false holder.loaderProgressBar.isVisible = false
holder.videoView.isVisible = true 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() { fun videoFileLoadError() {
} }

View File

@ -115,7 +115,7 @@ dependencies {
def coroutines_version = "1.3.8" def coroutines_version = "1.3.8"
def markwon_version = '3.1.0' def markwon_version = '3.1.0'
def daggerVersion = '2.25.4' def daggerVersion = '2.25.4'
def work_version = '2.3.3' def work_version = '2.4.0'
def retrofit_version = '2.6.2' def retrofit_version = '2.6.2'
implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" 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 com.zhuinden.monarchy.Monarchy
import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.BuildConfig
import org.matrix.android.sdk.api.auth.AuthenticationService 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.api.legacy.LegacySessionImporter
import org.matrix.android.sdk.common.DaggerTestMatrixComponent
import org.matrix.android.sdk.internal.SessionManager 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.network.UserAgentHolder
import org.matrix.android.sdk.internal.util.BackgroundDetectionObserver import org.matrix.android.sdk.internal.util.BackgroundDetectionObserver
import org.matrix.olm.OlmManager import org.matrix.olm.OlmManager
import java.io.InputStream
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@ -96,9 +93,5 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo
fun getSdkVersion(): String { fun getSdkVersion(): String {
return BuildConfig.VERSION_NAME + " (" + BuildConfig.GIT_SDK_REVISION + ")" 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 androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertNotNull
import org.junit.FixMethodOrder import org.junit.FixMethodOrder
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith 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.attachments.MXEncryptedAttachments
import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo 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.model.rest.EncryptedFileKey
import org.matrix.android.sdk.internal.crypto.attachments.toElementToDecrypt
import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
/** /**
@ -52,17 +53,14 @@ class AttachmentEncryptionTest {
memoryFile.inputStream 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) return Base64.encodeToString(toByteArray, 0, toByteArray.size, Base64.DEFAULT).replace("\n".toRegex(), "").replace("=".toRegex(), "")
decryptedStream.close()
return Base64.encodeToString(buffer, 0, len, Base64.DEFAULT).replace("\n".toRegex(), "").replace("=".toRegex(), "")
} }
@Test @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.auth.AuthenticationService
import org.matrix.android.sdk.api.legacy.LegacySessionImporter import org.matrix.android.sdk.api.legacy.LegacySessionImporter
import org.matrix.android.sdk.internal.SessionManager 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.di.DaggerMatrixComponent
import org.matrix.android.sdk.internal.network.UserAgentHolder import org.matrix.android.sdk.internal.network.UserAgentHolder
import org.matrix.android.sdk.internal.util.BackgroundDetectionObserver import org.matrix.android.sdk.internal.util.BackgroundDetectionObserver
import org.matrix.olm.OlmManager import org.matrix.olm.OlmManager
import java.io.InputStream
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import javax.inject.Inject import javax.inject.Inject
@ -97,9 +94,5 @@ class Matrix private constructor(context: Context, matrixConfiguration: MatrixCo
fun getSdkVersion(): String { fun getSdkVersion(): String {
return BuildConfig.VERSION_NAME + " (" + BuildConfig.GIT_SDK_REVISION + ")" 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 Idle : State()
object EncryptingThumbnail : State() object EncryptingThumbnail : State()
data class UploadingThumbnail(val current: Long, val total: Long) : 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() data class Uploading(val current: Long, val total: Long) : State()
object Success : State() object Success : State()
data class Failure(val throwable: Throwable) : 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 { fun Event.isFileMessage(): Boolean {
return getClearType() == EventType.MESSAGE return getClearType() == EventType.MESSAGE
&& when (getClearContent()?.toModel<MessageContent>()?.msgType) { && when (getClearContent()?.toModel<MessageContent>()?.msgType) {
@ -246,6 +254,16 @@ fun Event.isFileMessage(): Boolean {
else -> false 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? { fun Event.getRelationContent(): RelationDefaultContent? {
return if (isEncrypted()) { return if (isEncrypted()) {

View File

@ -110,13 +110,13 @@ interface SendService {
* Schedule this message to be resent * Schedule this message to be resent
* @param localEcho the unsent local echo * @param localEcho the unsent local echo
*/ */
fun resendTextMessage(localEcho: TimelineEvent): Cancelable? fun resendTextMessage(localEcho: TimelineEvent): Cancelable
/** /**
* Schedule this message to be resent * Schedule this message to be resent
* @param localEcho the unsent local echo * @param localEcho the unsent local echo
*/ */
fun resendMediaMessage(localEcho: TimelineEvent): Cancelable? fun resendMediaMessage(localEcho: TimelineEvent): Cancelable
/** /**
* Remove this failed message from the timeline * Remove this failed message from the timeline
@ -124,8 +124,16 @@ interface SendService {
*/ */
fun deleteFailedEcho(localEcho: TimelineEvent) fun deleteFailedEcho(localEcho: TimelineEvent)
/**
* Delete all the events in one of the sending states
*/
fun clearSendingQueue() 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) * Resend all failed messages one by one (and keep order)
*/ */

View File

@ -37,7 +37,8 @@ enum class SendState {
internal companion object { internal companion object {
val HAS_FAILED_STATES = listOf(UNDELIVERED, FAILED_UNKNOWN_DEVICES) val HAS_FAILED_STATES = listOf(UNDELIVERED, FAILED_UNKNOWN_DEVICES)
val IS_SENT_STATES = listOf(SENT, SYNCED) 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 val PENDING_STATES = IS_SENDING_STATES + HAS_FAILED_STATES
} }
@ -45,5 +46,7 @@ enum class SendState {
fun hasFailed() = HAS_FAILED_STATES.contains(this) fun hasFailed() = HAS_FAILED_STATES.contains(this)
fun isInProgress() = IS_PROGRESSING_STATES.contains(this)
fun isSending() = IS_SENDING_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 android.util.Base64
import org.matrix.android.sdk.internal.crypto.model.rest.EncryptedFileInfo 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.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 timber.log.Timber
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.io.File
import java.io.InputStream import java.io.InputStream
import java.io.OutputStream
import java.security.MessageDigest import java.security.MessageDigest
import java.security.SecureRandom import java.security.SecureRandom
import javax.crypto.Cipher import javax.crypto.Cipher
@ -35,8 +40,121 @@ internal object MXEncryptedAttachments {
private const val SECRET_KEY_SPEC_ALGORITHM = "AES" private const val SECRET_KEY_SPEC_ALGORITHM = "AES"
private const val MESSAGE_DIGEST_ALGORITHM = "SHA-256" 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. * 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 attachmentStream the attachment stream. Will be closed after this method call.
* @param mimetype the mime type * @param mimetype the mime type
* @return the encryption file info * @return the encryption file info
@ -59,14 +177,14 @@ internal object MXEncryptedAttachments {
val key = ByteArray(32) val key = ByteArray(32)
secureRandom.nextBytes(key) 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 encryptCipher = Cipher.getInstance(CIPHER_ALGORITHM)
val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM)
val ivParameterSpec = IvParameterSpec(initVectorBytes) val ivParameterSpec = IvParameterSpec(initVectorBytes)
encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec) encryptCipher.init(Cipher.ENCRYPT_MODE, secretKeySpec, ivParameterSpec)
val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM)
val data = ByteArray(CRYPTO_BUFFER_SIZE) val data = ByteArray(CRYPTO_BUFFER_SIZE)
var read: Int var read: Int
var encodedBytes: ByteArray var encodedBytes: ByteArray
@ -85,44 +203,26 @@ internal object MXEncryptedAttachments {
encodedBytes = encryptCipher.doFinal() encodedBytes = encryptCipher.doFinal()
messageDigest.update(encodedBytes, 0, encodedBytes.size) messageDigest.update(encodedBytes, 0, encodedBytes.size)
outputStream.write(encodedBytes) 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 EncryptionResult(
encryptedFileInfo = EncryptedFileInfo(
return decryptAttachment(attachmentStream, elementToDecrypt) 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 attachmentStream the attachment stream. Will be closed after this method call.
* @param elementToDecrypt the elementToDecrypt info * @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 // sanity checks
if (null == attachmentStream || elementToDecrypt == null) { if (null == attachmentStream || elementToDecrypt == null) {
Timber.e("## decryptAttachment() : null stream") Timber.e("## decryptAttachment() : null stream")
return null return false
} }
val t0 = System.currentTimeMillis() val t0 = System.currentTimeMillis()
ByteArrayOutputStream().use { outputStream -> try {
try { val key = Base64.decode(base64UrlToBase64(elementToDecrypt.k), Base64.DEFAULT)
val key = Base64.decode(base64UrlToBase64(elementToDecrypt.k), Base64.DEFAULT) val initVectorBytes = Base64.decode(elementToDecrypt.iv, Base64.DEFAULT)
val initVectorBytes = Base64.decode(elementToDecrypt.iv, Base64.DEFAULT)
val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM) val decryptCipher = Cipher.getInstance(CIPHER_ALGORITHM)
val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM) val secretKeySpec = SecretKeySpec(key, SECRET_KEY_SPEC_ALGORITHM)
val ivParameterSpec = IvParameterSpec(initVectorBytes) val ivParameterSpec = IvParameterSpec(initVectorBytes)
decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec) decryptCipher.init(Cipher.DECRYPT_MODE, secretKeySpec, ivParameterSpec)
val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM) val messageDigest = MessageDigest.getInstance(MESSAGE_DIGEST_ALGORITHM)
var read: Int var read: Int
val data = ByteArray(CRYPTO_BUFFER_SIZE) val data = ByteArray(CRYPTO_BUFFER_SIZE)
var decodedBytes: ByteArray 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) 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 return false
}
/**
* 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("=", "")
} }
} }

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.ForwardingSink
import okio.Sink import okio.Sink
import okio.buffer import okio.buffer
import org.matrix.android.sdk.api.extensions.tryThis
import java.io.IOException import java.io.IOException
internal class ProgressRequestBody(private val delegate: RequestBody, internal class ProgressRequestBody(private val delegate: RequestBody,
@ -35,15 +36,13 @@ internal class ProgressRequestBody(private val delegate: RequestBody,
return delegate.contentType() return delegate.contentType()
} }
override fun contentLength(): Long { override fun isOneShot() = delegate.isOneShot()
try {
return delegate.contentLength()
} catch (e: IOException) {
e.printStackTrace()
}
return -1 override fun isDuplex() = delegate.isDuplex()
}
val length = tryThis { delegate.contentLength() } ?: -1
override fun contentLength() = length
@Throws(IOException::class) @Throws(IOException::class)
override fun writeTo(sink: BufferedSink) { 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()}") Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${!source.exhausted()}")
if (elementToDecrypt != null) { if (elementToDecrypt != null) {
Timber.v("## decrypt file") Timber.v("## FileService: decrypt file")
val decryptedStream = MXEncryptedAttachments.decryptAttachment(source.inputStream(), elementToDecrypt) val decryptSuccess = MXEncryptedAttachments.decryptAttachment(
source.inputStream(),
elementToDecrypt,
destFile.outputStream().buffered()
)
response.close() response.close()
if (decryptedStream == null) { if (!decryptSuccess) {
return@flatMap Try.Failure(IllegalStateException("Decryption error")) return@flatMap Try.Failure(IllegalStateException("Decryption error"))
} else {
decryptedStream.use {
writeToFile(decryptedStream, destFile)
}
} }
} else { } else {
writeToFile(source.inputStream(), destFile) writeToFile(source.inputStream(), destFile)
response.close() response.close()
} }
} else {
Timber.v("## FileService: cache hit for $url")
} }
Try.just(copyFile(destFile, downloadMode)) Try.just(copyFile(destFile, downloadMode))

View File

@ -74,8 +74,8 @@ internal class DefaultContentUploadStateTracker @Inject constructor() : ContentU
updateState(key, progressData) updateState(key, progressData)
} }
internal fun setEncrypting(key: String) { internal fun setEncrypting(key: String, current: Long, total: Long) {
val progressData = ContentUploadStateTracker.State.Encrypting val progressData = ContentUploadStateTracker.State.Encrypting(current, total)
updateState(key, progressData) updateState(key, progressData)
} }

View File

@ -23,13 +23,16 @@ import com.squareup.moshi.Moshi
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType
import okhttp3.MediaType.Companion.toMediaTypeOrNull import okhttp3.MediaType.Companion.toMediaTypeOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import okhttp3.RequestBody import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.asRequestBody
import okhttp3.RequestBody.Companion.toRequestBody import okhttp3.RequestBody.Companion.toRequestBody
import okio.BufferedSink
import okio.source
import org.greenrobot.eventbus.EventBus 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.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.internal.di.Authenticated import org.matrix.android.sdk.internal.di.Authenticated
import org.matrix.android.sdk.internal.network.ProgressRequestBody 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.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.io.IOException import java.io.IOException
import java.util.UUID
import javax.inject.Inject import javax.inject.Inject
internal class FileUploader @Inject constructor(@Authenticated internal class FileUploader @Inject constructor(@Authenticated
@ -54,7 +58,21 @@ internal class FileUploader @Inject constructor(@Authenticated
filename: String?, filename: String?,
mimeType: String?, mimeType: String?,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
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) return upload(uploadBody, filename, progressListener)
} }
@ -70,14 +88,18 @@ internal class FileUploader @Inject constructor(@Authenticated
filename: String?, filename: String?,
mimeType: String?, mimeType: String?,
progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse { progressListener: ProgressRequestBody.Listener? = null): ContentUploadResponse {
return withContext(Dispatchers.IO) { val inputStream = withContext(Dispatchers.IO) {
val inputStream = context.contentResolver.openInputStream(uri) ?: throw FileNotFoundException() context.contentResolver.openInputStream(uri)
} ?: throw FileNotFoundException()
inputStream.use { val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
uploadByteArray(it.readBytes(), filename, mimeType, progressListener) 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 { private suspend fun upload(uploadBody: RequestBody, filename: String?, progressListener: ProgressRequestBody.Listener?): ContentUploadResponse {
val urlBuilder = uploadUrl.toHttpUrlOrNull()?.newBuilder() ?: throw RuntimeException() val urlBuilder = uploadUrl.toHttpUrlOrNull()?.newBuilder() ?: throw RuntimeException()

View File

@ -24,6 +24,7 @@ import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import id.zelory.compressor.Compressor import id.zelory.compressor.Compressor
import id.zelory.compressor.constraint.default 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.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.Event
import org.matrix.android.sdk.api.session.events.model.toContent 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.crypto.model.rest.EncryptedFileInfo
import org.matrix.android.sdk.internal.network.ProgressRequestBody import org.matrix.android.sdk.internal.network.ProgressRequestBody
import org.matrix.android.sdk.internal.session.DefaultFileService 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.session.room.send.MultipleEventSendingDispatcherWorker
import org.matrix.android.sdk.internal.worker.SessionWorkerParams import org.matrix.android.sdk.internal.worker.SessionWorkerParams
import org.matrix.android.sdk.internal.worker.WorkerParamsFactory import org.matrix.android.sdk.internal.worker.WorkerParamsFactory
@ -71,6 +73,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
@Inject lateinit var fileUploader: FileUploader @Inject lateinit var fileUploader: FileUploader
@Inject lateinit var contentUploadStateTracker: DefaultContentUploadStateTracker @Inject lateinit var contentUploadStateTracker: DefaultContentUploadStateTracker
@Inject lateinit var fileService: DefaultFileService @Inject lateinit var fileService: DefaultFileService
@Inject lateinit var cancelSendTracker: CancelSendTracker
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData) val params = WorkerParamsFactory.fromData<Params>(inputData)
@ -102,6 +105,13 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
var newImageAttributes: NewImageAttributes? = null 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 { try {
val inputStream = context.contentResolver.openInputStream(attachment.queryUri) val inputStream = context.contentResolver.openInputStream(attachment.queryUri)
?: return Result.success( ?: return Result.success(
@ -112,16 +122,22 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
) )
) )
inputStream.use { // always use a temporary file, it guaranties that we could report progress on upload and simplifies the flows
var uploadedThumbnailUrl: String? = null val workingFile = File.createTempFile(UUID.randomUUID().toString(), null, context.cacheDir)
var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null workingFile.outputStream().use {
inputStream.copyTo(it)
}
ThumbnailExtractor.extractThumbnail(context, params.attachment)?.let { thumbnailData -> // inputStream.use {
val thumbnailProgressListener = object : ProgressRequestBody.Listener { var uploadedThumbnailUrl: String? = null
override fun onProgress(current: Long, total: Long) { var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null
notifyTracker(params) { contentUploadStateTracker.setProgressThumbnail(it, current, total) }
} 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 { try {
val contentUploadResponse = if (params.isEncrypted) { val contentUploadResponse = if (params.isEncrypted) {
@ -140,96 +156,102 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
thumbnailProgressListener) thumbnailProgressListener)
} }
uploadedThumbnailUrl = contentUploadResponse.contentUri uploadedThumbnailUrl = contentUploadResponse.contentUri
} catch (t: Throwable) { } catch (t: Throwable) {
Timber.e(t, "Thumbnail update failed") Timber.e(t, "Thumbnail update failed")
}
} }
}
val progressListener = object : ProgressRequestBody.Listener { val progressListener = object : ProgressRequestBody.Listener {
override fun onProgress(current: Long, total: Long) { override fun onProgress(current: Long, total: Long) {
notifyTracker(params) { notifyTracker(params) {
if (isStopped) { if (isStopped) {
contentUploadStateTracker.setFailure(it, Throwable("Cancelled")) contentUploadStateTracker.setFailure(it, Throwable("Cancelled"))
} else { } else {
contentUploadStateTracker.setProgress(it, current, total) 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 // 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. // copy it to a cache folder by using InputStream and OutputStream.
// https://github.com/zetbaitsu/Compressor/pull/150 // 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. // 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) val compressedFile = Compressor.compress(context, workingFile) {
cacheFile.parentFile?.mkdirs() default(
if (cacheFile.exists()) { width = MAX_IMAGE_SIZE,
cacheFile.delete() height = MAX_IMAGE_SIZE
} )
cacheFile.createNewFile()
cacheFile.deleteOnExit()
val outputStream = cacheFile.outputStream()
outputStream.use {
inputStream.copyTo(outputStream)
} }
if (attachment.type == ContentAttachmentData.Type.IMAGE && params.compressBeforeSending) { val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
cacheFile = Compressor.compress(context, cacheFile) { BitmapFactory.decodeFile(compressedFile.absolutePath, options)
default( val fileSize = compressedFile.length().toInt()
width = MAX_IMAGE_SIZE, newImageAttributes = NewImageAttributes(
height = MAX_IMAGE_SIZE options.outWidth,
) options.outHeight,
}.also { compressedFile -> fileSize
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true } )
BitmapFactory.decodeFile(compressedFile.absolutePath, options) fileToUplaod = compressedFile
val fileSize = compressedFile.length().toInt() } else {
newImageAttributes = NewImageAttributes( fileToUplaod = workingFile
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 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) { } catch (e: Exception) {
Timber.e(e) Timber.e(e, "## FileService: ERROR")
notifyTracker(params) { contentUploadStateTracker.setFailure(it, e) } notifyTracker(params) { contentUploadStateTracker.setFailure(it, e) }
return Result.success( return Result.success(
WorkerParamsFactory.toData( WorkerParamsFactory.toData(
@ -259,7 +281,6 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
thumbnailUrl: String?, thumbnailUrl: String?,
thumbnailEncryptedFileInfo: EncryptedFileInfo?, thumbnailEncryptedFileInfo: EncryptedFileInfo?,
newImageAttributes: NewImageAttributes?): Result { newImageAttributes: NewImageAttributes?): Result {
Timber.v("handleSuccess $attachmentUrl, work is stopped $isStopped")
notifyTracker(params) { contentUploadStateTracker.setSuccess(it) } notifyTracker(params) { contentUploadStateTracker.setSuccess(it) }
val updatedEvents = params.events 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) 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, private fun updateEvent(event: Event,

View File

@ -61,19 +61,23 @@ internal class DefaultContentDownloadStateTracker @Inject constructor() : Progre
// private fun URL.toKey() = toString() // private fun URL.toKey() = toString()
override fun update(url: String, bytesRead: Long, contentLength: Long, done: Boolean) { override fun update(url: String, bytesRead: Long, contentLength: Long, done: Boolean) {
Timber.v("## DL Progress url:$url read:$bytesRead total:$contentLength done:$done") mainHandler.post {
if (done) { Timber.v("## DL Progress url:$url read:$bytesRead total:$contentLength done:$done")
updateState(url, ContentDownloadStateTracker.State.Success) if (done) {
} else { updateState(url, ContentDownloadStateTracker.State.Success)
updateState(url, ContentDownloadStateTracker.State.Downloading(bytesRead, contentLength, contentLength == -1L)) } else {
updateState(url, ContentDownloadStateTracker.State.Downloading(bytesRead, contentLength, contentLength == -1L))
}
} }
} }
override fun error(url: String, errorCode: Int) { override fun error(url: String, errorCode: Int) {
Timber.v("## DL Progress Error code:$errorCode") mainHandler.post {
updateState(url, ContentDownloadStateTracker.State.Failure(errorCode)) Timber.v("## DL Progress Error code:$errorCode")
listeners[url]?.forEach { updateState(url, ContentDownloadStateTracker.State.Failure(errorCode))
tryThis { it.onDownloadStateUpdate(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.IdentityServiceError
import org.matrix.android.sdk.api.session.identity.ThreePid import org.matrix.android.sdk.api.session.identity.ThreePid
import org.matrix.android.sdk.api.session.identity.toMedium 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.crypto.tools.withOlmUtility
import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.di.UserId
import org.matrix.android.sdk.internal.network.executeRequest 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.IdentityLookUpParams
import org.matrix.android.sdk.internal.session.identity.model.IdentityLookUpResponse import org.matrix.android.sdk.internal.session.identity.model.IdentityLookUpResponse
import org.matrix.android.sdk.internal.task.Task import org.matrix.android.sdk.internal.task.Task
import org.matrix.android.sdk.internal.util.base64ToBase64Url
import java.util.Locale import java.util.Locale
import javax.inject.Inject 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 package org.matrix.android.sdk.internal.session.room.send
import android.net.Uri
import androidx.work.BackoffPolicy import androidx.work.BackoffPolicy
import androidx.work.ExistingWorkPolicy import androidx.work.ExistingWorkPolicy
import androidx.work.OneTimeWorkRequest import androidx.work.OneTimeWorkRequest
import androidx.work.Operation import androidx.work.Operation
import com.squareup.inject.assisted.Assisted import com.squareup.inject.assisted.Assisted
import com.squareup.inject.assisted.AssistedInject 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.content.ContentAttachmentData
import org.matrix.android.sdk.api.session.crypto.CryptoService 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.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.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.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.SendService
import org.matrix.android.sdk.api.session.room.send.SendState 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.session.room.timeline.TimelineEvent
import org.matrix.android.sdk.api.util.Cancelable import org.matrix.android.sdk.api.util.Cancelable
import org.matrix.android.sdk.api.util.CancelableBag import org.matrix.android.sdk.api.util.CancelableBag
import org.matrix.android.sdk.api.util.JsonDict 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.SessionId
import org.matrix.android.sdk.internal.di.WorkManagerProvider import org.matrix.android.sdk.internal.di.WorkManagerProvider
import org.matrix.android.sdk.internal.session.content.UploadContentWorker 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.AlwaysSuccessfulWorker
import org.matrix.android.sdk.internal.worker.WorkerParamsFactory import org.matrix.android.sdk.internal.worker.WorkerParamsFactory
import org.matrix.android.sdk.internal.worker.startChain import org.matrix.android.sdk.internal.worker.startChain
import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@ -60,7 +70,8 @@ internal class DefaultSendService @AssistedInject constructor(
private val cryptoService: CryptoService, private val cryptoService: CryptoService,
private val taskExecutor: TaskExecutor, private val taskExecutor: TaskExecutor,
private val localEchoRepository: LocalEchoRepository, private val localEchoRepository: LocalEchoRepository,
private val roomEventSender: RoomEventSender private val roomEventSender: RoomEventSender,
private val cancelSendTracker: CancelSendTracker
) : SendService { ) : SendService {
@AssistedInject.Factory @AssistedInject.Factory
@ -127,48 +138,83 @@ internal class DefaultSendService @AssistedInject constructor(
.let { timelineSendEventWorkCommon.postWork(roomId, it) } .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()) { if (localEcho.root.isTextMessage() && localEcho.root.sendState.hasFailed()) {
localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT) localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT)
return sendEvent(localEcho.root) return sendEvent(localEcho.root)
} }
return null return NoOpCancellable
} }
override fun resendMediaMessage(localEcho: TimelineEvent): Cancelable? { override fun resendMediaMessage(localEcho: TimelineEvent): Cancelable {
if (localEcho.root.isImageMessage() && localEcho.root.sendState.hasFailed()) { if (localEcho.root.sendState.hasFailed()) {
// TODO this need a refactoring of attachement sending val clearContent = localEcho.root.getClearContent()
// val clearContent = localEcho.root.getClearContent() val messageContent = clearContent?.toModel<MessageContent>() as? MessageWithAttachmentContent ?: return NoOpCancellable
// val messageContent = clearContent?.toModel<MessageContent>() ?: return null
// when (messageContent.type) { val url = messageContent.getFileUrl() ?: return NoOpCancellable
// MessageType.MSGTYPE_IMAGE -> { if (url.startsWith("mxc://")) {
// val imageContent = clearContent.toModel<MessageImageContent>() ?: return null // We need to resend only the message as the attachment is ok
// val url = imageContent.url ?: return null localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT)
// if (url.startsWith("mxc://")) { return sendEvent(localEcho.root)
// //TODO }
// } else {
// //The image has not yet been sent // we need to resend the media
// val attachmentData = ContentAttachmentData( return when (messageContent) {
// size = imageContent.info!!.size.toLong(), is MessageImageContent -> {
// mimeType = imageContent.info.mimeType!!, // The image has not yet been sent
// width = imageContent.info.width.toLong(), val attachmentData = ContentAttachmentData(
// height = imageContent.info.height.toLong(), size = messageContent.info!!.size.toLong(),
// name = imageContent.body, mimeType = messageContent.info.mimeType!!,
// path = imageContent.url, width = messageContent.info.width.toLong(),
// type = ContentAttachmentData.Type.IMAGE height = messageContent.info.height.toLong(),
// ) name = messageContent.body,
// monarchy.runTransactionSync { queryUri = Uri.parse(messageContent.url),
// EventEntity.where(it,eventId = localEcho.root.eventId ?: "").findFirst()?.let { type = ContentAttachmentData.Type.IMAGE
// it.sendState = SendState.UNSENT )
// } localEchoRepository.updateSendState(localEcho.eventId, SendState.UNSENT)
// } internalSendMedia(listOf(localEcho.root), attachmentData, true)
// return internalSendMedia(localEcho.root,attachmentData) }
// } is MessageVideoContent -> {
// } val attachmentData = ContentAttachmentData(
// } size = messageContent.videoInfo?.size ?: 0L,
return null 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) { 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() { override fun resendAllFailedMessages() {
taskExecutor.executorScope.launch { taskExecutor.executorScope.launch {
val eventsToResend = localEchoRepository.getAllFailedEventsToResend(roomId) val eventsToResend = localEchoRepository.getAllFailedEventsToResend(roomId)
eventsToResend.forEach { 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, override fun sendMedia(attachment: ContentAttachmentData,
compressBeforeSending: Boolean, compressBeforeSending: Boolean,
roomIds: Set<String>): Cancelable { 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 crypto: CryptoService
@Inject lateinit var localEchoRepository: LocalEchoRepository @Inject lateinit var localEchoRepository: LocalEchoRepository
@Inject lateinit var cancelSendTracker: CancelSendTracker
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
Timber.v("Start Encrypt work") Timber.v("Start Encrypt work")
@ -61,7 +62,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
?: return Result.success() ?: return Result.success()
.also { Timber.e("Unable to parse work parameters") } .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) { if (params.lastFailureMessage != null) {
// Transmit the error // Transmit the error
return Result.success(inputData) return Result.success(inputData)
@ -75,6 +76,12 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters)
if (localEvent.eventId == null) { if (localEvent.eventId == null) {
return Result.success() 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) localEchoRepository.updateSendState(localEvent.eventId, SendState.ENCRYPTING)
val localMutableContent = localEvent.content?.toMutableMap() ?: mutableMapOf() 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.helper.nextId
import org.matrix.android.sdk.internal.database.mapper.ContentMapper 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.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.mapper.toEntity
import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventEntity
import org.matrix.android.sdk.internal.database.model.EventInsertEntity 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) { 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 -> monarchy.writeAsync { realm ->
val sendingEventEntity = EventEntity.where(realm, eventId).findFirst() val sendingEventEntity = EventEntity.where(realm, eventId).findFirst()
if (sendingEventEntity != null) { if (sendingEventEntity != null) {
@ -114,9 +113,13 @@ internal class LocalEchoRepository @Inject constructor(@SessionDatabase private
} }
suspend fun deleteFailedEcho(roomId: String, localEcho: TimelineEvent) { suspend fun deleteFailedEcho(roomId: String, localEcho: TimelineEvent) {
deleteFailedEcho(roomId, localEcho.eventId)
}
suspend fun deleteFailedEcho(roomId: String, eventId: String?) {
monarchy.awaitTransaction { realm -> monarchy.awaitTransaction { realm ->
TimelineEventEntity.where(realm, roomId = roomId, eventId = localEcho.root.eventId ?: "").findFirst()?.deleteFromRealm() TimelineEventEntity.where(realm, roomId = roomId, eventId = eventId ?: "").findFirst()?.deleteFromRealm()
EventEntity.where(realm, eventId = localEcho.root.eventId ?: "").findFirst()?.deleteFromRealm() EventEntity.where(realm, eventId = eventId ?: "").findFirst()?.deleteFromRealm()
roomSummaryUpdater.updateSendingInformation(realm, roomId) 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 -> return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
TimelineEventEntity TimelineEventEntity
.findAllInRoomWithSendStates(realm, roomId, SendState.HAS_FAILED_STATES) .findAllInRoomWithSendStates(realm, roomId, states)
.sortedByDescending { it.displayIndex } .sortedByDescending { it.displayIndex }
.mapNotNull { it.root?.asDomain() } .mapNotNull { it?.let { timelineEventMapper.map(it) } }
.filter { event -> .filter { event ->
when (event.getClearType()) { when (event.root.getClearType()) {
EventType.MESSAGE, EventType.MESSAGE,
EventType.REDACTION, EventType.REDACTION,
EventType.REACTION -> { EventType.REACTION -> {
val content = event.getClearContent().toModel<MessageContent>() val content = event.root.getClearContent().toModel<MessageContent>()
if (content != null) { if (content != null) {
when (content.msgType) { when (content.msgType) {
MessageType.MSGTYPE_EMOTE, MessageType.MSGTYPE_EMOTE,
MessageType.MSGTYPE_NOTICE, MessageType.MSGTYPE_NOTICE,
MessageType.MSGTYPE_LOCATION, MessageType.MSGTYPE_LOCATION,
MessageType.MSGTYPE_TEXT -> { MessageType.MSGTYPE_TEXT,
true
}
MessageType.MSGTYPE_FILE, MessageType.MSGTYPE_FILE,
MessageType.MSGTYPE_VIDEO, MessageType.MSGTYPE_VIDEO,
MessageType.MSGTYPE_IMAGE, MessageType.MSGTYPE_IMAGE,
MessageType.MSGTYPE_AUDIO -> { MessageType.MSGTYPE_AUDIO -> {
// need to resend the attachment // need to resend the attachment
false true
} }
else -> { else -> {
Timber.e("Cannot resend message ${event.type} / ${content.msgType}") Timber.e("Cannot resend message ${event.root.getClearType()} / ${content.msgType}")
false false
} }
} }
} else { } else {
Timber.e("Unsupported message to resend ${event.type}") Timber.e("Unsupported message to resend ${event.root.getClearType()}")
false false
} }
} }
else -> { else -> {
Timber.e("Unsupported message to resend ${event.type}") Timber.e("Unsupported message to resend ${event.root.getClearType()}")
false false
} }
} }

View File

@ -58,7 +58,7 @@ internal class MultipleEventSendingDispatcherWorker(context: Context, params: Wo
@Inject lateinit var localEchoRepository: LocalEchoRepository @Inject lateinit var localEchoRepository: LocalEchoRepository
override suspend fun doWork(): Result { 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) val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success() ?: return Result.success()
.also { Timber.e("Unable to parse work parameters") } .also { Timber.e("Unable to parse work parameters") }
@ -72,18 +72,21 @@ internal class MultipleEventSendingDispatcherWorker(context: Context, params: Wo
} }
// Transmit the error if needed? // Transmit the error if needed?
return Result.success(inputData) 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 // Create a work for every event
params.events.forEach { event -> params.events.forEach { event ->
if (params.isEncrypted) { 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) val encryptWork = createEncryptEventWork(params.sessionId, event, true)
// Note that event will be replaced by the result of the previous work // Note that event will be replaced by the result of the previous work
val sendWork = createSendEventWork(params.sessionId, event, false) val sendWork = createSendEventWork(params.sessionId, event, false)
timelineSendEventWorkCommon.postSequentialWorks(event.roomId!!, encryptWork, sendWork) timelineSendEventWorkCommon.postSequentialWorks(event.roomId!!, encryptWork, sendWork)
} else { } else {
localEchoRepository.updateSendState(event.eventId ?: "", SendState.SENDING)
Timber.v("## SendEvent: [${System.currentTimeMillis()}] Schedule send event ${event.eventId}")
val sendWork = createSendEventWork(params.sessionId, event, true) val sendWork = createSendEventWork(params.sessionId, event, true)
timelineSendEventWorkCommon.postWork(event.roomId!!, sendWork) timelineSendEventWorkCommon.postWork(event.roomId!!, sendWork)
} }

View File

@ -39,13 +39,16 @@ internal class RoomEventSender @Inject constructor(
) { ) {
fun sendEvent(event: Event): Cancelable { fun sendEvent(event: Event): Cancelable {
// Encrypted room handling // Encrypted room handling
return if (cryptoService.isRoomEncrypted(event.roomId ?: "")) { return if (cryptoService.isRoomEncrypted(event.roomId ?: "")
Timber.v("Send event in encrypted room") && !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) val encryptWork = createEncryptEventWork(event, true)
// Note that event will be replaced by the result of the previous work // Note that event will be replaced by the result of the previous work
val sendWork = createSendEventWork(event, false) val sendWork = createSendEventWork(event, false)
timelineSendEventWorkCommon.postSequentialWorks(event.roomId ?: "", encryptWork, sendWork) timelineSendEventWorkCommon.postSequentialWorks(event.roomId ?: "", encryptWork, sendWork)
} else { } else {
Timber.v("## SendEvent: [${System.currentTimeMillis()}] Schedule send event ${event.eventId}")
val sendWork = createSendEventWork(event, true) val sendWork = createSendEventWork(event, true)
timelineSendEventWorkCommon.postWork(event.roomId ?: "", sendWork) timelineSendEventWorkCommon.postWork(event.roomId ?: "", sendWork)
} }

View File

@ -34,7 +34,7 @@ import org.matrix.android.sdk.internal.worker.getSessionComponent
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject 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 * Possible previous worker: [EncryptEventWorker] or first worker
@ -56,12 +56,12 @@ internal class SendEventWorker(context: Context,
@Inject lateinit var localEchoRepository: LocalEchoRepository @Inject lateinit var localEchoRepository: LocalEchoRepository
@Inject lateinit var roomAPI: RoomAPI @Inject lateinit var roomAPI: RoomAPI
@Inject lateinit var eventBus: EventBus @Inject lateinit var eventBus: EventBus
@Inject lateinit var cancelSendTracker: CancelSendTracker
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData) val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.success() ?: 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() val sessionComponent = getSessionComponent(params.sessionId) ?: return Result.success()
sessionComponent.inject(this) sessionComponent.inject(this)
@ -75,22 +75,32 @@ internal class SendEventWorker(context: Context,
.also { Timber.e("Work cancelled due to bad input data") } .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) { if (params.lastFailureMessage != null) {
localEchoRepository.updateSendState(event.eventId, SendState.UNDELIVERED) localEchoRepository.updateSendState(event.eventId, SendState.UNDELIVERED)
// Transmit the error // Transmit the error
return Result.success(inputData) return Result.success(inputData)
.also { Timber.e("Work cancelled due to input error from parent") } .also { Timber.e("Work cancelled due to input error from parent") }
} }
Timber.v("## SendEvent: [${System.currentTimeMillis()}] Send event ${params.eventId}")
return try { return try {
sendEvent(event.eventId, event.roomId, event.type, event.content) sendEvent(event.eventId, event.roomId, event.type, event.content)
Result.success() Result.success()
} catch (exception: Throwable) { } catch (exception: Throwable) {
// It does start from 0, we want it to stop if it fails the third time if (/*currentAttemptCount >= MAX_NUMBER_OF_RETRY_BEFORE_FAILING ||**/ !exception.shouldBeRetried()) {
val currentAttemptCount = runAttemptCount + 1 Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed cannot retry ${params.eventId} > ${exception.localizedMessage}")
if (currentAttemptCount >= MAX_NUMBER_OF_RETRY_BEFORE_FAILING || !exception.shouldBeRetried()) {
localEchoRepository.updateSendState(event.eventId, SendState.UNDELIVERED) localEchoRepository.updateSendState(event.eventId, SendState.UNDELIVERED)
return Result.success() return Result.success()
} else { } else {
Timber.e("## SendEvent: [${System.currentTimeMillis()}] Send event Failed schedule retry ${params.eventId} > ${exception.localizedMessage}")
Result.retry() Result.retry()
} }
} }

View File

@ -115,6 +115,7 @@ internal class DefaultTimeline(
if (!results.isLoaded || !results.isValid) { if (!results.isLoaded || !results.isValid) {
return@OrderedRealmCollectionChangeListener return@OrderedRealmCollectionChangeListener
} }
Timber.v("## SendEvent: [${System.currentTimeMillis()}] DB update for room $roomId")
handleUpdates(results, changeSet) 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 workManagerProvider.workManager
.beginUniqueWork(buildWorkName(roomId), policy, workRequest) .beginUniqueWork(buildWorkName(roomId), policy, workRequest)
.enqueue() .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 moshi_version = '1.8.0'
def daggerVersion = '2.25.4' def daggerVersion = '2.25.4'
def autofill_version = "1.0.0" 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 arch_version = '2.1.0'
def lifecycle_version = '2.2.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 com.bumptech.glide.signature.ObjectKey
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.media.ImageContentRenderer
import org.matrix.android.sdk.api.Matrix
import okhttp3.OkHttpClient 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 timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.lang.Exception
import java.lang.IllegalArgumentException
class VectorGlideModelLoaderFactory(private val activeSessionHolder: ActiveSessionHolder) class VectorGlideModelLoaderFactory(private val activeSessionHolder: ActiveSessionHolder)
: ModelLoaderFactory<ImageContentRenderer.Data, InputStream> { : ModelLoaderFactory<ImageContentRenderer.Data, InputStream> {
@ -84,10 +86,14 @@ class VectorGlideDataFetcher(private val activeSessionHolder: ActiveSessionHolde
override fun cancel() { override fun cancel() {
if (stream != null) { if (stream != null) {
try { 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?.close() // interrupts decode if any
stream = null stream = null
} catch (ignore: IOException) { } catch (ignore: Throwable) {
Timber.e(ignore) 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()) callback.onDataReady(initialFile.inputStream())
return return
} }
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() // val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
val url = contentUrlResolver.resolveFullSize(data.url)
?: return
val request = Request.Builder() val fileService = activeSessionHolder.getSafeActiveSession()?.fileService() ?: return Unit.also {
.url(url) callback.onLoadFailed(IllegalArgumentException("No File service"))
.build() }
// 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() override fun onFailure(failure: Throwable) {
val inputStream = response.body?.byteStream() callback.onLoadFailed(failure as? Exception ?: IOException(failure.localizedMessage))
Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${inputStream?.available()}") }
if (!response.isSuccessful) { }
callback.onLoadFailed(IOException("Unexpected code $response")) )
return // val url = contentUrlResolver.resolveFullSize(data.url)
} // ?: return
stream = if (data.elementToDecrypt != null && data.elementToDecrypt.k.isNotBlank()) { //
Matrix.decryptStream(inputStream, data.elementToDecrypt) // val request = Request.Builder()
} else { // .url(url)
inputStream // .build()
} //
callback.onDataReady(stream) // 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 android.widget.LinearLayout
import androidx.core.view.isVisible import androidx.core.view.isVisible
import im.vector.app.R 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.AvatarRenderer
import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData
import im.vector.app.features.home.room.detail.timeline.item.toMatrixItem 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 { receiptAvatars.forEach {
GlideApp.with(context.applicationContext).clear(it) avatarRenderer?.clear(it)
} }
isVisible = false isVisible = false
} }

View File

@ -19,7 +19,9 @@ package im.vector.app.features.attachments
import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.content.ContentAttachmentData
fun ContentAttachmentData.isPreviewable(): Boolean { 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( 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.GlideRequest
import im.vector.app.core.glide.GlideRequests import im.vector.app.core.glide.GlideRequests
import im.vector.app.core.utils.getColorFromUserId 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.session.content.ContentUrlResolver
import org.matrix.android.sdk.api.util.MatrixItem import org.matrix.android.sdk.api.util.MatrixItem
import javax.inject.Inject import javax.inject.Inject
@ -56,6 +57,11 @@ class AvatarRenderer @Inject constructor(private val activeSessionHolder: Active
DrawableImageViewTarget(imageView)) 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 @UiThread
fun render(matrixItem: MatrixItem, imageView: ImageView, glideRequests: GlideRequests) { fun render(matrixItem: MatrixItem, imageView: ImageView, glideRequests: GlideRequests) {
render(imageView.context, 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 UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailAction()
data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction() data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction()
object MarkAllAsRead : 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() data class HandleTombstoneEvent(val event: Event) : RoomDetailAction()
object AcceptInvite : RoomDetailAction() object AcceptInvite : RoomDetailAction()
object RejectInvite : RoomDetailAction() object RejectInvite : RoomDetailAction()
@ -55,6 +55,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
data class ResendMessage(val eventId: String) : RoomDetailAction() data class ResendMessage(val eventId: String) : RoomDetailAction()
data class RemoveFailedEcho(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() 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)) roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null))
} }
is MessageWithAttachmentContent -> { is MessageWithAttachmentContent -> {
val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, messageContent) val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, informationData.senderId, messageContent)
roomDetailViewModel.handle(action) roomDetailViewModel.handle(action)
} }
is EncryptedEventContent -> { is EncryptedEventContent -> {
@ -1617,6 +1617,9 @@ class RoomDetailFragment @Inject constructor(
is EventSharedAction.Remove -> { is EventSharedAction.Remove -> {
roomDetailViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId)) roomDetailViewModel.handle(RoomDetailAction.RemoveFailedEcho(action.eventId))
} }
is EventSharedAction.Cancel -> {
roomDetailViewModel.handle(RoomDetailAction.CancelSend(action.eventId))
}
is EventSharedAction.ReportContentSpam -> { is EventSharedAction.ReportContentSpam -> {
roomDetailViewModel.handle(RoomDetailAction.ReportContent( roomDetailViewModel.handle(RoomDetailAction.ReportContent(
action.eventId, action.senderId, "This message is spam", spam = true)) 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.MatrixCallback
import org.matrix.android.sdk.api.MatrixPatterns import org.matrix.android.sdk.api.MatrixPatterns
import org.matrix.android.sdk.api.NoOpMatrixCallback 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.query.QueryStringValue
import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.Session
import org.matrix.android.sdk.api.session.crypto.MXCryptoError 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.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.isTextMessage
import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toContent
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
@ -282,6 +283,7 @@ class RoomDetailViewModel @AssistedInject constructor(
is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action) is RoomDetailAction.AddJitsiWidget -> handleAddJitsiConference(action)
is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId) is RoomDetailAction.RemoveWidget -> handleDeleteWidget(action.widgetId)
is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action) is RoomDetailAction.EnsureNativeWidgetAllowed -> handleCheckWidgetAllowed(action)
is RoomDetailAction.CancelSend -> handleCancel(action)
}.exhaustive }.exhaustive
} }
@ -989,8 +991,18 @@ class RoomDetailViewModel @AssistedInject constructor(
private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) { private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) {
val mxcUrl = action.messageFileContent.getFileUrl() 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 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 // we can open it
session.fileService().getTemporarySharableURI(mxcUrl!!, action.messageFileContent.mimeType)?.let { uri -> session.fileService().getTemporarySharableURI(mxcUrl!!, action.messageFileContent.mimeType)?.let { uri ->
_viewEvents.post(RoomDetailViewEvents.OpenFile( _viewEvents.post(RoomDetailViewEvents.OpenFile(
@ -1051,9 +1063,9 @@ class RoomDetailViewModel @AssistedInject constructor(
return return
} }
when { when {
it.root.isTextMessage() -> room.resendTextMessage(it) it.root.isTextMessage() -> room.resendTextMessage(it)
it.root.isImageMessage() -> room.resendMediaMessage(it) it.root.isAttachmentMessage() -> room.resendMediaMessage(it)
else -> { else -> {
// TODO // 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() { private fun handleClearSendQueue() {
room.clearSendingQueue() 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.Session
import org.matrix.android.sdk.api.session.crypto.keysbackup.KeysBackupState 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.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.isTextMessage
import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.events.model.toModel
import org.matrix.android.sdk.api.session.room.model.message.MessageContent 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.api.session.room.timeline.hasBeenEdited
import org.matrix.android.sdk.rx.rx import org.matrix.android.sdk.rx.rx
import org.matrix.android.sdk.rx.unwrap 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. * 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.Resend(eventId))
} }
add(EventSharedAction.Remove(eventId)) add(EventSharedAction.Remove(eventId))
if (vectorPreferences.developerMode()) {
addViewSourceItems(timelineEvent)
}
} else if (timelineEvent.root.sendState.isSending()) { } else if (timelineEvent.root.sendState.isSending()) {
// TODO is uploading attachment? // TODO is uploading attachment?
if (canCancel(timelineEvent)) { if (canCancel(timelineEvent)) {
@ -298,13 +303,7 @@ class MessageActionsViewModel @AssistedInject constructor(@Assisted
add(EventSharedAction.ReRequestKey(timelineEvent.eventId)) add(EventSharedAction.ReRequestKey(timelineEvent.eventId))
} }
} }
addViewSourceItems(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))
}
} }
add(EventSharedAction.CopyPermalink(eventId)) add(EventSharedAction.CopyPermalink(eventId))
if (session.myUserId != timelineEvent.root.senderId) { 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 { private fun canCancel(@Suppress("UNUSED_PARAMETER") event: TimelineEvent): Boolean {
return false return true
} }
private fun canReply(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { 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 { 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 { private fun canViewReactions(event: TimelineEvent): Boolean {

View File

@ -187,10 +187,18 @@ class MessageItemFactory @Inject constructor(
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
attributes: AbsMessageItem.Attributes): MessageFileItem? { attributes: AbsMessageItem.Attributes): MessageFileItem? {
val fileUrl = messageContent.getFileUrl()?.let {
if (informationData.sentByMe && !informationData.sendState.isSent()) {
it
} else {
it.takeIf { it.startsWith("mxc://") }
}
} ?: ""
return MessageFileItem_() return MessageFileItem_()
.attributes(attributes) .attributes(attributes)
.izLocalFile(messageContent.getFileUrl().isLocalFile()) .izLocalFile(fileUrl.isLocalFile())
.mxcUrl(messageContent.getFileUrl() ?: "") .izDownloaded(session.fileService().isFileInCache(fileUrl, messageContent.mimeType))
.mxcUrl(fileUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder) .contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.highlighted(highlight) .highlighted(highlight)

View File

@ -75,9 +75,9 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
is ContentUploadStateTracker.State.Idle -> handleIdle() is ContentUploadStateTracker.State.Idle -> handleIdle()
is ContentUploadStateTracker.State.EncryptingThumbnail -> handleEncryptingThumbnail() is ContentUploadStateTracker.State.EncryptingThumbnail -> handleEncryptingThumbnail()
is ContentUploadStateTracker.State.UploadingThumbnail -> handleProgressThumbnail(state) 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.Uploading -> handleProgress(state)
is ContentUploadStateTracker.State.Failure -> handleFailure(state) is ContentUploadStateTracker.State.Failure -> handleFailure(/*state*/)
is ContentUploadStateTracker.State.Success -> handleSuccess() is ContentUploadStateTracker.State.Success -> handleSuccess()
} }
} }
@ -98,26 +98,29 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
} }
private fun handleEncryptingThumbnail() { 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) { private fun handleProgressThumbnail(state: ContentUploadStateTracker.State.UploadingThumbnail) {
doHandleProgress(R.string.send_file_step_sending_thumbnail, state.current, state.total) doHandleProgress(R.string.send_file_step_sending_thumbnail, state.current, state.total)
} }
private fun handleEncrypting() { private fun handleEncrypting(state: ContentUploadStateTracker.State.Encrypting) {
doHandleEncrypting(R.string.send_file_step_encrypting_file) doHandleEncrypting(R.string.send_file_step_encrypting_file, state.current, state.total)
} }
private fun handleProgress(state: ContentUploadStateTracker.State.Uploading) { private fun handleProgress(state: ContentUploadStateTracker.State.Uploading) {
doHandleProgress(R.string.send_file_step_sending_file, state.current, state.total) doHandleProgress(R.string.send_file_step_sending_file, state.current, state.total)
} }
private fun doHandleEncrypting(resId: Int) { private fun doHandleEncrypting(resId: Int, current: Long, total: Long) {
progressLayout.visibility = View.VISIBLE progressLayout.visibility = View.VISIBLE
val percent = if (total > 0) (100L * (current.toFloat() / total.toFloat())) else 0f
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar) val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView) 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?.text = progressLayout.context.getString(resId)
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING)) progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.ENCRYPTING))
} }
@ -130,19 +133,23 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup,
progressBar?.isVisible = true progressBar?.isVisible = true
progressBar?.isIndeterminate = false progressBar?.isIndeterminate = false
progressBar?.progress = percent.toInt() progressBar?.progress = percent.toInt()
progressTextView.isVisible = true
progressTextView?.text = progressLayout.context.getString(resId, progressTextView?.text = progressLayout.context.getString(resId,
TextUtils.formatFileSize(progressLayout.context, current, true), TextUtils.formatFileSize(progressLayout.context, current, true),
TextUtils.formatFileSize(progressLayout.context, total, true)) TextUtils.formatFileSize(progressLayout.context, total, true))
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING)) progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.SENDING))
} }
private fun handleFailure(state: ContentUploadStateTracker.State.Failure) { private fun handleFailure(/*state: ContentUploadStateTracker.State.Failure*/) {
progressLayout.visibility = View.VISIBLE progressLayout.visibility = View.VISIBLE
val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar) val progressBar = progressLayout.findViewById<ProgressBar>(R.id.mediaProgressBar)
val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView) val progressTextView = progressLayout.findViewById<TextView>(R.id.mediaProgressTextView)
progressBar?.isVisible = false progressBar?.isVisible = false
progressTextView?.text = errorFormatter.toHumanReadable(state.throwable) // Do not show the message it's too technical for users, and unfortunate when upload is cancelled
progressTextView?.setTextColor(messageColorProvider.getMessageTextColor(SendState.UNDELIVERED)) // 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() { private fun handleSuccess() {

View File

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

View File

@ -77,6 +77,7 @@ abstract class AbsMessageItem<H : AbsMessageItem.Holder> : AbsBaseMessageItem<H>
} }
override fun unbind(holder: H) { override fun unbind(holder: H) {
attributes.avatarRenderer.clear(holder.avatarImageView)
holder.avatarImageView.setOnClickListener(null) holder.avatarImageView.setOnClickListener(null)
holder.avatarImageView.setOnLongClickListener(null) holder.avatarImageView.setOnLongClickListener(null)
holder.memberNameView.setOnClickListener(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) 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> { override fun getEventIds(): List<String> {
return listOf(attributes.informationData.eventId) 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.R
import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder 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) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() { abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
@ -86,6 +87,13 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
holder.fileImageWrapper.setOnClickListener(attributes.itemClickListener) holder.fileImageWrapper.setOnClickListener(attributes.itemClickListener)
holder.fileImageWrapper.setOnLongClickListener(attributes.itemLongClickListener) holder.fileImageWrapper.setOnLongClickListener(attributes.itemLongClickListener)
holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG) 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) { override fun unbind(holder: Holder) {
@ -103,6 +111,7 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
val fileImageWrapper by bind<ViewGroup>(R.id.messageFileImageView) val fileImageWrapper by bind<ViewGroup>(R.id.messageFileImageView)
val fileDownloadProgress by bind<ProgressBar>(R.id.messageFileProgressbar) val fileDownloadProgress by bind<ProgressBar>(R.id.messageFileProgressbar)
val filenameView by bind<TextView>(R.id.messageFilenameView) val filenameView by bind<TextView>(R.id.messageFilenameView)
val eventSendingIndicator by bind<ProgressBar>(R.id.eventSendingIndicator)
} }
companion object { 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.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.ProgressBar
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute 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.core.glide.GlideApp
import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder import im.vector.app.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.app.features.media.ImageContentRenderer 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) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Holder>() { 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 // The sending state color will be apply to the progress text
renderSendState(holder.imageView, null, holder.failedToSendIndicator) renderSendState(holder.imageView, null, holder.failedToSendIndicator)
holder.playContentView.visibility = if (playable) View.VISIBLE else View.GONE 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) { override fun unbind(holder: Holder) {
GlideApp.with(holder.view.context.applicationContext).clear(holder.imageView) GlideApp.with(holder.view.context.applicationContext).clear(holder.imageView)
imageContentRenderer.clear(holder.imageView)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId) contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
holder.imageView.setOnClickListener(null) holder.imageView.setOnClickListener(null)
holder.imageView.setOnLongClickListener(null) holder.imageView.setOnLongClickListener(null)
@ -79,6 +89,7 @@ abstract class MessageImageVideoItem : AbsMessageItem<MessageImageVideoItem.Hold
val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia) val mediaContentView by bind<ViewGroup>(R.id.messageContentMedia)
val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator) val failedToSendIndicator by bind<ImageView>(R.id.messageFailToSendIndicator)
val eventSendingIndicator by bind<ProgressBar>(R.id.eventSendingIndicator)
} }
companion object { 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> { override fun getEventIds(): List<String> {
return listOf(attributes.informationData.eventId) return listOf(attributes.informationData.eventId)
} }

View File

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

View File

@ -101,11 +101,7 @@ abstract class BaseAttachmentProvider(val imageContentRenderer: ImageContentRend
override fun loadVideo(target: VideoLoaderTarget, info: AttachmentInfo.Video) { override fun loadVideo(target: VideoLoaderTarget, info: AttachmentInfo.Video) {
val data = info.data as? VideoContentRenderer.Data ?: return 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()) { imageContentRenderer.render(data.thumbnailMediaData, target.contextView(), object : CustomViewTarget<ImageView, Drawable>(target.contextView()) {
override fun onLoadFailed(errorDrawable: Drawable?) { override fun onLoadFailed(errorDrawable: Drawable?) {
target.onThumbnailLoadFailed(info.uid, errorDrawable) target.onThumbnailLoadFailed(info.uid, errorDrawable)
@ -120,24 +116,28 @@ abstract class BaseAttachmentProvider(val imageContentRenderer: ImageContentRend
} }
}) })
target.onVideoFileLoading(info.uid) if (data.url?.startsWith("content://") == true && data.allowNonMxcUrls) {
fileService.downloadFile( target.onVideoURLReady(info.uid, data.url)
downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE, } else {
id = data.eventId, target.onVideoFileLoading(info.uid)
mimeType = data.mimeType, fileService.downloadFile(
elementToDecrypt = data.elementToDecrypt, downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
fileName = data.filename, id = data.eventId,
url = data.url, mimeType = data.mimeType,
callback = object : MatrixCallback<File> { elementToDecrypt = data.elementToDecrypt,
override fun onSuccess(data: File) { fileName = data.filename,
target.onVideoFileReady(info.uid, data) url = data.url,
} callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) {
target.onVideoFileReady(info.uid, data)
}
override fun onFailure(failure: Throwable) { override fun onFailure(failure: Throwable) {
target.onVideoFileLoadFailed(info.uid) target.onVideoFileLoadFailed(info.uid)
}
} }
} )
) }
} }
override fun clear(id: String) { 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.api.session.content.ContentUrlResolver
import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import org.matrix.android.sdk.api.extensions.tryThis
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
@ -50,6 +51,8 @@ interface AttachmentData : Parcelable {
val mimeType: String? val mimeType: String?
val url: String? val url: String?
val elementToDecrypt: ElementToDecrypt? 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, class ImageContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
@ -65,7 +68,9 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
val height: Int?, val height: Int?,
val maxHeight: Int, val maxHeight: Int,
val width: 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 { ) : AttachmentData {
fun isLocalFile() = url.isLocalFile() fun isLocalFile() = url.isLocalFile()
@ -103,6 +108,15 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
.into(imageView) .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>) { fun render(data: Data, contextView: View, target: CustomViewTarget<*, Drawable>) {
val req = if (data.elementToDecrypt != null) { val req = if (data.elementToDecrypt != null) {
// Encrypted image // Encrypted image
@ -111,7 +125,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
.load(data) .load(data)
} else { } else {
// Clear image // Clear image
val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url) val resolvedUrl = resolveUrl(data)
GlideApp GlideApp
.with(contextView) .with(contextView)
.load(resolvedUrl) .load(resolvedUrl)
@ -165,7 +179,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
.load(data) .load(data)
} else { } else {
// Clear image // Clear image
val resolvedUrl = activeSessionHolder.getActiveSession().contentUrlResolver().resolveFullSize(data.url) val resolvedUrl = resolveUrl(data)
GlideApp GlideApp
.with(imageView) .with(imageView)
.load(resolvedUrl) .load(resolvedUrl)
@ -205,7 +219,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver()
val resolvedUrl = when (mode) { val resolvedUrl = when (mode) {
Mode.FULL_SIZE, 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) Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, size.width, size.height, ContentUrlResolver.ThumbnailMethod.SCALE)
} }
// Fallback to base url // Fallback to base url
@ -219,7 +233,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
error( error(
GlideApp GlideApp
.with(imageView) .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 (width, height) = processSize(data, Mode.THUMBNAIL)
val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() 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) val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
if (fullSize.isNullOrBlank() || thumbnail.isNullOrBlank()) { 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 { private fun processSize(data: Data, mode: Mode): Size {
val maxImageWidth = data.maxWidth val maxImageWidth = data.maxWidth
val maxImageHeight = data.maxHeight val maxImageHeight = data.maxHeight

View File

@ -63,7 +63,9 @@ class RoomEventsAttachmentProvider(
maxHeight = -1, maxHeight = -1,
maxWidth = -1, maxWidth = -1,
width = null, width = null,
height = null height = null,
allowNonMxcUrls = it.root.sendState.isSending()
) )
if (content.mimeType == "image/gif") { if (content.mimeType == "image/gif") {
AttachmentInfo.AnimatedImage( AttachmentInfo.AnimatedImage(
@ -89,7 +91,8 @@ class RoomEventsAttachmentProvider(
height = content.videoInfo?.height, height = content.videoInfo?.height,
maxHeight = -1, maxHeight = -1,
width = content.videoInfo?.width, width = content.videoInfo?.width,
maxWidth = -1 maxWidth = -1,
allowNonMxcUrls = it.root.sendState.isSending()
) )
val data = VideoContentRenderer.Data( val data = VideoContentRenderer.Data(
eventId = it.eventId, eventId = it.eventId,
@ -97,7 +100,8 @@ class RoomEventsAttachmentProvider(
mimeType = content.mimeType, mimeType = content.mimeType,
url = content.getFileUrl(), url = content.getFileUrl(),
elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(), elementToDecrypt = content.encryptedFileInfo?.toElementToDecrypt(),
thumbnailMediaData = thumbnailData thumbnailMediaData = thumbnailData,
allowNonMxcUrls = it.root.sendState.isSending()
) )
AttachmentInfo.Video( AttachmentInfo.Video(
uid = it.eventId, uid = it.eventId,

View File

@ -24,12 +24,14 @@ import androidx.core.view.isVisible
import im.vector.app.R import im.vector.app.R
import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.ActiveSessionHolder
import im.vector.app.core.error.ErrorFormatter 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.MatrixCallback
import org.matrix.android.sdk.api.session.file.FileService import org.matrix.android.sdk.api.session.file.FileService
import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt import org.matrix.android.sdk.internal.crypto.attachments.ElementToDecrypt
import kotlinx.android.parcel.Parcelize import kotlinx.android.parcel.Parcelize
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.net.URLEncoder
import javax.inject.Inject import javax.inject.Inject
class VideoContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, 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 mimeType: String?,
override val url: String?, override val url: String?,
override val elementToDecrypt: ElementToDecrypt?, 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 ) : AttachmentData
fun render(data: Data, fun render(data: Data,
@ -60,6 +64,12 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
loadingView.isVisible = false loadingView.isVisible = false
errorView.isVisible = true errorView.isVisible = true
errorView.setText(R.string.unknown_error) 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 { } else {
thumbnailView.isVisible = true thumbnailView.isVisible = true
loadingView.isVisible = true loadingView.isVisible = true
@ -91,6 +101,7 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
} }
} else { } else {
val resolvedUrl = contentUrlResolver.resolveFullSize(data.url) val resolvedUrl = contentUrlResolver.resolveFullSize(data.url)
?: data.url?.takeIf { data.url.isLocalFile() && data.allowNonMxcUrls }
if (resolvedUrl == null) { if (resolvedUrl == null) {
thumbnailView.isVisible = false thumbnailView.isVisible = false

View File

@ -42,18 +42,31 @@
<!-- the media --> <!-- the media -->
<TextView <TextView
android:id="@+id/messageFilenameView" android:id="@+id/messageFilenameView"
android:layout_width="0dp" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:layout_marginEnd="32dp" android:layout_marginEnd="32dp"
android:autoLink="none" android:autoLink="none"
android:gravity="center_vertical" android:gravity="center_vertical"
android:minHeight="@dimen/chat_avatar_size" android:minHeight="@dimen/chat_avatar_size"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@+id/messageFileImageView" app:layout_constraintStart_toEndOf="@+id/messageFileImageView"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:text="A filename here" /> 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 <androidx.constraintlayout.widget.Barrier
android:id="@+id/horizontalBarrier" android:id="@+id/horizontalBarrier"
android:layout_width="wrap_content" android:layout_width="wrap_content"

View File

@ -28,6 +28,15 @@
app:layout_constraintTop_toTopOf="@id/messageThumbnailView" app:layout_constraintTop_toTopOf="@id/messageThumbnailView"
tools:visibility="visible" /> 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 <ImageView
android:id="@+id/messageMediaPlayView" android:id="@+id/messageMediaPlayView"
android:layout_width="40dp" android:layout_width="40dp"