diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt index 864a9d6f..3621c6ad 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/Session.kt @@ -24,6 +24,7 @@ import im.vector.matrix.android.api.session.cache.CacheService import im.vector.matrix.android.api.session.content.ContentUploadStateTracker import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.crypto.CryptoService +import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.group.GroupService import im.vector.matrix.android.api.session.pushers.PushersService import im.vector.matrix.android.api.session.room.RoomDirectoryService @@ -46,6 +47,7 @@ interface Session : CacheService, SignOutService, FilterService, + FileService, PushRuleService, PushersService { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/content/ContentUploadStateTracker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/content/ContentUploadStateTracker.kt index 9211371d..26273ebb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/content/ContentUploadStateTracker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/content/ContentUploadStateTracker.kt @@ -28,10 +28,11 @@ interface ContentUploadStateTracker { sealed class State { object Idle : State() - data class ProgressData(val current: Long, val total: Long) : State() + object EncryptingThumbnail : State() + data class UploadingThumbnail(val current: Long, val total: Long) : State() + object Encrypting : State() + data class Uploading(val current: Long, val total: Long) : State() object Success : State() - object Failure : State() + data class Failure(val throwable: Throwable) : State() } - - } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt index 0397b514..8e933426 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/crypto/CryptoService.kt @@ -26,12 +26,14 @@ import im.vector.matrix.android.api.session.events.model.Content import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.internal.crypto.MXEventDecryptionResult import im.vector.matrix.android.internal.crypto.NewSessionListener +import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult import im.vector.matrix.android.internal.crypto.model.MXDeviceInfo import im.vector.matrix.android.internal.crypto.model.MXEncryptEventContentResult import im.vector.matrix.android.internal.crypto.model.MXUsersDevicesMap import im.vector.matrix.android.internal.crypto.model.rest.DevicesListResponse import im.vector.matrix.android.internal.crypto.model.rest.RoomKeyRequestBody +import java.io.File interface CryptoService { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/FileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/FileService.kt new file mode 100644 index 00000000..296b7e15 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/FileService.kt @@ -0,0 +1,52 @@ +/* + * Copyright 2019 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 im.vector.matrix.android.api.session.file + +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt +import java.io.File + + +/** + * This interface defines methods to get files. + */ +interface FileService { + + enum class DownloadMode { + /** + * Download file in external storage + */ + TO_EXPORT, + /** + * Download file in cache + */ + FOR_INTERNAL_USE + } + + /** + * Download a file. + * Result will be a decrypted file, stored in the cache folder. id parameter will be used to create a sub folder to avoid name collision. + * You can pass the eventId + */ + fun downloadFile( + downloadMode: DownloadMode, + id: String, + fileName: String, + url: String?, + elementToDecrypt: ElementToDecrypt?, + callback: MatrixCallback) +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageAudioContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageAudioContent.kt index 7a9ccf7a..624d827d 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageAudioContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageAudioContent.kt @@ -42,7 +42,7 @@ data class MessageAudioContent( /** * Required. Required if the file is not encrypted. The URL (typically MXC URI) to the audio clip. */ - @Json(name = "url") val url: String? = null, + @Json(name = "url") override val url: String? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null, @@ -51,4 +51,4 @@ data class MessageAudioContent( * Required if the file is encrypted. Information on the encrypted file, as specified in End-to-end encryption. */ @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null -) : MessageEncyptedContent \ No newline at end of file +) : MessageEncryptedContent diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEncyptedContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEncryptedContent.kt similarity index 67% rename from matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEncyptedContent.kt rename to matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEncryptedContent.kt index 3a98701c..125c18bb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEncyptedContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageEncryptedContent.kt @@ -20,8 +20,18 @@ import im.vector.matrix.android.internal.crypto.model.rest.EncryptedFileInfo /** - * Interface for message which can contains encrypted data + * Interface for message which can contains an encrypted file */ -interface MessageEncyptedContent : MessageContent { +interface MessageEncryptedContent : MessageContent { + /** + * Required. Required if the file is unencrypted. The URL (typically MXC URI) to the image. + */ + val url: String? + val encryptedFileInfo: EncryptedFileInfo? -} \ No newline at end of file +} + +/** + * Get the url of the encrypted file or of the file + */ +fun MessageEncryptedContent.getFileUrl() = encryptedFileInfo?.url ?: url diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageFileContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageFileContent.kt index 1b7f1798..ac70f5bf 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageFileContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageFileContent.kt @@ -16,6 +16,7 @@ package im.vector.matrix.android.api.session.room.model.message +import android.content.ClipDescription import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.matrix.android.api.session.events.model.Content @@ -47,10 +48,22 @@ data class MessageFileContent( /** * Required. Required if the file is unencrypted. The URL (typically MXC URI) to the file. */ - @Json(name = "url") val url: String? = null, + @Json(name = "url") override val url: String? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null, @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null -) : MessageEncyptedContent \ No newline at end of file +) : MessageEncryptedContent { + + fun getMimeType(): String { + // Mimetype default to plain text, should not be used + return encryptedFileInfo?.mimetype + ?: info?.mimeType + ?: ClipDescription.MIMETYPE_TEXT_PLAIN + } + + fun getFileName(): String { + return filename ?: body + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageImageContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageImageContent.kt index 50feb484..107a8b27 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageImageContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageImageContent.kt @@ -43,7 +43,7 @@ data class MessageImageContent( /** * Required. Required if the file is unencrypted. The URL (typically MXC URI) to the image. */ - @Json(name = "url") val url: String? = null, + @Json(name = "url") override val url: String? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null, @@ -52,4 +52,4 @@ data class MessageImageContent( * Required if the file is encrypted. Information on the encrypted file, as specified in End-to-end encryption. */ @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null -) : MessageEncyptedContent \ No newline at end of file +) : MessageEncryptedContent diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVideoContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVideoContent.kt index 11845d4d..1c84b3e7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVideoContent.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageVideoContent.kt @@ -42,7 +42,7 @@ data class MessageVideoContent( /** * Required. Required if the file is unencrypted. The URL (typically MXC URI) to the video clip. */ - @Json(name = "url") val url: String? = null, + @Json(name = "url") override val url: String? = null, @Json(name = "m.relates_to") override val relatesTo: RelationDefaultContent? = null, @Json(name = "m.new_content") override val newContent: Content? = null, @@ -51,4 +51,4 @@ data class MessageVideoContent( * Required if the file is encrypted. Information on the encrypted file, as specified in End-to-end encryption. */ @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null -) : MessageEncyptedContent \ No newline at end of file +) : MessageEncryptedContent diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoManager.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoManager.kt index 667f1446..342c7ee5 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoManager.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/CryptoManager.kt @@ -48,6 +48,7 @@ import im.vector.matrix.android.internal.crypto.actions.SetDeviceVerificationAct import im.vector.matrix.android.internal.crypto.algorithms.IMXEncrypting import im.vector.matrix.android.internal.crypto.algorithms.megolm.MXMegolmEncryptionFactory import im.vector.matrix.android.internal.crypto.algorithms.olm.MXOlmEncryptionFactory +import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt import im.vector.matrix.android.internal.crypto.keysbackup.KeysBackup import im.vector.matrix.android.internal.crypto.model.ImportRoomKeysResult import im.vector.matrix.android.internal.crypto.model.MXDeviceInfo @@ -78,6 +79,7 @@ import im.vector.matrix.android.internal.util.fetchCopied import kotlinx.coroutines.* import org.matrix.olm.OlmManager import timber.log.Timber +import java.io.File import java.util.* import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/attachments/MXEncryptedAttachments.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/attachments/MXEncryptedAttachments.kt index 7fe82421..d8c62709 100755 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/attachments/MXEncryptedAttachments.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/crypto/attachments/MXEncryptedAttachments.kt @@ -16,8 +16,8 @@ package im.vector.matrix.android.internal.crypto.attachments -import android.text.TextUtils import android.util.Base64 +import arrow.core.Try import im.vector.matrix.android.internal.crypto.model.rest.EncryptedFileInfo import im.vector.matrix.android.internal.crypto.model.rest.EncryptedFileKey import timber.log.Timber @@ -51,7 +51,7 @@ object MXEncryptedAttachments { * @param mimetype the mime type * @return the encryption file info */ - fun encryptAttachment(attachmentStream: InputStream, mimetype: String): EncryptionResult? { + fun encryptAttachment(attachmentStream: InputStream, mimetype: String): Try { val t0 = System.currentTimeMillis() val secureRandom = SecureRandom() @@ -115,23 +115,21 @@ object MXEncryptedAttachments { encryptedByteArray = outStream.toByteArray() ) - outStream.close() - Timber.v("Encrypt in " + (System.currentTimeMillis() - t0) + " ms") - return result + return Try.just(result) } catch (oom: OutOfMemoryError) { - Timber.e(oom, "## encryptAttachment failed " + oom.message) + Timber.e(oom, "## encryptAttachment failed") + return Try.Failure(oom) } catch (e: Exception) { - Timber.e(e, "## encryptAttachment failed " + e.message) + Timber.e(e, "## encryptAttachment failed") + return Try.Failure(e) + } finally { + try { + outStream.close() + } catch (e: Exception) { + Timber.e(e, "## encryptAttachment() : fail to close outStream") + } } - - try { - outStream.close() - } catch (e: Exception) { - Timber.e(e, "## encryptAttachment() : fail to close outStream") - } - - return null } /** @@ -199,7 +197,7 @@ object MXEncryptedAttachments { val currentDigestValue = base64ToUnpaddedBase64(Base64.encodeToString(messageDigest.digest(), Base64.DEFAULT)) - if (!TextUtils.equals(elementToDecrypt.sha256, currentDigestValue)) { + if (elementToDecrypt.sha256 != currentDigestValue) { Timber.e("## decryptAttachment() : Digest value mismatch") outStream.close() return null diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt new file mode 100644 index 00000000..0f1554ea --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultFileService.kt @@ -0,0 +1,121 @@ +/* + * Copyright 2019 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 im.vector.matrix.android.internal.session + +import android.content.Context +import android.os.Environment +import arrow.core.Try +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.auth.data.SessionParams +import im.vector.matrix.android.api.session.content.ContentUrlResolver +import im.vector.matrix.android.api.session.file.FileService +import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt +import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments +import im.vector.matrix.android.internal.extensions.foldToCallback +import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers +import im.vector.matrix.android.internal.util.md5 +import im.vector.matrix.android.internal.util.writeToFile +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import okhttp3.Request +import timber.log.Timber +import java.io.File +import java.io.IOException +import javax.inject.Inject + +internal class DefaultFileService @Inject constructor(private val context: Context, + private val sessionParams: SessionParams, + private val contentUrlResolver: ContentUrlResolver, + private val coroutineDispatchers: MatrixCoroutineDispatchers) : FileService { + + val okHttpClient = OkHttpClient() + + /** + * Download file in the cache folder, and eventually decrypt it + * TODO implement clear file, to delete "MF" + */ + override fun downloadFile(downloadMode: FileService.DownloadMode, + id: String, + fileName: String, + url: String?, + elementToDecrypt: ElementToDecrypt?, + callback: MatrixCallback) { + GlobalScope.launch(coroutineDispatchers.main) { + withContext(coroutineDispatchers.io) { + Try { + val folder = getFolder(downloadMode, id) + + File(folder, fileName) + }.flatMap { destFile -> + if (!destFile.exists() || downloadMode == FileService.DownloadMode.TO_EXPORT) { + Try { + val resolvedUrl = contentUrlResolver.resolveFullSize(url) ?: throw IllegalArgumentException("url is null") + + val request = Request.Builder() + .url(resolvedUrl) + .build() + + val response = okHttpClient.newCall(request).execute() + val inputStream = response.body()?.byteStream() + Timber.v("Response size ${response.body()?.contentLength()} - Stream available: ${inputStream?.available()}") + if (!response.isSuccessful + || inputStream == null) { + throw IOException() + } + + if (elementToDecrypt != null) { + Timber.v("## decrypt file") + MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt) ?: throw IllegalStateException("Decryption error") + } else { + inputStream + } + } + .map { inputStream -> + writeToFile(inputStream, destFile) + destFile + } + } else { + Try.just(destFile) + } + } + } + .foldToCallback(callback) + } + } + + private fun getFolder(downloadMode: FileService.DownloadMode, id: String): File { + return when (downloadMode) { + FileService.DownloadMode.FOR_INTERNAL_USE -> { + // Create dir tree (MF stands for Matrix File): + // /MF/// + val tmpFolderRoot = File(context.cacheDir, "MF") + val tmpFolderUser = File(tmpFolderRoot, sessionParams.credentials.userId.md5()) + File(tmpFolderUser, id.md5()) + } + FileService.DownloadMode.TO_EXPORT -> { + Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + } + } + .also { folder -> + if (!folder.exists()) { + folder.mkdirs() + } + } + } +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt index 32311853..72fdadfc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/DefaultSession.kt @@ -21,7 +21,6 @@ import android.os.Looper import androidx.annotation.MainThread import androidx.lifecycle.LiveData import androidx.work.WorkManager -import com.zhuinden.monarchy.Monarchy import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.auth.data.SessionParams import im.vector.matrix.android.api.pushrules.PushRuleService @@ -30,6 +29,7 @@ import im.vector.matrix.android.api.session.cache.CacheService import im.vector.matrix.android.api.session.content.ContentUploadStateTracker import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.crypto.CryptoService +import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.group.GroupService import im.vector.matrix.android.api.session.pushers.PushersService import im.vector.matrix.android.api.session.room.RoomDirectoryService @@ -61,20 +61,22 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se private val pushRuleService: PushRuleService, private val pushersService: PushersService, private val cryptoService: CryptoManager, + private val fileService: FileService, private val syncThread: SyncThread, private val contentUrlResolver: ContentUrlResolver, private val contentUploadProgressTracker: ContentUploadStateTracker) : Session, - RoomService by roomService, - RoomDirectoryService by roomDirectoryService, - GroupService by groupService, - UserService by userService, - CryptoService by cryptoService, - CacheService by cacheService, - SignOutService by signOutService, - FilterService by filterService, - PushRuleService by pushRuleService, - PushersService by pushersService { + RoomService by roomService, + RoomDirectoryService by roomDirectoryService, + GroupService by groupService, + UserService by userService, + CryptoService by cryptoService, + CacheService by cacheService, + SignOutService by signOutService, + FilterService by filterService, + FileService by fileService, + PushRuleService by pushRuleService, + PushersService by pushersService { private var isOpen = false diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/DefaultContentUploadStateTracker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/DefaultContentUploadStateTracker.kt index fbd7983d..4bb58fe7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/DefaultContentUploadStateTracker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/DefaultContentUploadStateTracker.kt @@ -43,8 +43,8 @@ internal class DefaultContentUploadStateTracker @Inject constructor() : ContentU } } - internal fun setFailure(key: String) { - val failure = ContentUploadStateTracker.State.Failure + internal fun setFailure(key: String, throwable: Throwable) { + val failure = ContentUploadStateTracker.State.Failure(throwable) updateState(key, failure) } @@ -53,8 +53,23 @@ internal class DefaultContentUploadStateTracker @Inject constructor() : ContentU updateState(key, success) } + internal fun setEncryptingThumbnail(key: String) { + val progressData = ContentUploadStateTracker.State.EncryptingThumbnail + updateState(key, progressData) + } + + internal fun setProgressThumbnail(key: String, current: Long, total: Long) { + val progressData = ContentUploadStateTracker.State.UploadingThumbnail(current, total) + updateState(key, progressData) + } + + internal fun setEncrypting(key: String) { + val progressData = ContentUploadStateTracker.State.Encrypting + updateState(key, progressData) + } + internal fun setProgress(key: String, current: Long, total: Long) { - val progressData = ContentUploadStateTracker.State.ProgressData(current, total) + val progressData = ContentUploadStateTracker.State.Uploading(current, total) updateState(key, progressData) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/UploadContentWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/UploadContentWorker.kt index 2597ef4e..8e1a0281 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/UploadContentWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/UploadContentWorker.kt @@ -48,7 +48,7 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) : val event: Event, val attachment: ContentAttachmentData, val isRoomEncrypted: Boolean, - override var lastFailureMessage: String? = null + override val lastFailureMessage: String? = null ) : SessionWorkerParams @Inject lateinit var fileUploader: FileUploader @@ -69,27 +69,47 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) : val eventId = params.event.eventId ?: return Result.success() val attachment = params.attachment - val isRoomEncrypted = params.isRoomEncrypted + val attachmentFile = try { + File(attachment.path) + } catch (e: Exception) { + Timber.e(e) + contentUploadStateTracker.setFailure(params.event.eventId, e) + return Result.success( + WorkerParamsFactory.toData(params.copy( + lastFailureMessage = e.localizedMessage + )) + ) + } - - val thumbnailData = ThumbnailExtractor.extractThumbnail(params.attachment) - val attachmentFile = createAttachmentFile(attachment) ?: return Result.failure() var uploadedThumbnailUrl: String? = null var uploadedThumbnailEncryptedFileInfo: EncryptedFileInfo? = null - if (thumbnailData != null) { - val contentUploadResponse = if (isRoomEncrypted) { + ThumbnailExtractor.extractThumbnail(params.attachment)?.let { thumbnailData -> + val thumbnailProgressListener = object : ProgressRequestBody.Listener { + override fun onProgress(current: Long, total: Long) { + contentUploadStateTracker.setProgressThumbnail(eventId, current, total) + } + } + + val contentUploadResponse = if (params.isRoomEncrypted) { Timber.v("Encrypt thumbnail") - val encryptionResult = MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType) - ?: return Result.failure() + contentUploadStateTracker.setEncryptingThumbnail(eventId) + MXEncryptedAttachments.encryptAttachment(ByteArrayInputStream(thumbnailData.bytes), thumbnailData.mimeType) + .flatMap { encryptionResult -> + uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo - uploadedThumbnailEncryptedFileInfo = encryptionResult.encryptedFileInfo - - fileUploader - .uploadByteArray(encryptionResult.encryptedByteArray, "thumb_${attachment.name}", thumbnailData.mimeType) + fileUploader + .uploadByteArray(encryptionResult.encryptedByteArray, + "thumb_${attachment.name}", + "application/octet-stream", + thumbnailProgressListener) + } } else { fileUploader - .uploadByteArray(thumbnailData.bytes, "thumb_${attachment.name}", thumbnailData.mimeType) + .uploadByteArray(thumbnailData.bytes, + "thumb_${attachment.name}", + thumbnailData.mimeType, + thumbnailProgressListener) } contentUploadResponse @@ -107,16 +127,17 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) : var uploadedFileEncryptedFileInfo: EncryptedFileInfo? = null - val contentUploadResponse = if (isRoomEncrypted) { + val contentUploadResponse = if (params.isRoomEncrypted) { Timber.v("Encrypt file") + contentUploadStateTracker.setEncrypting(eventId) - val encryptionResult = MXEncryptedAttachments.encryptAttachment(FileInputStream(attachmentFile), attachment.mimeType) - ?: return Result.failure() + MXEncryptedAttachments.encryptAttachment(FileInputStream(attachmentFile), attachment.mimeType) + .flatMap { encryptionResult -> + uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo - uploadedFileEncryptedFileInfo = encryptionResult.encryptedFileInfo - - fileUploader - .uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener) + fileUploader + .uploadByteArray(encryptionResult.encryptedByteArray, attachment.name, "application/octet-stream", progressListener) + } } else { fileUploader .uploadFile(attachmentFile, attachment.name, attachment.mimeType, progressListener) @@ -129,17 +150,8 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) : ) } - private fun createAttachmentFile(attachment: ContentAttachmentData): File? { - return try { - File(attachment.path) - } catch (e: Exception) { - Timber.e(e) - null - } - } - private fun handleFailure(params: Params, failure: Throwable): Result { - contentUploadStateTracker.setFailure(params.event.eventId!!) + contentUploadStateTracker.setFailure(params.event.eventId!!, failure) return Result.success( WorkerParamsFactory.toData( params.copy( @@ -190,9 +202,10 @@ internal class UploadContentWorker(context: Context, params: WorkerParameters) : thumbnailEncryptedFileInfo: EncryptedFileInfo?): MessageVideoContent { return copy( url = if (encryptedFileInfo == null) url else null, + encryptedFileInfo = encryptedFileInfo?.copy(url = url), videoInfo = videoInfo?.copy( thumbnailUrl = if (thumbnailEncryptedFileInfo == null) thumbnailUrl else null, - thumbnailFile = thumbnailEncryptedFileInfo?.copy(url = url) + thumbnailFile = thumbnailEncryptedFileInfo?.copy(url = thumbnailUrl) ) ) } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GetGroupDataWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GetGroupDataWorker.kt index a619b83a..081739cb 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GetGroupDataWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/group/GetGroupDataWorker.kt @@ -32,7 +32,7 @@ internal class GetGroupDataWorker(context: Context, params: WorkerParameters) : internal data class Params( override val userId: String, val groupIds: List, - override var lastFailureMessage: String? = null + override val lastFailureMessage: String? = null ) : SessionWorkerParams @Inject lateinit var getGroupDataTask: GetGroupDataTask diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index 9cf6ac69..09322e6a 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -19,6 +19,7 @@ package im.vector.matrix.android.internal.session.room import dagger.Binds import dagger.Module import dagger.Provides +import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.room.RoomDirectoryService import im.vector.matrix.android.api.session.room.RoomService import im.vector.matrix.android.api.session.room.members.MembershipService @@ -27,6 +28,7 @@ import im.vector.matrix.android.api.session.room.read.ReadService import im.vector.matrix.android.api.session.room.send.SendService import im.vector.matrix.android.api.session.room.state.StateService import im.vector.matrix.android.api.session.room.timeline.TimelineService +import im.vector.matrix.android.internal.session.DefaultFileService import im.vector.matrix.android.internal.session.SessionScope import im.vector.matrix.android.internal.session.room.create.CreateRoomTask import im.vector.matrix.android.internal.session.room.create.DefaultCreateRoomTask @@ -138,4 +140,6 @@ internal abstract class RoomModule { @Binds abstract fun bindTimelineService(timelineService: DefaultTimelineService): TimelineService + @Binds + abstract fun bindFileService(fileService: DefaultFileService): FileService } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/SendRelationWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/SendRelationWorker.kt index a04c7b3e..81a888ee 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/SendRelationWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/relation/SendRelationWorker.kt @@ -40,7 +40,7 @@ internal class SendRelationWorker(context: Context, params: WorkerParameters) : val roomId: String, val event: Event, val relationType: String? = null, - override var lastFailureMessage: String? + override val lastFailureMessage: String? ) : SessionWorkerParams @Inject lateinit var roomAPI: RoomAPI diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/EncryptEventWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/EncryptEventWorker.kt index 44046a1d..118fa7cc 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/EncryptEventWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/EncryptEventWorker.kt @@ -42,7 +42,7 @@ internal class EncryptEventWorker(context: Context, params: WorkerParameters) val event: Event, /**Do not encrypt these keys, keep them as is in encrypted content (e.g. m.relates_to)*/ val keepKeys: List? = null, - override var lastFailureMessage: String? = null + override val lastFailureMessage: String? = null ) : SessionWorkerParams @Inject lateinit var crypto: CryptoService diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RedactEventWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RedactEventWorker.kt index 38e0e23b..5acc16e1 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RedactEventWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/RedactEventWorker.kt @@ -36,7 +36,7 @@ internal class RedactEventWorker(context: Context, params: WorkerParameters) : C val roomId: String, val eventId: String, val reason: String?, - override var lastFailureMessage: String? = null + override val lastFailureMessage: String? = null ) : SessionWorkerParams @Inject lateinit var roomAPI: RoomAPI diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt index 315ea457..05cd56e3 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/SendEventWorker.kt @@ -39,7 +39,7 @@ internal class SendEventWorker constructor(context: Context, params: WorkerParam override val userId: String, val roomId: String, val event: Event, - override var lastFailureMessage: String? = null + override val lastFailureMessage: String? = null ) : SessionWorkerParams @Inject lateinit var localEchoUpdater: LocalEchoUpdater diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/FileSaver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/FileSaver.kt new file mode 100644 index 00000000..9654f5e0 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/util/FileSaver.kt @@ -0,0 +1,37 @@ +/* + * Copyright 2019 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 im.vector.matrix.android.internal.util + +import androidx.annotation.WorkerThread +import okio.Okio +import java.io.File +import java.io.InputStream + +/** + * Save an input stream to a file with Okio + */ +@WorkerThread +fun writeToFile(inputStream: InputStream, outputFile: File) { + val source = Okio.buffer(Okio.source(inputStream)) + val sink = Okio.buffer(Okio.sink(outputFile)) + + source.use { input -> + sink.use { output -> + output.writeAll(input) + } + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/worker/SessionWorkerParams.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/worker/SessionWorkerParams.kt index 99fe3142..0c53a3ef 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/worker/SessionWorkerParams.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/worker/SessionWorkerParams.kt @@ -20,5 +20,5 @@ interface SessionWorkerParams { val userId: String // Null is no error occurs. When chaining Workers, first step is to check that there is no lastFailureMessage from the previous workers - var lastFailureMessage: String? + val lastFailureMessage: String? } diff --git a/vector/src/main/java/im/vector/riotx/core/files/FileSaver.kt b/vector/src/main/java/im/vector/riotx/core/files/FileSaver.kt index 23798587..d6b0056c 100644 --- a/vector/src/main/java/im/vector/riotx/core/files/FileSaver.kt +++ b/vector/src/main/java/im/vector/riotx/core/files/FileSaver.kt @@ -23,6 +23,7 @@ import arrow.core.Try import okio.Okio import timber.log.Timber import java.io.File +import java.io.InputStream /** * Save a string to a file with Okio diff --git a/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt b/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt index 01ca16bf..03cf248b 100644 --- a/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt +++ b/vector/src/main/java/im/vector/riotx/core/utils/PermissionsTools.kt @@ -66,6 +66,7 @@ const val PERMISSION_REQUEST_CODE_AUDIO_CALL = 571 const val PERMISSION_REQUEST_CODE_VIDEO_CALL = 572 const val PERMISSION_REQUEST_CODE_EXPORT_KEYS = 573 const val PERMISSION_REQUEST_CODE_CHANGE_AVATAR = 574 +const val PERMISSION_REQUEST_CODE_DOWNLOAD_FILE = 575 /** * Log the used permissions statuses. diff --git a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt index 77780016..0677224f 100644 --- a/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/crypto/keysbackup/setup/KeysBackupSetupActivity.kt @@ -27,7 +27,7 @@ import im.vector.riotx.R import im.vector.riotx.core.dialogs.ExportKeysDialog import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.platform.SimpleFragmentActivity -import im.vector.riotx.core.utils.toast +import im.vector.riotx.core.utils.* import im.vector.riotx.features.crypto.keys.KeysExporter class KeysBackupSetupActivity : SimpleFragmentActivity() { @@ -132,39 +132,48 @@ class KeysBackupSetupActivity : SimpleFragmentActivity() { } private fun exportKeysManually() { - ExportKeysDialog().show(this, object : ExportKeysDialog.ExportKeyDialogListener { - override fun onPassphrase(passphrase: String) { - showWaitingView() + if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_EXPORT_KEYS)) { + ExportKeysDialog().show(this, object : ExportKeysDialog.ExportKeyDialogListener { + override fun onPassphrase(passphrase: String) { + showWaitingView() - KeysExporter(session) - .export(this@KeysBackupSetupActivity, - passphrase, - object : MatrixCallback { + KeysExporter(session) + .export(this@KeysBackupSetupActivity, + passphrase, + object : MatrixCallback { - override fun onSuccess(data: String) { - hideWaitingView() + override fun onSuccess(data: String) { + hideWaitingView() - AlertDialog.Builder(this@KeysBackupSetupActivity) - .setMessage(getString(R.string.encryption_export_saved_as, data)) - .setCancelable(false) - .setPositiveButton(R.string.ok) { dialog, which -> - val resultIntent = Intent() - resultIntent.putExtra(MANUAL_EXPORT, true) - setResult(RESULT_OK, resultIntent) - finish() - } - .show() - } + AlertDialog.Builder(this@KeysBackupSetupActivity) + .setMessage(getString(R.string.encryption_export_saved_as, data)) + .setCancelable(false) + .setPositiveButton(R.string.ok) { dialog, which -> + val resultIntent = Intent() + resultIntent.putExtra(MANUAL_EXPORT, true) + setResult(RESULT_OK, resultIntent) + finish() + } + .show() + } - override fun onFailure(failure: Throwable) { - toast(failure.localizedMessage) - hideWaitingView() - } - }) - } - }) + override fun onFailure(failure: Throwable) { + toast(failure.localizedMessage) + hideWaitingView() + } + }) + } + }) + } } + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (allGranted(grantResults)) { + if (requestCode == PERMISSION_REQUEST_CODE_EXPORT_KEYS) { + exportKeysManually() + } + } + } override fun onBackPressed() { if (viewModel.shouldPromptOnBack) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt index df5cfc96..d52b16ca 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailActions.kt @@ -18,6 +18,7 @@ package im.vector.riotx.features.home.room.detail import com.jaiselrahman.filepicker.model.MediaFile import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary +import im.vector.matrix.android.api.session.room.model.message.MessageFileContent import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.TimelineEvent @@ -33,6 +34,7 @@ sealed class RoomDetailActions { data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailActions() data class ShowEditHistoryAction(val event: String, val editAggregatedSummary: EditAggregatedSummary) : RoomDetailActions() data class NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions() + data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions() object AcceptInvite : RoomDetailActions() object RejectInvite : RoomDetailActions() diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt index 72c17f63..1439a1e9 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailFragment.kt @@ -63,19 +63,14 @@ import im.vector.riotx.R import im.vector.riotx.core.di.ScreenComponent import im.vector.riotx.core.dialogs.DialogListItem import im.vector.riotx.core.epoxy.LayoutManagerStateRestorer +import im.vector.riotx.core.error.ErrorFormatter import im.vector.riotx.core.extensions.hideKeyboard import im.vector.riotx.core.extensions.observeEvent import im.vector.riotx.core.extensions.setTextOrHide +import im.vector.riotx.core.files.addEntryToDownloadManager import im.vector.riotx.core.glide.GlideApp import im.vector.riotx.core.platform.VectorBaseFragment -import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO -import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA -import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA -import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA -import im.vector.riotx.core.utils.checkPermissions -import im.vector.riotx.core.utils.copyToClipboard -import im.vector.riotx.core.utils.openCamera -import im.vector.riotx.core.utils.shareMedia +import im.vector.riotx.core.utils.* import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter import im.vector.riotx.features.autocomplete.command.CommandAutocompletePolicy import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter @@ -180,6 +175,7 @@ class RoomDetailFragment : @Inject lateinit var notificationDrawerManager: NotificationDrawerManager @Inject lateinit var roomDetailViewModelFactory: RoomDetailViewModel.Factory @Inject lateinit var textComposerViewModelFactory: TextComposerViewModel.Factory + @Inject lateinit var errorFormatter: ErrorFormatter private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback @@ -220,6 +216,15 @@ class RoomDetailFragment : scrollOnHighlightedEventCallback.scheduleScrollTo(it) } + roomDetailViewModel.downloadedFileEvent.observeEvent(this) { downloadFileState -> + if (downloadFileState.throwable != null) { + requireActivity().toast(errorFormatter.toHumanReadable(downloadFileState.throwable)) + } else if (downloadFileState.file != null) { + requireActivity().toast(getString(R.string.downloaded_file, downloadFileState.file.path)) + addEntryToDownloadManager(requireContext(), downloadFileState.file, downloadFileState.mimeType) + } + } + roomDetailViewModel.selectSubscribe( RoomDetailViewState::sendMode, RoomDetailViewState::selectedEvent, @@ -615,8 +620,27 @@ class RoomDetailFragment : startActivity(intent) } - override fun onFileMessageClicked(messageFileContent: MessageFileContent) { - vectorBaseActivity.notImplemented("open file") + override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) { + val action = RoomDetailActions.DownloadFile(eventId, messageFileContent) + // We need WRITE_EXTERNAL permission + if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) { + roomDetailViewModel.process(action) + } else { + roomDetailViewModel.pendingAction = action + } + } + + override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { + if (allGranted(grantResults)) { + if (requestCode == PERMISSION_REQUEST_CODE_DOWNLOAD_FILE) { + val action = roomDetailViewModel.pendingAction + + if (action != null) { + roomDetailViewModel.pendingAction = null + roomDetailViewModel.process(action) + } + } + } } override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) { diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt index 83dcb415..9e0fda91 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewModel.kt @@ -31,10 +31,13 @@ import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.content.ContentAttachmentData import im.vector.matrix.android.api.session.events.model.toModel +import im.vector.matrix.android.api.session.file.FileService import im.vector.matrix.android.api.session.room.model.Membership import im.vector.matrix.android.api.session.room.model.message.MessageContent import im.vector.matrix.android.api.session.room.model.message.MessageType +import im.vector.matrix.android.api.session.room.model.message.getFileUrl import im.vector.matrix.android.api.session.room.timeline.TimelineEvent +import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt import im.vector.matrix.rx.rx import im.vector.riotx.R import im.vector.riotx.core.intent.getFilenameFromUri @@ -50,6 +53,7 @@ import io.reactivex.rxkotlin.subscribeBy import org.commonmark.parser.Parser import org.commonmark.renderer.html.HtmlRenderer import timber.log.Timber +import java.io.File import java.text.SimpleDateFormat import java.util.* import java.util.concurrent.TimeUnit @@ -71,6 +75,9 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } private var timeline = room.createTimeline(eventId, allowedTypes) + // Slot to keep a pending action during permission request + var pendingAction: RoomDetailActions? = null + @AssistedInject.Factory interface Factory { fun create(initialState: RoomDetailViewState): RoomDetailViewModel @@ -113,6 +120,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro is RoomDetailActions.EnterEditMode -> handleEditAction(action) is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action) is RoomDetailActions.EnterReplyMode -> handleReplyAction(action) + is RoomDetailActions.DownloadFile -> handleDownloadFile(action) is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action) else -> Timber.e("Unhandled Action: $action") } @@ -149,6 +157,10 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro val navigateToEvent: LiveData> get() = _navigateToEvent + private val _downloadedFileEvent = MutableLiveData>() + val downloadedFileEvent: LiveData> + get() = _downloadedFileEvent + // PRIVATE METHODS ***************************************************************************** @@ -433,6 +445,40 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro } } + data class DownloadFileState( + val mimeType: String, + val file: File?, + val throwable: Throwable? + ) + + private fun handleDownloadFile(action: RoomDetailActions.DownloadFile) { + session.downloadFile( + FileService.DownloadMode.TO_EXPORT, + action.eventId, + action.messageFileContent.getFileName(), + action.messageFileContent.getFileUrl(), + action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(), + object : MatrixCallback { + override fun onSuccess(data: File) { + _downloadedFileEvent.postValue(LiveEvent(DownloadFileState( + action.messageFileContent.getMimeType(), + data, + null + ))) + } + + override fun onFailure(failure: Throwable) { + _downloadedFileEvent.postValue(LiveEvent(DownloadFileState( + action.messageFileContent.getMimeType(), + null, + failure + ))) + } + }) + + } + + private fun handleNavigateToEvent(action: RoomDetailActions.NavigateToEvent) { val targetEventId = action.eventId diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt index ec1ea87e..0ecfa216 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/TimelineEventController.kt @@ -57,7 +57,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View) fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) - fun onFileMessageClicked(messageFileContent: MessageFileContent) + fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?) } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt index 92ec26dc..889df1b5 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -29,14 +29,7 @@ import im.vector.matrix.android.api.permalinks.MatrixPermalinkSpan import im.vector.matrix.android.api.session.events.model.RelationType import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary -import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent -import im.vector.matrix.android.api.session.room.model.message.MessageContent -import im.vector.matrix.android.api.session.room.model.message.MessageEmoteContent -import im.vector.matrix.android.api.session.room.model.message.MessageFileContent -import im.vector.matrix.android.api.session.room.model.message.MessageImageContent -import im.vector.matrix.android.api.session.room.model.message.MessageNoticeContent -import im.vector.matrix.android.api.session.room.model.message.MessageTextContent -import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent +import im.vector.matrix.android.api.session.room.model.message.* import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt @@ -87,9 +80,9 @@ class MessageItemFactory @Inject constructor( val messageContent: MessageContent = event.annotations?.editSummary?.aggregatedContent?.toModel() - ?: event.root.getClearContent().toModel() - ?: //Malformed content, we should echo something on screen - return DefaultItem_().text(stringProvider.getString(R.string.malformed_message)) + ?: event.root.getClearContent().toModel() + ?: //Malformed content, we should echo something on screen + return DefaultItem_().text(stringProvider.getString(R.string.malformed_message)) if (messageContent.relatesTo?.type == RelationType.REPLACE) { // ignore replace event, the targeted id is already edited @@ -99,16 +92,16 @@ class MessageItemFactory @Inject constructor( // val ev = all.toModel() return when (messageContent) { is MessageEmoteContent -> buildEmoteMessageItem(messageContent, - informationData, - event.annotations?.editSummary, - highlight, - callback) + informationData, + event.annotations?.editSummary, + highlight, + callback) is MessageTextContent -> buildTextMessageItem(event.sendState, - messageContent, - informationData, - event.annotations?.editSummary, - highlight, - callback + messageContent, + informationData, + event.annotations?.editSummary, + highlight, + callback ) is MessageImageContent -> buildImageMessageItem(messageContent, informationData, highlight, callback) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback) @@ -142,7 +135,7 @@ class MessageItemFactory @Inject constructor( })) .longClickListener { view -> return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false + ?: false } } @@ -165,12 +158,16 @@ class MessageItemFactory @Inject constructor( })) .longClickListener { view -> return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false + ?: false } .clickListener( DebouncedClickListener(View.OnClickListener { _ -> - callback?.onFileMessageClicked(messageContent) + callback?.onFileMessageClicked(informationData.eventId, messageContent) })) + .longClickListener { view -> + return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) + ?: false + } } private fun buildNotHandledMessageItem(messageContent: MessageContent, highlight: Boolean): DefaultItem? { @@ -188,7 +185,7 @@ class MessageItemFactory @Inject constructor( val (maxWidth, maxHeight) = timelineMediaSizeProvider.getMaxSize() val data = ImageContentRenderer.Data( filename = messageContent.body, - url = messageContent.encryptedFileInfo?.url ?: messageContent.url, + url = messageContent.getFileUrl(), elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(), height = messageContent.info?.height, maxHeight = maxHeight, @@ -218,7 +215,7 @@ class MessageItemFactory @Inject constructor( })) .longClickListener { view -> return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false + ?: false } } @@ -239,8 +236,10 @@ class MessageItemFactory @Inject constructor( ) val videoData = VideoContentRenderer.Data( + eventId = informationData.eventId, filename = messageContent.body, - videoUrl = messageContent.url, + url = messageContent.getFileUrl(), + elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(), thumbnailMediaData = thumbnailData ) @@ -262,7 +261,7 @@ class MessageItemFactory @Inject constructor( .clickListener { view -> callback?.onVideoMessageClicked(messageContent, videoData, view) } .longClickListener { view -> return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false + ?: false } } @@ -302,7 +301,7 @@ class MessageItemFactory @Inject constructor( })) .longClickListener { view -> return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false + ?: false } } @@ -334,9 +333,9 @@ class MessageItemFactory @Inject constructor( //nop } }, - editStart, - editEnd, - Spanned.SPAN_INCLUSIVE_EXCLUSIVE) + editStart, + editEnd, + Spanned.SPAN_INCLUSIVE_EXCLUSIVE) return spannable } @@ -372,7 +371,7 @@ class MessageItemFactory @Inject constructor( })) .longClickListener { view -> return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false + ?: false } } @@ -408,7 +407,7 @@ class MessageItemFactory @Inject constructor( })) .longClickListener { view -> return@longClickListener callback?.onEventLongClicked(informationData, messageContent, view) - ?: false + ?: false } } diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt index fa4f05f1..688cac3d 100644 --- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt +++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentUploadStateTrackerBinder.kt @@ -16,17 +16,16 @@ package im.vector.riotx.features.home.room.detail.timeline.helper -import android.content.Context import android.text.format.Formatter import android.view.View import android.view.ViewGroup import android.widget.ProgressBar import android.widget.TextView +import androidx.core.view.isVisible import im.vector.matrix.android.api.session.content.ContentUploadStateTracker import im.vector.riotx.R import im.vector.riotx.core.di.ActiveSessionHolder import im.vector.riotx.features.media.ImageContentRenderer -import java.io.File import javax.inject.Inject class ContentUploadStateTrackerBinder @Inject constructor(private val activeSessionHolder: ActiveSessionHolder) { @@ -61,45 +60,77 @@ private class ContentMediaProgressUpdater(private val progressLayout: ViewGroup, override fun onUpdate(state: ContentUploadStateTracker.State) { when (state) { - is ContentUploadStateTracker.State.Idle -> handleIdle(state) - is ContentUploadStateTracker.State.Failure -> handleFailure(state) - is ContentUploadStateTracker.State.Success -> handleSuccess(state) - is ContentUploadStateTracker.State.ProgressData -> handleProgress(state) + is ContentUploadStateTracker.State.Idle -> handleIdle(state) + is ContentUploadStateTracker.State.EncryptingThumbnail -> handleEncryptingThumbnail(state) + is ContentUploadStateTracker.State.UploadingThumbnail -> handleProgressThumbnail(state) + is ContentUploadStateTracker.State.Encrypting -> handleEncrypting(state) + is ContentUploadStateTracker.State.Uploading -> handleProgress(state) + is ContentUploadStateTracker.State.Failure -> handleFailure(state) + is ContentUploadStateTracker.State.Success -> handleSuccess(state) } } private fun handleIdle(state: ContentUploadStateTracker.State.Idle) { if (mediaData.isLocalFile()) { - val file = File(mediaData.url) - progressLayout.visibility = View.VISIBLE + progressLayout.isVisible = true val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) + progressBar?.isVisible = true + progressBar?.isIndeterminate = true progressBar?.progress = 0 - progressTextView?.text = formatStats(progressLayout.context, 0L, file.length()) + progressTextView?.text = progressLayout.context.getString(R.string.send_file_step_idle) } else { - progressLayout.visibility = View.GONE + progressLayout.isVisible = false } } - private fun handleFailure(state: ContentUploadStateTracker.State.Failure) { + private fun handleEncryptingThumbnail(state: ContentUploadStateTracker.State.EncryptingThumbnail) { + doHandleEncrypting(R.string.send_file_step_encrypting_thumbnail) + } + private fun handleProgressThumbnail(state: ContentUploadStateTracker.State.UploadingThumbnail) { + doHandleProgress(R.string.send_file_step_sending_thumbnail, state.current, state.total) + } + + private fun handleEncrypting(state: ContentUploadStateTracker.State.Encrypting) { + doHandleEncrypting(R.string.send_file_step_encrypting_file) + } + + private fun handleProgress(state: ContentUploadStateTracker.State.Uploading) { + doHandleProgress(R.string.send_file_step_sending_file, state.current, state.total) + } + + private fun doHandleEncrypting(resId: Int) { + progressLayout.visibility = View.VISIBLE + val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) + val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) + progressBar?.isIndeterminate = true + progressTextView?.text = progressLayout.context.getString(resId) + } + + private fun doHandleProgress(resId: Int, current: Long, total: Long) { + progressLayout.visibility = View.VISIBLE + val percent = 100L * (current.toFloat() / total.toFloat()) + val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) + val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) + progressBar?.isVisible = true + progressBar?.isIndeterminate = false + progressBar?.progress = percent.toInt() + progressTextView?.text = progressLayout.context.getString(resId, + Formatter.formatShortFileSize(progressLayout.context, current), + Formatter.formatShortFileSize(progressLayout.context, total)) + } + + private fun handleFailure(state: ContentUploadStateTracker.State.Failure) { + progressLayout.visibility = View.VISIBLE + val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) + val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) + progressBar?.isVisible = false + // TODO Red text + progressTextView?.text = state.throwable.localizedMessage } private fun handleSuccess(state: ContentUploadStateTracker.State.Success) { - + progressLayout.visibility = View.GONE } - - private fun handleProgress(state: ContentUploadStateTracker.State.ProgressData) { - progressLayout.visibility = View.VISIBLE - val percent = 100L * (state.current.toFloat() / state.total.toFloat()) - val progressBar = progressLayout.findViewById(R.id.mediaProgressBar) - val progressTextView = progressLayout.findViewById(R.id.mediaProgressTextView) - progressBar?.progress = percent.toInt() - progressTextView?.text = formatStats(progressLayout.context, state.current, state.total) - } - - private fun formatStats(context: Context, current: Long, total: Long): String { - return "${Formatter.formatShortFileSize(context, current)} / ${Formatter.formatShortFileSize(context, total)}" - } - } diff --git a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt index 412043e8..4d68d869 100644 --- a/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt +++ b/vector/src/main/java/im/vector/riotx/features/media/ImageContentRenderer.kt @@ -100,7 +100,6 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder: return } - // TODO DECRYPT_FILE Decrypt file imageView.showImage( Uri.parse(thumbnail), Uri.parse(fullSize) diff --git a/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt b/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt index 5cba0084..80114676 100644 --- a/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt +++ b/vector/src/main/java/im/vector/riotx/features/media/VideoContentRenderer.kt @@ -18,26 +18,90 @@ package im.vector.riotx.features.media import android.os.Parcelable import android.widget.ImageView +import android.widget.ProgressBar +import android.widget.TextView import android.widget.VideoView +import androidx.core.view.isVisible +import im.vector.matrix.android.api.MatrixCallback +import im.vector.matrix.android.api.session.file.FileService +import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt +import im.vector.riotx.R import im.vector.riotx.core.di.ActiveSessionHolder +import im.vector.riotx.core.error.ErrorFormatter import kotlinx.android.parcel.Parcelize +import timber.log.Timber +import java.io.File import javax.inject.Inject -class VideoContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder){ +class VideoContentRenderer @Inject constructor(private val activeSessionHolder: ActiveSessionHolder, + private val errorFormatter: ErrorFormatter) { - // TODO DECRYPT_FILE Encrypted data @Parcelize data class Data( + val eventId: String, val filename: String, - val videoUrl: String?, + val url: String?, + val elementToDecrypt: ElementToDecrypt?, val thumbnailMediaData: ImageContentRenderer.Data ) : Parcelable - fun render(data: Data, thumbnailView: ImageView, videoView: VideoView) { + fun render(data: Data, + thumbnailView: ImageView, + loadingView: ProgressBar, + videoView: VideoView, + errorView: TextView) { val contentUrlResolver = activeSessionHolder.getActiveSession().contentUrlResolver() - val resolvedUrl = contentUrlResolver.resolveFullSize(data.videoUrl) - videoView.setVideoPath(resolvedUrl) - videoView.start() + + if (data.elementToDecrypt != null) { + Timber.v("Decrypt video") + videoView.isVisible = false + + if (data.url == null) { + loadingView.isVisible = false + errorView.isVisible = true + errorView.setText(R.string.unknown_error) + } else { + thumbnailView.isVisible = true + loadingView.isVisible = true + + activeSessionHolder.getActiveSession() + .downloadFile( + FileService.DownloadMode.FOR_INTERNAL_USE, + data.eventId, + data.filename, + data.url, + data.elementToDecrypt, + object : MatrixCallback { + override fun onSuccess(data: File) { + thumbnailView.isVisible = false + loadingView.isVisible = false + videoView.isVisible = true + + videoView.setVideoPath(data.path) + videoView.start() + } + + override fun onFailure(failure: Throwable) { + loadingView.isVisible = false + errorView.isVisible = true + errorView.text = errorFormatter.toHumanReadable(failure) + } + }) + } + } else { + thumbnailView.isVisible = false + loadingView.isVisible = false + + val resolvedUrl = contentUrlResolver.resolveFullSize(data.url) + + if (resolvedUrl == null) { + errorView.isVisible = true + errorView.setText(R.string.unknown_error) + } else { + videoView.setVideoPath(resolvedUrl) + videoView.start() + } + } } } \ No newline at end of file diff --git a/vector/src/main/java/im/vector/riotx/features/media/VideoMediaViewerActivity.kt b/vector/src/main/java/im/vector/riotx/features/media/VideoMediaViewerActivity.kt index e8e4f213..093e257c 100644 --- a/vector/src/main/java/im/vector/riotx/features/media/VideoMediaViewerActivity.kt +++ b/vector/src/main/java/im/vector/riotx/features/media/VideoMediaViewerActivity.kt @@ -28,6 +28,7 @@ import javax.inject.Inject class VideoMediaViewerActivity : VectorBaseActivity() { + @Inject lateinit var imageContentRenderer: ImageContentRenderer @Inject lateinit var videoContentRenderer: VideoContentRenderer override fun injectWith(injector: ScreenComponent) { @@ -38,12 +39,10 @@ class VideoMediaViewerActivity : VectorBaseActivity() { super.onCreate(savedInstanceState) setContentView(im.vector.riotx.R.layout.activity_video_media_viewer) val mediaData = intent.getParcelableExtra(EXTRA_MEDIA_DATA) - if (mediaData.videoUrl.isNullOrEmpty()) { - finish() - } else { - configureToolbar(videoMediaViewerToolbar, mediaData) - videoContentRenderer.render(mediaData, videoMediaViewerThumbnailView, videoMediaViewerVideoView) - } + + configureToolbar(videoMediaViewerToolbar, mediaData) + imageContentRenderer.render(mediaData.thumbnailMediaData, ImageContentRenderer.Mode.FULL_SIZE, videoMediaViewerThumbnailView) + videoContentRenderer.render(mediaData, videoMediaViewerThumbnailView, videoMediaViewerLoading, videoMediaViewerVideoView, videoMediaViewerErrorView) } private fun configureToolbar(toolbar: Toolbar, mediaData: VideoContentRenderer.Data) { diff --git a/vector/src/main/res/layout/activity_video_media_viewer.xml b/vector/src/main/res/layout/activity_video_media_viewer.xml index e1f243b5..f21e6daf 100644 --- a/vector/src/main/res/layout/activity_video_media_viewer.xml +++ b/vector/src/main/res/layout/activity_video_media_viewer.xml @@ -15,6 +15,7 @@ --> @@ -32,12 +33,35 @@ + android:layout_height="match_parent" + android:visibility="gone" + tools:visibility="visible" /> + + + android:layout_height="match_parent" + android:visibility="gone" /> + + diff --git a/vector/src/main/res/values/strings_riotX.xml b/vector/src/main/res/values/strings_riotX.xml index 1539d84d..8919438a 100644 --- a/vector/src/main/res/values/strings_riotX.xml +++ b/vector/src/main/res/values/strings_riotX.xml @@ -5,5 +5,14 @@ Direct Messages + Waiting… + Encrypting thumbnail… + Sending thumbnail (%1$s / %2$s) + Encrypting file… + Sending file (%1$s / %2$s) + + Downloading file %1$s… + File %1$s has been downloaded! + \ No newline at end of file