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