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