diff --git a/CHANGES.md b/CHANGES.md
index 831ef4f081..e716f1252a 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -12,6 +12,7 @@ Improvements 🙌:
- Room Settings: Name, Topic, Photo, Aliases, History Visibility (#1455)
- Update user avatar (#1054)
- Allow self-signed certificate (#1564)
+ - Improve file download and open in timeline
Bugfix 🐛:
- Fix dark theme issue on login screen (#1097)
diff --git a/matrix-sdk-android/src/main/AndroidManifest.xml b/matrix-sdk-android/src/main/AndroidManifest.xml
index e8762b21f2..94b2db2bf1 100644
--- a/matrix-sdk-android/src/main/AndroidManifest.xml
+++ b/matrix-sdk-android/src/main/AndroidManifest.xml
@@ -13,8 +13,20 @@
android:authorities="${applicationId}.workmanager-init"
android:exported="false"
tools:node="remove" />
-
+
+
+
+
-
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 018aae4580..b4f9afdbd4 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
@@ -28,6 +28,7 @@ import im.vector.matrix.android.api.session.call.CallSignalingService
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.ContentDownloadStateTracker
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.homeserver.HomeServerCapabilitiesService
@@ -59,7 +60,6 @@ interface Session :
CacheService,
SignOutService,
FilterService,
- FileService,
TermsService,
ProfileService,
PushRuleService,
@@ -152,6 +152,11 @@ interface Session :
*/
fun typingUsersTracker(): TypingUsersTracker
+ /**
+ * Returns the ContentDownloadStateTracker associated with the session
+ */
+ fun contentDownloadProgressTracker(): ContentDownloadStateTracker
+
/**
* Returns the cryptoService associated with the session
*/
@@ -177,6 +182,11 @@ interface Session :
*/
fun callSignalingService(): CallSignalingService
+ /**
+ * Returns the file download service associated with the session
+ */
+ fun fileService(): FileService
+
/**
* Add a listener to the session.
* @param listener the listener to add.
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt
index 1e178484e9..975e72e088 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/events/model/Event.kt
@@ -235,3 +235,11 @@ fun Event.isVideoMessage(): Boolean {
else -> false
}
}
+
+fun Event.isFileMessage(): Boolean {
+ return getClearType() == EventType.MESSAGE
+ && when (getClearContent()?.toModel()?.msgType) {
+ MessageType.MSGTYPE_FILE -> true
+ else -> false
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/ContentDownloadStateTracker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/ContentDownloadStateTracker.kt
new file mode 100644
index 0000000000..ed098e7a42
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/ContentDownloadStateTracker.kt
@@ -0,0 +1,35 @@
+/*
+ * 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 im.vector.matrix.android.api.session.file
+
+interface ContentDownloadStateTracker {
+ fun track(key: String, updateListener: UpdateListener)
+ fun unTrack(key: String, updateListener: UpdateListener)
+ fun clear()
+
+ sealed class State {
+ object Idle : State()
+ data class Downloading(val current: Long, val total: Long, val indeterminate: Boolean) : State()
+ object Decrypting : State()
+ object Success : State()
+ data class Failure(val errorCode: Int) : State()
+ }
+
+ interface UpdateListener {
+ fun onDownloadStateUpdate(state: State)
+ }
+}
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
index 32fb1a6ab0..c120c499c9 100644
--- 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
@@ -16,6 +16,7 @@
package im.vector.matrix.android.api.session.file
+import android.net.Uri
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
@@ -31,26 +32,58 @@ interface FileService {
* Download file in external storage
*/
TO_EXPORT,
+
/**
* Download file in cache
*/
FOR_INTERNAL_USE,
+
/**
* Download file in file provider path
*/
FOR_EXTERNAL_SHARE
}
+ enum class FileState {
+ IN_CACHE,
+ DOWNLOADING,
+ UNKNOWN
+ }
+
/**
* 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
+ * Result will be a decrypted file, stored in the cache folder. url parameter will be used to create unique filename to avoid name collision.
*/
fun downloadFile(
downloadMode: DownloadMode,
id: String,
fileName: String,
+ mimeType: String?,
url: String?,
elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback): Cancelable
+
+ fun isFileInCache(mxcUrl: String, mimeType: String?): Boolean
+
+ /**
+ * Use this URI and pass it to intent using flag Intent.FLAG_GRANT_READ_URI_PERMISSION
+ * (if not other app won't be able to access it)
+ */
+ fun getTemporarySharableURI(mxcUrl: String, mimeType: String?): Uri?
+
+ /**
+ * Get information on the given file.
+ * Mimetype should be the same one as passed to downloadFile (limitation for now)
+ */
+ fun fileState(mxcUrl: String, mimeType: String?): FileState
+
+ /**
+ * Clears all the files downloaded by the service
+ */
+ fun clearCache()
+
+ /**
+ * Get size of cached files
+ */
+ fun getCacheSize(): Int
}
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/MatrixSDKFileProvider.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/MatrixSDKFileProvider.kt
new file mode 100644
index 0000000000..31d85eefb0
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/file/MatrixSDKFileProvider.kt
@@ -0,0 +1,30 @@
+/*
+ * 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 im.vector.matrix.android.api.session.file
+
+import android.net.Uri
+import androidx.core.content.FileProvider
+
+/**
+ * We have to declare our own file provider to avoid collision with apps using the sdk
+ * and having their own
+ */
+class MatrixSDKFileProvider : FileProvider() {
+ override fun getType(uri: Uri): String? {
+ return super.getType(uri) ?: "plain/text"
+ }
+}
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 248e782a74..a0f4655f4b 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
@@ -51,4 +51,8 @@ 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
-) : MessageWithAttachmentContent
+) : MessageWithAttachmentContent {
+
+ override val mimeType: String?
+ get() = encryptedFileInfo?.mimetype ?: audioInfo?.mimeType
+}
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 f770a2ccea..067d08e5b8 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,7 +16,7 @@
package im.vector.matrix.android.api.session.room.model.message
-import android.content.ClipDescription
+import android.webkit.MimeTypeMap
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.session.events.model.Content
@@ -59,12 +59,12 @@ data class MessageFileContent(
@Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
) : MessageWithAttachmentContent {
- fun getMimeType(): String {
- // Mimetype default to plain text, should not be used
- return encryptedFileInfo?.mimetype
+ override val mimeType: String?
+ get() = encryptedFileInfo?.mimetype
?: info?.mimeType
- ?: ClipDescription.MIMETYPE_TEXT_PLAIN
- }
+ ?: MimeTypeMap.getFileExtensionFromUrl(filename ?: body)?.let { extension ->
+ MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
+ }
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 f50a108947..75ae5f0323 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
@@ -52,4 +52,7 @@ 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
-) : MessageImageInfoContent
+) : MessageImageInfoContent {
+ override val mimeType: String?
+ get() = encryptedFileInfo?.mimetype ?: info?.mimeType ?: "image/*"
+}
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageStickerContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageStickerContent.kt
index 9198537bff..6730768d7d 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageStickerContent.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageStickerContent.kt
@@ -52,4 +52,7 @@ data class MessageStickerContent(
* 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
-) : MessageImageInfoContent
+) : MessageImageInfoContent {
+ override val mimeType: String?
+ get() = encryptedFileInfo?.mimetype ?: info?.mimeType
+}
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 88d2d72d15..34d599595f 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
@@ -51,4 +51,7 @@ 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
-) : MessageWithAttachmentContent
+) : MessageWithAttachmentContent {
+ override val mimeType: String?
+ get() = encryptedFileInfo?.mimetype ?: videoInfo?.mimeType
+}
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageWithAttachmentContent.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageWithAttachmentContent.kt
index 9caf38013f..0613f69c56 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageWithAttachmentContent.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/model/message/MessageWithAttachmentContent.kt
@@ -31,9 +31,13 @@ interface MessageWithAttachmentContent : MessageContent {
* Required if the file is encrypted. Information on the encrypted file, as specified in End-to-end encryption.
*/
val encryptedFileInfo: EncryptedFileInfo?
+
+ val mimeType: String?
}
/**
* Get the url of the encrypted file or of the file
*/
fun MessageWithAttachmentContent.getFileUrl() = encryptedFileInfo?.url ?: url
+
+fun MessageWithAttachmentContent.getFileName() = (this as? MessageFileContent)?.getFileName() ?: body
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/AuthQualifiers.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/AuthQualifiers.kt
index 105d904329..8ef2710fc5 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/AuthQualifiers.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/AuthQualifiers.kt
@@ -33,3 +33,7 @@ internal annotation class Unauthenticated
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
internal annotation class UnauthenticatedWithCertificate
+
+@Qualifier
+@Retention(AnnotationRetention.RUNTIME)
+internal annotation class UnauthenticatedWithCertificateWithProgress
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/FileQualifiers.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/FileQualifiers.kt
index 5dfc04539a..5a7ac1bb24 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/FileQualifiers.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/di/FileQualifiers.kt
@@ -24,7 +24,7 @@ internal annotation class SessionFilesDirectory
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
-internal annotation class SessionCacheDirectory
+internal annotation class SessionDownloadsDirectory
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
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
index 107ef6a351..6d0c9b7b96 100644
--- 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
@@ -16,18 +16,24 @@
package im.vector.matrix.android.internal.session
+import android.content.Context
+import android.net.Uri
+import android.webkit.MimeTypeMap
+import androidx.core.content.FileProvider
import arrow.core.Try
import im.vector.matrix.android.api.MatrixCallback
+import im.vector.matrix.android.api.extensions.tryThis
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.util.Cancelable
+import im.vector.matrix.android.api.util.NoOpCancellable
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments
import im.vector.matrix.android.internal.di.CacheDirectory
import im.vector.matrix.android.internal.di.ExternalFilesDirectory
-import im.vector.matrix.android.internal.di.SessionCacheDirectory
-import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate
-import im.vector.matrix.android.internal.extensions.foldToCallback
+import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
+import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificateWithProgress
+import im.vector.matrix.android.internal.session.download.DownloadProgressInterceptor.Companion.DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import im.vector.matrix.android.internal.util.toCancelable
@@ -36,49 +42,88 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient
import okhttp3.Request
+import okio.buffer
+import okio.sink
+import okio.source
import timber.log.Timber
import java.io.File
import java.io.IOException
+import java.io.InputStream
+import java.net.URLEncoder
import javax.inject.Inject
internal class DefaultFileService @Inject constructor(
+ private val context: Context,
@CacheDirectory
private val cacheDirectory: File,
@ExternalFilesDirectory
private val externalFilesDirectory: File?,
- @SessionCacheDirectory
+ @SessionDownloadsDirectory
private val sessionCacheDirectory: File,
private val contentUrlResolver: ContentUrlResolver,
- @UnauthenticatedWithCertificate
+ @UnauthenticatedWithCertificateWithProgress
private val okHttpClient: OkHttpClient,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val taskExecutor: TaskExecutor
) : FileService {
+ private fun String.safeFileName() = URLEncoder.encode(this, Charsets.US_ASCII.displayName())
+
+ private val downloadFolder = File(sessionCacheDirectory, "MF")
+
+ /**
+ * Retain ongoing downloads to avoid re-downloading and already downloading file
+ * map of mxCurl to callbacks
+ */
+ private val ongoing = mutableMapOf>>()
+
/**
* Download file in the cache folder, and eventually decrypt it
- * TODO implement clear file, to delete "MF"
+ * TODO looks like files are copied 3 times
*/
override fun downloadFile(downloadMode: FileService.DownloadMode,
id: String,
fileName: String,
+ mimeType: String?,
url: String?,
elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback): Cancelable {
+ val unwrappedUrl = url ?: return NoOpCancellable.also {
+ callback.onFailure(IllegalArgumentException("url is null"))
+ }
+
+ Timber.v("## FileService downloadFile $unwrappedUrl")
+
+ synchronized(ongoing) {
+ val existing = ongoing[unwrappedUrl]
+ if (existing != null) {
+ Timber.v("## FileService downloadFile is already downloading.. ")
+ existing.add(callback)
+ return NoOpCancellable
+ } else {
+ // mark as tracked
+ ongoing[unwrappedUrl] = ArrayList()
+ // and proceed to download
+ }
+ }
+
return taskExecutor.executorScope.launch(coroutineDispatchers.main) {
withContext(coroutineDispatchers.io) {
Try {
- val folder = File(sessionCacheDirectory, "MF")
- if (!folder.exists()) {
- folder.mkdirs()
+ if (!downloadFolder.exists()) {
+ downloadFolder.mkdirs()
}
- File(folder, fileName)
+ // ensure we use unique file name by using URL (mapped to suitable file name)
+ // Also we need to add extension for the FileProvider, if not it lot's of app that it's
+ // shared with will not function well (even if mime type is passed in the intent)
+ File(downloadFolder, fileForUrl(unwrappedUrl, mimeType))
}.flatMap { destFile ->
if (!destFile.exists()) {
val resolvedUrl = contentUrlResolver.resolveFullSize(url) ?: return@flatMap Try.Failure(IllegalArgumentException("url is null"))
val request = Request.Builder()
.url(resolvedUrl)
+ .header(DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER, url)
.build()
val response = try {
@@ -87,30 +132,104 @@ internal class DefaultFileService @Inject constructor(
return@flatMap Try.Failure(e)
}
- var inputStream = response.body?.byteStream()
- Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${inputStream?.available()}")
-
- if (!response.isSuccessful || inputStream == null) {
+ if (!response.isSuccessful) {
return@flatMap Try.Failure(IOException())
}
+ val source = response.body?.source()
+ ?: return@flatMap Try.Failure(IOException())
+
+ Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${!source.exhausted()}")
+
if (elementToDecrypt != null) {
Timber.v("## decrypt file")
- inputStream = MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt)
- ?: return@flatMap Try.Failure(IllegalStateException("Decryption error"))
+ val decryptedStream = MXEncryptedAttachments.decryptAttachment(source.inputStream(), elementToDecrypt)
+ response.close()
+ if (decryptedStream == null) {
+ return@flatMap Try.Failure(IllegalStateException("Decryption error"))
+ } else {
+ decryptedStream.use {
+ writeToFile(decryptedStream, destFile)
+ }
+ }
+ } else {
+ writeToFile(source.inputStream(), destFile)
+ response.close()
}
-
- writeToFile(inputStream, destFile)
}
Try.just(copyFile(destFile, downloadMode))
}
- }
- .foldToCallback(callback)
+ }.fold({
+ callback.onFailure(it)
+ // notify concurrent requests
+ val toNotify = synchronized(ongoing) {
+ ongoing[unwrappedUrl]?.also {
+ ongoing.remove(unwrappedUrl)
+ }
+ }
+ toNotify?.forEach { otherCallbacks ->
+ tryThis { otherCallbacks.onFailure(it) }
+ }
+ }, { file ->
+ callback.onSuccess(file)
+ // notify concurrent requests
+ val toNotify = synchronized(ongoing) {
+ ongoing[unwrappedUrl]?.also {
+ ongoing.remove(unwrappedUrl)
+ }
+ }
+ Timber.v("## FileService additional to notify ${toNotify?.size ?: 0} ")
+ toNotify?.forEach { otherCallbacks ->
+ tryThis { otherCallbacks.onSuccess(file) }
+ }
+ })
}.toCancelable()
}
+ fun storeDataFor(url: String, mimeType: String?, inputStream: InputStream) {
+ val file = File(downloadFolder, fileForUrl(url, mimeType))
+ val source = inputStream.source().buffer()
+ file.sink().buffer().let { sink ->
+ source.use { input ->
+ sink.use { output ->
+ output.writeAll(input)
+ }
+ }
+ }
+ }
+
+ private fun fileForUrl(url: String, mimeType: String?): String {
+ val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) }
+ return if (extension != null) "${url.safeFileName()}.$extension" else url.safeFileName()
+ }
+
+ override fun isFileInCache(mxcUrl: String, mimeType: String?): Boolean {
+ return File(downloadFolder, fileForUrl(mxcUrl, mimeType)).exists()
+ }
+
+ override fun fileState(mxcUrl: String, mimeType: String?): FileService.FileState {
+ if (isFileInCache(mxcUrl, mimeType)) return FileService.FileState.IN_CACHE
+ val isDownloading = synchronized(ongoing) {
+ ongoing[mxcUrl] != null
+ }
+ return if (isDownloading) FileService.FileState.DOWNLOADING else FileService.FileState.UNKNOWN
+ }
+
+ /**
+ * Use this URI and pass it to intent using flag Intent.FLAG_GRANT_READ_URI_PERMISSION
+ * (if not other app won't be able to access it)
+ */
+ override fun getTemporarySharableURI(mxcUrl: String, mimeType: String?): Uri? {
+ // this string could be extracted no?
+ val authority = "${context.packageName}.mx-sdk.fileprovider"
+ val targetFile = File(downloadFolder, fileForUrl(mxcUrl, mimeType))
+ if (!targetFile.exists()) return null
+ return FileProvider.getUriForFile(context, authority, targetFile)
+ }
+
private fun copyFile(file: File, downloadMode: FileService.DownloadMode): File {
+ // TODO some of this seems outdated, will need to be re-worked
return when (downloadMode) {
FileService.DownloadMode.TO_EXPORT ->
file.copyTo(File(externalFilesDirectory, file.name), true)
@@ -120,4 +239,17 @@ internal class DefaultFileService @Inject constructor(
file
}
}
+
+ override fun getCacheSize(): Int {
+ return downloadFolder.walkTopDown()
+ .onEnter {
+ Timber.v("Get size of ${it.absolutePath}")
+ true
+ }
+ .sumBy { it.length().toInt() }
+ }
+
+ override fun clearCache() {
+ downloadFolder.deleteRecursively()
+ }
}
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 e32ba7e63c..2172b4a05d 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
@@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.call.CallSignalingService
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.ContentDownloadStateTracker
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.homeserver.HomeServerCapabilitiesService
@@ -90,7 +91,7 @@ internal class DefaultSession @Inject constructor(
private val pushersService: Lazy,
private val termsService: Lazy,
private val cryptoService: Lazy,
- private val fileService: Lazy,
+ private val defaultFileService: Lazy,
private val secureStorageService: Lazy,
private val profileService: Lazy,
private val widgetService: Lazy,
@@ -100,6 +101,7 @@ internal class DefaultSession @Inject constructor(
private val sessionParamsStore: SessionParamsStore,
private val contentUploadProgressTracker: ContentUploadStateTracker,
private val typingUsersTracker: TypingUsersTracker,
+ private val contentDownloadStateTracker: ContentDownloadStateTracker,
private val initialSyncProgressService: Lazy,
private val homeServerCapabilitiesService: Lazy,
private val accountDataService: Lazy,
@@ -120,7 +122,6 @@ internal class DefaultSession @Inject constructor(
FilterService by filterService.get(),
PushRuleService by pushRuleService.get(),
PushersService by pushersService.get(),
- FileService by fileService.get(),
TermsService by termsService.get(),
InitialSyncProgressService by initialSyncProgressService.get(),
SecureStorageService by secureStorageService.get(),
@@ -239,10 +240,14 @@ internal class DefaultSession @Inject constructor(
override fun typingUsersTracker() = typingUsersTracker
+ override fun contentDownloadProgressTracker(): ContentDownloadStateTracker = contentDownloadStateTracker
+
override fun cryptoService(): CryptoService = cryptoService.get()
override fun identityService() = defaultIdentityService
+ override fun fileService(): FileService = defaultFileService.get()
+
override fun widgetService(): WidgetService = widgetService.get()
override fun integrationManagerService() = integrationManagerService
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt
index f084bec924..fb05bc68a2 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/SessionModule.kt
@@ -43,12 +43,13 @@ import im.vector.matrix.android.internal.crypto.verification.VerificationMessage
import im.vector.matrix.android.internal.database.SessionRealmConfigurationFactory
import im.vector.matrix.android.internal.di.Authenticated
import im.vector.matrix.android.internal.di.DeviceId
-import im.vector.matrix.android.internal.di.SessionCacheDirectory
import im.vector.matrix.android.internal.di.SessionDatabase
+import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
import im.vector.matrix.android.internal.di.SessionFilesDirectory
import im.vector.matrix.android.internal.di.SessionId
import im.vector.matrix.android.internal.di.Unauthenticated
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate
+import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificateWithProgress
import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.di.UserMd5
import im.vector.matrix.android.internal.eventbus.EventBusTimberLogger
@@ -60,9 +61,11 @@ import im.vector.matrix.android.internal.network.PreferredNetworkCallbackStrateg
import im.vector.matrix.android.internal.network.RetrofitFactory
import im.vector.matrix.android.internal.network.httpclient.addAccessTokenInterceptor
import im.vector.matrix.android.internal.network.httpclient.addSocketFactory
+import im.vector.matrix.android.internal.network.interceptors.CurlLoggingInterceptor
import im.vector.matrix.android.internal.network.token.AccessTokenProvider
import im.vector.matrix.android.internal.network.token.HomeserverAccessTokenProvider
import im.vector.matrix.android.internal.session.call.CallEventObserver
+import im.vector.matrix.android.internal.session.download.DownloadProgressInterceptor
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.homeserver.DefaultHomeServerCapabilitiesService
import im.vector.matrix.android.internal.session.identity.DefaultIdentityService
@@ -160,10 +163,10 @@ internal abstract class SessionModule {
@JvmStatic
@Provides
- @SessionCacheDirectory
+ @SessionDownloadsDirectory
fun providesCacheDir(@SessionId sessionId: String,
context: Context): File {
- return File(context.cacheDir, sessionId)
+ return File(context.cacheDir, "downloads/$sessionId")
}
@JvmStatic
@@ -216,6 +219,27 @@ internal abstract class SessionModule {
.build()
}
+ @JvmStatic
+ @Provides
+ @SessionScope
+ @UnauthenticatedWithCertificateWithProgress
+ fun providesProgressOkHttpClient(@UnauthenticatedWithCertificate okHttpClient: OkHttpClient,
+ downloadProgressInterceptor: DownloadProgressInterceptor): OkHttpClient {
+ return okHttpClient.newBuilder()
+ .apply {
+ // Remove the previous CurlLoggingInterceptor, to add it after the accessTokenInterceptor
+ val existingCurlInterceptors = interceptors().filterIsInstance()
+ interceptors().removeAll(existingCurlInterceptors)
+
+ addInterceptor(downloadProgressInterceptor)
+
+ // Re add eventually the curl logging interceptors
+ existingCurlInterceptors.forEach {
+ addInterceptor(it)
+ }
+ }.build()
+ }
+
@JvmStatic
@Provides
@SessionScope
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/cleanup/CleanupSession.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/cleanup/CleanupSession.kt
index ebd0fad39c..56c7eb557b 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/cleanup/CleanupSession.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/cleanup/CleanupSession.kt
@@ -22,7 +22,7 @@ import im.vector.matrix.android.internal.auth.SessionParamsStore
import im.vector.matrix.android.internal.crypto.CryptoModule
import im.vector.matrix.android.internal.database.RealmKeysUtils
import im.vector.matrix.android.internal.di.CryptoDatabase
-import im.vector.matrix.android.internal.di.SessionCacheDirectory
+import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.di.SessionFilesDirectory
import im.vector.matrix.android.internal.di.SessionId
@@ -44,7 +44,7 @@ internal class CleanupSession @Inject constructor(
@SessionDatabase private val clearSessionDataTask: ClearCacheTask,
@CryptoDatabase private val clearCryptoDataTask: ClearCacheTask,
@SessionFilesDirectory private val sessionFiles: File,
- @SessionCacheDirectory private val sessionCache: File,
+ @SessionDownloadsDirectory private val sessionCache: File,
private val realmKeysUtils: RealmKeysUtils,
@SessionDatabase private val realmSessionConfiguration: RealmConfiguration,
@CryptoDatabase private val realmCryptoConfiguration: RealmConfiguration,
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/ContentModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/ContentModule.kt
index 577626c8ac..016a1252bc 100644
--- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/ContentModule.kt
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/ContentModule.kt
@@ -20,6 +20,8 @@ import dagger.Binds
import dagger.Module
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.file.ContentDownloadStateTracker
+import im.vector.matrix.android.internal.session.download.DefaultContentDownloadStateTracker
@Module
internal abstract class ContentModule {
@@ -27,6 +29,9 @@ internal abstract class ContentModule {
@Binds
abstract fun bindContentUploadStateTracker(tracker: DefaultContentUploadStateTracker): ContentUploadStateTracker
+ @Binds
+ abstract fun bindContentDownloadStateTracker(tracker: DefaultContentDownloadStateTracker): ContentDownloadStateTracker
+
@Binds
abstract fun bindContentUrlResolver(resolver: DefaultContentUrlResolver): ContentUrlResolver
}
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 75885755e1..dfab8c5f7e 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
@@ -35,6 +35,7 @@ import im.vector.matrix.android.api.session.room.model.message.MessageVideoConte
import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments
import im.vector.matrix.android.internal.crypto.model.rest.EncryptedFileInfo
import im.vector.matrix.android.internal.network.ProgressRequestBody
+import im.vector.matrix.android.internal.session.DefaultFileService
import im.vector.matrix.android.internal.session.room.send.MultipleEventSendingDispatcherWorker
import im.vector.matrix.android.internal.worker.SessionWorkerParams
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
@@ -71,6 +72,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
@Inject lateinit var fileUploader: FileUploader
@Inject lateinit var contentUploadStateTracker: DefaultContentUploadStateTracker
+ @Inject lateinit var fileService: DefaultFileService
override suspend fun doWork(): Result {
val params = WorkerParamsFactory.fromData(inputData)
@@ -210,6 +212,13 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
.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,
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/DefaultContentDownloadStateTracker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/DefaultContentDownloadStateTracker.kt
new file mode 100644
index 0000000000..bdae5b10ef
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/DefaultContentDownloadStateTracker.kt
@@ -0,0 +1,85 @@
+/*
+ * 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 im.vector.matrix.android.internal.session.download
+
+import android.os.Handler
+import android.os.Looper
+import im.vector.matrix.android.api.extensions.tryThis
+import im.vector.matrix.android.api.session.file.ContentDownloadStateTracker
+import im.vector.matrix.android.internal.session.SessionScope
+import timber.log.Timber
+import javax.inject.Inject
+
+@SessionScope
+internal class DefaultContentDownloadStateTracker @Inject constructor() : ProgressListener, ContentDownloadStateTracker {
+
+ private val mainHandler = Handler(Looper.getMainLooper())
+ private val states = mutableMapOf()
+ private val listeners = mutableMapOf>()
+
+ override fun track(key: String, updateListener: ContentDownloadStateTracker.UpdateListener) {
+ val listeners = listeners.getOrPut(key) { ArrayList() }
+ if (!listeners.contains(updateListener)) {
+ listeners.add(updateListener)
+ }
+ val currentState = states[key] ?: ContentDownloadStateTracker.State.Idle
+ mainHandler.post {
+ try {
+ updateListener.onDownloadStateUpdate(currentState)
+ } catch (e: Exception) {
+ Timber.e(e, "## ContentUploadStateTracker.onUpdate() failed")
+ }
+ }
+ }
+
+ override fun unTrack(key: String, updateListener: ContentDownloadStateTracker.UpdateListener) {
+ listeners[key]?.apply {
+ remove(updateListener)
+ }
+ }
+
+ override fun clear() {
+ states.clear()
+ listeners.clear()
+ }
+
+// private fun URL.toKey() = toString()
+
+ override fun update(url: String, bytesRead: Long, contentLength: Long, done: Boolean) {
+ Timber.v("## DL Progress url:$url read:$bytesRead total:$contentLength done:$done")
+ if (done) {
+ updateState(url, ContentDownloadStateTracker.State.Success)
+ } else {
+ updateState(url, ContentDownloadStateTracker.State.Downloading(bytesRead, contentLength, contentLength == -1L))
+ }
+ }
+
+ override fun error(url: String, errorCode: Int) {
+ Timber.v("## DL Progress Error code:$errorCode")
+ updateState(url, ContentDownloadStateTracker.State.Failure(errorCode))
+ listeners[url]?.forEach {
+ tryThis { it.onDownloadStateUpdate(ContentDownloadStateTracker.State.Failure(errorCode)) }
+ }
+ }
+
+ private fun updateState(url: String, state: ContentDownloadStateTracker.State) {
+ states[url] = state
+ listeners[url]?.forEach {
+ tryThis { it.onDownloadStateUpdate(state) }
+ }
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/DownloadProgressInterceptor.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/DownloadProgressInterceptor.kt
new file mode 100644
index 0000000000..3fdc252cbc
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/DownloadProgressInterceptor.kt
@@ -0,0 +1,49 @@
+/*
+ * 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 im.vector.matrix.android.internal.session.download
+
+import okhttp3.Interceptor
+import okhttp3.Response
+import javax.inject.Inject
+
+internal class DownloadProgressInterceptor @Inject constructor(
+ private val downloadStateTracker: DefaultContentDownloadStateTracker
+) : Interceptor {
+
+ companion object {
+ const val DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER = "matrix-sdk:mxc_URL"
+ }
+
+ override fun intercept(chain: Interceptor.Chain): Response {
+ val url = chain.request().url.toUrl()
+ val mxcURl = chain.request().header(DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER)
+
+ val request = chain.request().newBuilder()
+ .removeHeader(DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER)
+ .build()
+
+ val originalResponse = chain.proceed(request)
+ if (!originalResponse.isSuccessful) {
+ downloadStateTracker.error(mxcURl ?: url.toExternalForm(), originalResponse.code)
+ return originalResponse
+ }
+ val responseBody = originalResponse.body ?: return originalResponse
+ return originalResponse.newBuilder()
+ .body(ProgressResponseBody(responseBody, mxcURl ?: url.toExternalForm(), downloadStateTracker))
+ .build()
+ }
+}
diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/ProgressResponseBody.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/ProgressResponseBody.kt
new file mode 100644
index 0000000000..dc41b94321
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/download/ProgressResponseBody.kt
@@ -0,0 +1,62 @@
+/*
+ * 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 im.vector.matrix.android.internal.session.download
+
+import okhttp3.MediaType
+import okhttp3.ResponseBody
+import okio.Buffer
+import okio.BufferedSource
+import okio.ForwardingSource
+import okio.Source
+import okio.buffer
+
+class ProgressResponseBody(
+ private val responseBody: ResponseBody,
+ private val chainUrl: String,
+ private val progressListener: ProgressListener) : ResponseBody() {
+
+ private var bufferedSource: BufferedSource? = null
+
+ override fun contentType(): MediaType? = responseBody.contentType()
+ override fun contentLength(): Long = responseBody.contentLength()
+
+ override fun source(): BufferedSource {
+ if (bufferedSource == null) {
+ bufferedSource = source(responseBody.source()).buffer()
+ }
+ return bufferedSource!!
+ }
+
+ fun source(source: Source): Source {
+ return object : ForwardingSource(source) {
+ var totalBytesRead = 0L
+
+ override fun read(sink: Buffer, byteCount: Long): Long {
+ val bytesRead = super.read(sink, byteCount)
+ // read() returns the number of bytes read, or -1 if this source is exhausted.
+ totalBytesRead += if (bytesRead != -1L) bytesRead else 0L
+ progressListener.update(chainUrl, totalBytesRead, responseBody.contentLength(), bytesRead == -1L)
+ return bytesRead
+ }
+ }
+ }
+}
+
+interface ProgressListener {
+ fun update(url: String, bytesRead: Long, contentLength: Long, done: Boolean)
+ fun error(url: String, errorCode: Int)
+}
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
index 562a32d7bb..f1d6b3eb0d 100644
--- 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
@@ -17,10 +17,8 @@
package im.vector.matrix.android.internal.util
import androidx.annotation.WorkerThread
-import okio.buffer
-import okio.sink
-import okio.source
import java.io.File
+import java.io.FileOutputStream
import java.io.InputStream
/**
@@ -28,9 +26,7 @@ import java.io.InputStream
*/
@WorkerThread
fun writeToFile(inputStream: InputStream, outputFile: File) {
- inputStream.source().buffer().use { input ->
- outputFile.sink().buffer().use { output ->
- output.writeAll(input)
- }
+ FileOutputStream(outputFile).use {
+ inputStream.copyTo(it)
}
}
diff --git a/matrix-sdk-android/src/main/res/xml/sdk_provider_paths.xml b/matrix-sdk-android/src/main/res/xml/sdk_provider_paths.xml
new file mode 100644
index 0000000000..7c15e41df3
--- /dev/null
+++ b/matrix-sdk-android/src/main/res/xml/sdk_provider_paths.xml
@@ -0,0 +1,6 @@
+
+
+
+
\ No newline at end of file
diff --git a/tools/check/forbidden_strings_in_code.txt b/tools/check/forbidden_strings_in_code.txt
index 68ee0b2dd2..08142719b9 100644
--- a/tools/check/forbidden_strings_in_code.txt
+++ b/tools/check/forbidden_strings_in_code.txt
@@ -164,7 +164,7 @@ Formatter\.formatShortFileSize===1
# android\.text\.TextUtils
### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If it is ok, change the value in file forbidden_strings_in_code.txt
-enum class===73
+enum class===74
### Do not import temporary legacy classes
import im.vector.matrix.android.internal.legacy.riot===3
diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml
index 20107c9b65..3ed0d95b71 100644
--- a/vector/src/main/AndroidManifest.xml
+++ b/vector/src/main/AndroidManifest.xml
@@ -204,7 +204,7 @@
+ android:exported="false">
@@ -239,7 +239,7 @@
A media button receiver receives and helps translate hardware media playback buttons,
such as those found on wired and wireless headsets, into the appropriate callbacks in your app.
-->
-
+
@@ -254,7 +254,7 @@
android:grantUriPermissions="true">
+ android:resource="@xml/sdk_provider_paths" />
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 f978e20ca9..21bc16b09f 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
@@ -19,12 +19,14 @@ package im.vector.riotx.core.files
import android.app.DownloadManager
import android.content.ContentValues
import android.content.Context
+import android.net.Uri
import android.os.Build
import android.provider.MediaStore
import androidx.annotation.WorkerThread
import arrow.core.Try
import okio.buffer
import okio.sink
+import okio.source
import timber.log.Timber
import java.io.File
@@ -56,7 +58,7 @@ fun addEntryToDownloadManager(context: Context,
file: File,
mimeType: String,
title: String = file.name,
- description: String = file.name) {
+ description: String = file.name) : Uri? {
try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val contentValues = ContentValues().apply {
@@ -65,17 +67,31 @@ fun addEntryToDownloadManager(context: Context,
put(MediaStore.Downloads.MIME_TYPE, mimeType)
put(MediaStore.Downloads.SIZE, file.length())
}
- context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)?.let { uri ->
- context.contentResolver.openOutputStream(uri)?.use { outputStream ->
- outputStream.sink().buffer().write(file.inputStream().use { it.readBytes() })
+ return context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
+ ?.let { uri ->
+ Timber.v("## addEntryToDownloadManager(): $uri")
+ val source = file.inputStream().source().buffer()
+ context.contentResolver.openOutputStream(uri)?.sink()?.buffer()?.let { sink ->
+ source.use { input ->
+ sink.use { output ->
+ output.writeAll(input)
+ }
+ }
}
+ uri
+ } ?: run {
+ Timber.v("## addEntryToDownloadManager(): context.contentResolver.insert failed")
+
+ null
}
} else {
- val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager?
+ val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
@Suppress("DEPRECATION")
downloadManager?.addCompletedDownload(title, description, true, mimeType, file.absolutePath, file.length(), true)
+ return null
}
} catch (e: Exception) {
Timber.e(e, "## addEntryToDownloadManager(): Exception")
}
+ return null
}
diff --git a/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt b/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt
index 81be9620d0..d93d4e9089 100644
--- a/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt
+++ b/vector/src/main/java/im/vector/riotx/core/utils/ExternalApplicationsUtil.kt
@@ -26,6 +26,7 @@ import android.net.Uri
import android.os.Build
import android.provider.Browser
import android.provider.MediaStore
+import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent
import androidx.browser.customtabs.CustomTabsSession
import androidx.core.content.ContextCompat
@@ -33,8 +34,10 @@ import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import im.vector.riotx.BuildConfig
import im.vector.riotx.R
+import im.vector.riotx.features.notifications.NotificationUtils
import okio.buffer
import okio.sink
+import okio.source
import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat
@@ -296,7 +299,7 @@ fun shareMedia(context: Context, file: File, mediaMimeType: String?) {
}
}
-fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String?): Boolean {
+fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String?, notificationUtils: NotificationUtils) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
val externalContentUri: Uri
val values = ContentValues()
@@ -334,21 +337,34 @@ fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String
values.put(MediaStore.Downloads.DATE_TAKEN, System.currentTimeMillis())
}
}
- context.contentResolver.insert(externalContentUri, values)?.let { uri ->
- context.contentResolver.openOutputStream(uri)?.use { outputStream ->
- outputStream.sink().buffer().write(file.inputStream().use { it.readBytes() })
- return true
+ val uri = context.contentResolver.insert(externalContentUri, values)
+ if (uri == null) {
+ Toast.makeText(context, R.string.error_saving_media_file, Toast.LENGTH_LONG).show()
+ } else {
+ val source = file.inputStream().source().buffer()
+ context.contentResolver.openOutputStream(uri)?.sink()?.buffer()?.let { sink ->
+ source.use { input ->
+ sink.use { output ->
+ output.writeAll(input)
+ }
+ }
+ }
+ notificationUtils.buildDownloadFileNotification(
+ uri,
+ title,
+ mediaMimeType ?: "application/octet-stream"
+ ).let { notification ->
+ notificationUtils.showNotificationMessage("DL", uri.hashCode(), notification)
}
}
+ // TODO add notification?
} else {
@Suppress("DEPRECATION")
Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE).also { mediaScanIntent ->
mediaScanIntent.data = Uri.fromFile(file)
context.sendBroadcast(mediaScanIntent)
}
- return true
}
- return false
}
/**
diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt
index 354591b618..c664e7e62c 100644
--- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt
+++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailAction.kt
@@ -18,8 +18,8 @@ package im.vector.riotx.features.home.room.detail
import im.vector.matrix.android.api.session.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event
-import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageStickerContent
+import im.vector.matrix.android.api.session.room.model.message.MessageWithAttachmentContent
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.platform.VectorViewModelAction
@@ -39,7 +39,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailAction()
data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction()
object MarkAllAsRead : RoomDetailAction()
- data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailAction()
+ data class DownloadOrOpen(val eventId: String, val messageFileContent: MessageWithAttachmentContent) : RoomDetailAction()
data class HandleTombstoneEvent(val event: Event) : RoomDetailAction()
object AcceptInvite : RoomDetailAction()
object RejectInvite : RoomDetailAction()
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 a450e10be6..2d7a26efb7 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
@@ -68,15 +68,14 @@ import im.vector.matrix.android.api.session.events.model.Event
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.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
-import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageFormat
import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent
import im.vector.matrix.android.api.session.room.model.message.MessageStickerContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageVerificationRequestContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
+import im.vector.matrix.android.api.session.room.model.message.MessageWithAttachmentContent
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.Timeline
@@ -98,7 +97,6 @@ import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.core.extensions.showKeyboard
import im.vector.riotx.core.extensions.trackItemsVisibilityChange
-import im.vector.riotx.core.files.addEntryToDownloadManager
import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.intent.getMimeTypeFromUri
import im.vector.riotx.core.platform.VectorBaseFragment
@@ -112,7 +110,6 @@ import im.vector.riotx.core.utils.KeyboardStateUtils
import im.vector.riotx.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL
import im.vector.riotx.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL
import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
-import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_DOWNLOAD_FILE
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_INCOMING_URI
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_PICK_ATTACHMENT
import im.vector.riotx.core.utils.TextUtils
@@ -167,6 +164,7 @@ import im.vector.riotx.features.invite.VectorInviteView
import im.vector.riotx.features.media.ImageContentRenderer
import im.vector.riotx.features.media.VideoContentRenderer
import im.vector.riotx.features.notifications.NotificationDrawerManager
+import im.vector.riotx.features.notifications.NotificationUtils
import im.vector.riotx.features.permalink.NavigationInterceptor
import im.vector.riotx.features.permalink.PermalinkHandler
import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
@@ -209,6 +207,7 @@ class RoomDetailFragment @Inject constructor(
private val eventHtmlRenderer: EventHtmlRenderer,
private val vectorPreferences: VectorPreferences,
private val colorProvider: ColorProvider,
+ private val notificationUtils: NotificationUtils,
private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager) :
VectorBaseFragment(),
TimelineEventController.Callback,
@@ -342,11 +341,12 @@ class RoomDetailFragment @Inject constructor(
is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it)
is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it)
- is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode)
+ is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode)
RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager()
is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it)
is RoomDetailViewEvents.DisplayEnableIntegrationsWarning -> displayDisabledIntegrationDialog()
is RoomDetailViewEvents.OpenIntegrationManager -> openIntegrationManager()
+ is RoomDetailViewEvents.OpenFile -> startOpenFileIntent(it)
}.exhaustive
}
}
@@ -368,6 +368,21 @@ class RoomDetailFragment @Inject constructor(
navigator.openStickerPicker(this, roomDetailArgs.roomId, event.widget)
}
+ private fun startOpenFileIntent(action: RoomDetailViewEvents.OpenFile) {
+ if (action.uri != null) {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndTypeAndNormalize(action.uri, action.mimeType)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ if (intent.resolveActivity(requireActivity().packageManager) != null) {
+ requireActivity().startActivity(intent)
+ } else {
+ requireActivity().toast(R.string.error_no_external_application_found)
+ }
+ }
+ }
+
private fun displayPromptForIntegrationManager() {
// The Sticker picker widget is not installed yet. Propose the user to install it
val builder = AlertDialog.Builder(requireContext())
@@ -485,10 +500,22 @@ class RoomDetailFragment @Inject constructor(
val activity = requireActivity()
if (action.throwable != null) {
activity.toast(errorFormatter.toHumanReadable(action.throwable))
- } else if (action.file != null) {
- activity.toast(getString(R.string.downloaded_file, action.file.path))
- addEntryToDownloadManager(activity, action.file, action.mimeType)
}
+// else if (action.file != null) {
+// addEntryToDownloadManager(activity, action.file, action.mimeType ?: "application/octet-stream")?.let {
+// // This is a temporary solution to help users find downloaded files
+// // there is a better way to do that
+// // On android Q+ this method returns the file URI, on older
+// // it returns null, and the download manager handles the notification
+// notificationUtils.buildDownloadFileNotification(
+// it,
+// action.file.name ?: "file",
+// action.mimeType ?: "application/octet-stream"
+// ).let { notification ->
+// notificationUtils.showNotificationMessage("DL", action.file.absolutePath.hashCode(), notification)
+// }
+// }
+// }
}
private fun setupNotificationView() {
@@ -524,24 +551,24 @@ class RoomDetailFragment @Inject constructor(
}
R.id.voice_call,
R.id.video_call -> {
- val activeCall = sharedCallActionViewModel.activeCall.value
- val isVideoCall = item.itemId == R.id.video_call
- if (activeCall != null) {
- // resume existing if same room, if not prompt to kill and then restart new call?
- if (activeCall.roomId == roomDetailArgs.roomId) {
- onTapToReturnToCall()
- }
+ val activeCall = sharedCallActionViewModel.activeCall.value
+ val isVideoCall = item.itemId == R.id.video_call
+ if (activeCall != null) {
+ // resume existing if same room, if not prompt to kill and then restart new call?
+ if (activeCall.roomId == roomDetailArgs.roomId) {
+ onTapToReturnToCall()
+ }
// else {
- // TODO might not work well, and should prompt
+ // TODO might not work well, and should prompt
// webRtcPeerConnectionManager.endCall()
// safeStartCall(it, isVideoCall)
// }
- } else {
- safeStartCall(isVideoCall)
- }
+ } else {
+ safeStartCall(isVideoCall)
+ }
true
}
- R.id.hangup_call -> {
+ R.id.hangup_call -> {
roomDetailViewModel.handle(RoomDetailAction.EndCall)
true
}
@@ -667,6 +694,8 @@ class RoomDetailFragment @Inject constructor(
}
}
}
+ // TODO why don't we call super here?
+ // super.onActivityResult(requestCode, resultCode, data)
}
// PRIVATE METHODS *****************************************************************************
@@ -1150,31 +1179,32 @@ class RoomDetailFragment @Inject constructor(
navigator.openVideoViewer(requireActivity(), mediaData)
}
- override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) {
- val action = RoomDetailAction.DownloadFile(eventId, messageFileContent)
- // We need WRITE_EXTERNAL permission
- if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) {
- showSnackWithMessage(getString(R.string.downloading_file, messageFileContent.getFileName()))
- roomDetailViewModel.handle(action)
- } else {
- roomDetailViewModel.pendingAction = action
- }
- }
+// override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) {
+// val isEncrypted = messageFileContent.encryptedFileInfo != null
+// val action = RoomDetailAction.DownloadOrOpen(eventId, messageFileContent, isEncrypted)
+// // We need WRITE_EXTERNAL permission
+// // if (!isEncrypted || checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) {
+// showSnackWithMessage(getString(R.string.downloading_file, messageFileContent.getFileName()))
+// roomDetailViewModel.handle(action)
+// // } else {
+// // roomDetailViewModel.pendingAction = action
+// // }
+// }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) {
if (allGranted(grantResults)) {
when (requestCode) {
- PERMISSION_REQUEST_CODE_DOWNLOAD_FILE -> {
- val action = roomDetailViewModel.pendingAction
- if (action != null) {
- (action as? RoomDetailAction.DownloadFile)
- ?.messageFileContent
- ?.getFileName()
- ?.let { showSnackWithMessage(getString(R.string.downloading_file, it)) }
- roomDetailViewModel.pendingAction = null
- roomDetailViewModel.handle(action)
- }
- }
+// PERMISSION_REQUEST_CODE_DOWNLOAD_FILE -> {
+// val action = roomDetailViewModel.pendingAction
+// if (action != null) {
+// (action as? RoomDetailAction.DownloadFile)
+// ?.messageFileContent
+// ?.getFileName()
+// ?.let { showSnackWithMessage(getString(R.string.downloading_file, it)) }
+// roomDetailViewModel.pendingAction = null
+// roomDetailViewModel.handle(action)
+// }
+// }
PERMISSION_REQUEST_CODE_INCOMING_URI -> {
val pendingUri = roomDetailViewModel.pendingUri
if (pendingUri != null) {
@@ -1214,9 +1244,9 @@ class RoomDetailFragment @Inject constructor(
}
}
- override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) {
- vectorBaseActivity.notImplemented("open audio file")
- }
+// override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) {
+// vectorBaseActivity.notImplemented("open audio file")
+// }
override fun onLoadMore(direction: Timeline.Direction) {
roomDetailViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction))
@@ -1227,6 +1257,10 @@ class RoomDetailFragment @Inject constructor(
is MessageVerificationRequestContent -> {
roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null))
}
+ is MessageWithAttachmentContent -> {
+ val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, messageContent)
+ roomDetailViewModel.handle(action)
+ }
is EncryptedEventContent -> {
roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId))
}
@@ -1305,11 +1339,12 @@ class RoomDetailFragment @Inject constructor(
}
private fun onShareActionClicked(action: EventSharedAction.Share) {
- session.downloadFile(
+ session.fileService().downloadFile(
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
action.eventId,
action.messageContent.body,
action.messageContent.getFileUrl(),
+ action.messageContent.mimeType,
action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
object : MatrixCallback {
override fun onSuccess(data: File) {
@@ -1322,26 +1357,23 @@ class RoomDetailFragment @Inject constructor(
}
private fun onSaveActionClicked(action: EventSharedAction.Save) {
- session.downloadFile(
- FileService.DownloadMode.FOR_EXTERNAL_SHARE,
- action.eventId,
- action.messageContent.body,
- action.messageContent.getFileUrl(),
- action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
- object : MatrixCallback {
+ session.fileService().downloadFile(
+ downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
+ id = action.eventId,
+ fileName = action.messageContent.body,
+ mimeType = action.messageContent.mimeType,
+ url = action.messageContent.getFileUrl(),
+ elementToDecrypt = action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
+ callback = object : MatrixCallback {
override fun onSuccess(data: File) {
if (isAdded) {
- val saved = saveMedia(
+ saveMedia(
context = requireContext(),
file = data,
title = action.messageContent.body,
- mediaMimeType = getMimeTypeFromUri(requireContext(), data.toUri())
+ mediaMimeType = action.messageContent.mimeType ?: getMimeTypeFromUri(requireContext(), data.toUri()),
+ notificationUtils = notificationUtils
)
- if (saved) {
- Toast.makeText(requireContext(), R.string.media_file_added_to_gallery, Toast.LENGTH_LONG).show()
- } else {
- Toast.makeText(requireContext(), R.string.error_adding_media_file_to_gallery, Toast.LENGTH_LONG).show()
- }
}
}
}
diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewEvents.kt
index 6ed5373b58..b4c1f751bc 100644
--- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewEvents.kt
+++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/RoomDetailViewEvents.kt
@@ -16,6 +16,7 @@
package im.vector.riotx.features.home.room.detail
+import android.net.Uri
import androidx.annotation.StringRes
import im.vector.matrix.android.api.session.widgets.model.Widget
import im.vector.matrix.android.internal.crypto.model.event.WithHeldCode
@@ -45,11 +46,17 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
) : RoomDetailViewEvents()
data class DownloadFileState(
- val mimeType: String,
+ val mimeType: String?,
val file: File?,
val throwable: Throwable?
) : RoomDetailViewEvents()
+ data class OpenFile(
+ val mimeType: String?,
+ val uri: Uri?,
+ val throwable: Throwable?
+ ) : RoomDetailViewEvents()
+
abstract class SendMessageResult : RoomDetailViewEvents()
object DisplayPromptForIntegrationManager: RoomDetailViewEvents()
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 9a79ea6a0a..62830a1c63 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
@@ -48,6 +48,7 @@ import im.vector.matrix.android.api.session.room.model.RoomSummary
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.OptionItem
+import im.vector.matrix.android.api.session.room.model.message.getFileName
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.powerlevels.PowerLevelsHelper
@@ -243,7 +244,7 @@ class RoomDetailViewModel @AssistedInject constructor(
is RoomDetailAction.EnterEditMode -> handleEditAction(action)
is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailAction.EnterReplyMode -> handleReplyAction(action)
- is RoomDetailAction.DownloadFile -> handleDownloadFile(action)
+ is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action)
is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action)
is RoomDetailAction.ResendMessage -> handleResendEvent(action)
@@ -858,30 +859,44 @@ class RoomDetailViewModel @AssistedInject constructor(
}
}
- private fun handleDownloadFile(action: RoomDetailAction.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) {
- _viewEvents.post(RoomDetailViewEvents.DownloadFileState(
- action.messageFileContent.getMimeType(),
- data,
- null
- ))
- }
+ private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) {
+ val mxcUrl = action.messageFileContent.getFileUrl()
+ val isDownloaded = mxcUrl?.let { session.fileService().isFileInCache(it, action.messageFileContent.mimeType) } ?: false
+ if (isDownloaded) {
+ // we can open it
+ session.fileService().getTemporarySharableURI(mxcUrl!!, action.messageFileContent.mimeType)?.let { uri ->
+ _viewEvents.post(RoomDetailViewEvents.OpenFile(
+ action.messageFileContent.mimeType,
+ uri,
+ null
+ ))
+ }
+ } else {
+ session.fileService().downloadFile(
+ FileService.DownloadMode.FOR_INTERNAL_USE,
+ action.eventId,
+ action.messageFileContent.getFileName(),
+ action.messageFileContent.mimeType,
+ mxcUrl,
+ action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(),
+ object : MatrixCallback {
+ override fun onSuccess(data: File) {
+ _viewEvents.post(RoomDetailViewEvents.DownloadFileState(
+ action.messageFileContent.mimeType,
+ data,
+ null
+ ))
+ }
- override fun onFailure(failure: Throwable) {
- _viewEvents.post(RoomDetailViewEvents.DownloadFileState(
- action.messageFileContent.getMimeType(),
- null,
- failure
- ))
- }
- })
+ override fun onFailure(failure: Throwable) {
+ _viewEvents.post(RoomDetailViewEvents.DownloadFileState(
+ action.messageFileContent.mimeType,
+ null,
+ failure
+ ))
+ }
+ })
+ }
}
private fun handleNavigateToEvent(action: RoomDetailAction.NavigateToEvent) {
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 095340caee..9815bac275 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
@@ -25,8 +25,6 @@ import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyController
import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.VisibilityState
-import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
-import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.timeline.Timeline
@@ -40,6 +38,7 @@ import im.vector.riotx.features.home.room.detail.RoomDetailViewState
import im.vector.riotx.features.home.room.detail.UnreadState
import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory
import im.vector.riotx.features.home.room.detail.timeline.factory.TimelineItemFactory
+import im.vector.riotx.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.ReadMarkerVisibilityStateChangedListener
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback
@@ -59,6 +58,7 @@ import javax.inject.Inject
class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter,
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
+ private val contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder,
private val timelineItemFactory: TimelineItemFactory,
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
private val mergedHeaderItemFactory: MergedHeaderItemFactory,
@@ -74,8 +74,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View)
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
- fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent)
- fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
+// fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent)
+// fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
fun onEditedDecorationClicked(informationData: MessageInformationData)
// TODO move all callbacks to this?
@@ -226,6 +226,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
timelineMediaSizeProvider.recyclerView = null
contentUploadStateTrackerBinder.clear()
+ contentDownloadStateTrackerBinder.clear()
timeline?.removeListener(this)
super.onDetachedFromRecyclerView(recyclerView)
}
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 96abe1ff40..2174556098 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
@@ -57,6 +57,7 @@ import im.vector.riotx.core.utils.containsOnlyEmojis
import im.vector.riotx.core.utils.isLocalFile
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider
+import im.vector.riotx.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
@@ -99,6 +100,7 @@ class MessageItemFactory @Inject constructor(
private val messageInformationDataFactory: MessageInformationDataFactory,
private val messageItemAttributesFactory: MessageItemAttributesFactory,
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
+ private val contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder,
private val defaultItemFactory: DefaultItemFactory,
private val noticeItemFactory: NoticeItemFactory,
private val avatarSizeProvider: AvatarSizeProvider,
@@ -140,8 +142,8 @@ class MessageItemFactory @Inject constructor(
is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
- is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback, attributes)
- is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback, attributes)
+ is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
+ is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes)
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, callback)
@@ -184,20 +186,17 @@ class MessageItemFactory @Inject constructor(
@Suppress("UNUSED_PARAMETER")
informationData: MessageInformationData,
highlight: Boolean,
- callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageFileItem? {
return MessageFileItem_()
.attributes(attributes)
.izLocalFile(messageContent.getFileUrl().isLocalFile())
+ .mxcUrl(messageContent.getFileUrl() ?: "")
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
+ .contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
.filename(messageContent.body)
- .iconRes(R.drawable.filetype_audio)
- .clickListener(
- DebouncedClickListener(View.OnClickListener {
- callback?.onAudioMessageClicked(messageContent)
- }))
+ .iconRes(R.drawable.ic_headphones)
}
private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent,
@@ -232,35 +231,27 @@ class MessageItemFactory @Inject constructor(
)
)
.callback(callback)
-// .izLocalFile(messageContent.getFileUrl().isLocalFile())
-// .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline)
-// .filename(messageContent.body)
-// .iconRes(R.drawable.filetype_audio)
-// .clickListener(
-// DebouncedClickListener(View.OnClickListener {
-// callback?.onAudioMessageClicked(messageContent)
-// }))
}
private fun buildFileMessageItem(messageContent: MessageFileContent,
- informationData: MessageInformationData,
+// informationData: MessageInformationData,
highlight: Boolean,
- callback: TimelineEventController.Callback?,
+// callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageFileItem? {
+ val mxcUrl = messageContent.getFileUrl() ?: ""
return MessageFileItem_()
.attributes(attributes)
.leftGuideline(avatarSizeProvider.leftGuideline)
.izLocalFile(messageContent.getFileUrl().isLocalFile())
+ .izDownloaded(session.fileService().isFileInCache(mxcUrl, messageContent.mimeType))
+ .mxcUrl(mxcUrl)
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
+ .contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.highlighted(highlight)
.filename(messageContent.body)
- .iconRes(R.drawable.filetype_attachment)
- .clickListener(
- DebouncedClickListener(View.OnClickListener {
- callback?.onFileMessageClicked(informationData.eventId, messageContent)
- }))
+ .iconRes(R.drawable.ic_paperclip)
}
private fun buildNotHandledMessageItem(messageContent: MessageContent,
@@ -282,6 +273,7 @@ class MessageItemFactory @Inject constructor(
val data = ImageContentRenderer.Data(
eventId = informationData.eventId,
filename = messageContent.body,
+ mimeType = messageContent.mimeType,
url = messageContent.getFileUrl(),
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
height = messageContent.info?.height,
@@ -318,6 +310,7 @@ class MessageItemFactory @Inject constructor(
val thumbnailData = ImageContentRenderer.Data(
eventId = informationData.eventId,
filename = messageContent.body,
+ mimeType = messageContent.mimeType,
url = messageContent.videoInfo?.thumbnailFile?.url
?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
@@ -330,6 +323,7 @@ class MessageItemFactory @Inject constructor(
val videoData = VideoContentRenderer.Data(
eventId = informationData.eventId,
filename = messageContent.body,
+ mimeType = messageContent.mimeType,
url = messageContent.getFileUrl(),
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
thumbnailMediaData = thumbnailData
diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentDownloadStateTrackerBinder.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentDownloadStateTrackerBinder.kt
new file mode 100644
index 0000000000..5c63de3e8d
--- /dev/null
+++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/helper/ContentDownloadStateTrackerBinder.kt
@@ -0,0 +1,130 @@
+/*
+ * 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.riotx.features.home.room.detail.timeline.helper
+
+import android.graphics.drawable.Drawable
+import androidx.vectordrawable.graphics.drawable.Animatable2Compat
+import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
+import im.vector.matrix.android.api.session.file.ContentDownloadStateTracker
+import im.vector.riotx.R
+import im.vector.riotx.core.di.ActiveSessionHolder
+import im.vector.riotx.core.di.ScreenScope
+import im.vector.riotx.core.error.ErrorFormatter
+import im.vector.riotx.features.home.room.detail.timeline.MessageColorProvider
+import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem
+import javax.inject.Inject
+
+@ScreenScope
+class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
+ private val messageColorProvider: MessageColorProvider,
+ private val errorFormatter: ErrorFormatter) {
+
+ private val updateListeners = mutableMapOf()
+
+ fun bind(mxcUrl: String,
+ holder: MessageFileItem.Holder) {
+ activeSessionHolder.getSafeActiveSession()?.also { session ->
+ val downloadStateTracker = session.contentDownloadProgressTracker()
+ val updateListener = ContentDownloadUpdater(holder, messageColorProvider, errorFormatter)
+ updateListeners[mxcUrl] = updateListener
+ downloadStateTracker.track(mxcUrl, updateListener)
+ }
+ }
+
+ fun unbind(mxcUrl: String) {
+ activeSessionHolder.getSafeActiveSession()?.also { session ->
+ val downloadStateTracker = session.contentDownloadProgressTracker()
+ updateListeners[mxcUrl]?.also {
+ it.stop()
+ downloadStateTracker.unTrack(mxcUrl, it)
+ }
+ }
+ }
+
+ fun clear() {
+ activeSessionHolder.getSafeActiveSession()?.also {
+ it.contentDownloadProgressTracker().clear()
+ }
+ }
+}
+
+private class ContentDownloadUpdater(private val holder: MessageFileItem.Holder,
+ private val messageColorProvider: MessageColorProvider,
+ private val errorFormatter: ErrorFormatter) : ContentDownloadStateTracker.UpdateListener {
+
+ override fun onDownloadStateUpdate(state: ContentDownloadStateTracker.State) {
+ when (state) {
+ ContentDownloadStateTracker.State.Idle -> handleIdle()
+ is ContentDownloadStateTracker.State.Downloading -> handleProgress(state)
+ ContentDownloadStateTracker.State.Decrypting -> handleDecrypting()
+ ContentDownloadStateTracker.State.Success -> handleSuccess()
+ is ContentDownloadStateTracker.State.Failure -> handleFailure()
+ }
+ }
+
+ private var animatedDrawable: AnimatedVectorDrawableCompat? = null
+ private var animationLoopCallback = object : Animatable2Compat.AnimationCallback() {
+ override fun onAnimationEnd(drawable: Drawable?) {
+ animatedDrawable?.start()
+ }
+ }
+
+ fun stop() {
+ animatedDrawable?.unregisterAnimationCallback(animationLoopCallback)
+ animatedDrawable?.stop()
+ animatedDrawable = null
+ }
+
+ private fun handleIdle() {
+ holder.fileDownloadProgress.progress = 0
+ holder.fileDownloadProgress.isIndeterminate = false
+ }
+
+ private fun handleDecrypting() {
+ holder.fileDownloadProgress.isIndeterminate = true
+ }
+
+ private fun handleProgress(state: ContentDownloadStateTracker.State.Downloading) {
+ doHandleProgress(state.current, state.total)
+ }
+
+ private fun doHandleProgress(current: Long, total: Long) {
+ val percent = 100L * (current.toFloat() / total.toFloat())
+ holder.fileDownloadProgress.isIndeterminate = false
+ holder.fileDownloadProgress.progress = percent.toInt()
+ if (animatedDrawable == null) {
+ animatedDrawable = AnimatedVectorDrawableCompat.create(holder.view.context, R.drawable.ic_download_anim)
+ holder.fileImageView.setImageDrawable(animatedDrawable)
+ animatedDrawable?.start()
+ animatedDrawable?.registerAnimationCallback(animationLoopCallback)
+ }
+ }
+
+ private fun handleFailure() {
+ stop()
+ holder.fileDownloadProgress.isIndeterminate = false
+ holder.fileDownloadProgress.progress = 0
+ holder.fileImageView.setImageResource(R.drawable.ic_close_round)
+ }
+
+ private fun handleSuccess() {
+ stop()
+ holder.fileDownloadProgress.isIndeterminate = false
+ holder.fileDownloadProgress.progress = 100
+ holder.fileImageView.setImageResource(R.drawable.ic_paperclip)
+ }
+}
diff --git a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt
index ea52df2bfb..1d7f4c634b 100644
--- a/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt
+++ b/vector/src/main/java/im/vector/riotx/features/home/room/detail/timeline/item/MessageFileItem.kt
@@ -17,15 +17,16 @@
package im.vector.riotx.features.home.room.detail.timeline.item
import android.graphics.Paint
-import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
+import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
+import im.vector.riotx.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
@@ -33,33 +34,64 @@ abstract class MessageFileItem : AbsMessageItem() {
@EpoxyAttribute
var filename: CharSequence = ""
+
+ @EpoxyAttribute
+ var mxcUrl: String = ""
+
@EpoxyAttribute
@DrawableRes
var iconRes: Int = 0
- @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
- var clickListener: View.OnClickListener? = null
+
+// @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
+// var clickListener: View.OnClickListener? = null
+
@EpoxyAttribute
var izLocalFile = false
+
+ @EpoxyAttribute
+ var izDownloaded = false
+
@EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
+ @EpoxyAttribute
+ lateinit var contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder
+
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.fileLayout, holder.filenameView)
if (!attributes.informationData.sendState.hasFailed()) {
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout)
} else {
+ holder.fileImageView.setImageResource(R.drawable.ic_cross)
holder.progressLayout.isVisible = false
}
holder.filenameView.text = filename
- holder.fileImageView.setImageResource(iconRes)
- holder.filenameView.setOnClickListener(clickListener)
+ if (attributes.informationData.sendState.isSending()) {
+ holder.fileImageView.setImageResource(iconRes)
+ } else {
+ if (izDownloaded) {
+ holder.fileImageView.setImageResource(iconRes)
+ holder.fileDownloadProgress.progress = 100
+ } else {
+ contentDownloadStateTrackerBinder.bind(mxcUrl, holder)
+ holder.fileImageView.setImageResource(R.drawable.ic_download)
+ holder.fileDownloadProgress.progress = 0
+ }
+ }
+// holder.view.setOnClickListener(clickListener)
+
+ holder.filenameView.setOnClickListener(attributes.itemClickListener)
+ holder.filenameView.setOnLongClickListener(attributes.itemLongClickListener)
+ holder.fileImageWrapper.setOnClickListener(attributes.itemClickListener)
+ holder.fileImageWrapper.setOnLongClickListener(attributes.itemLongClickListener)
holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG)
}
override fun unbind(holder: Holder) {
super.unbind(holder)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
+ contentDownloadStateTrackerBinder.unbind(mxcUrl)
}
override fun getViewType() = STUB_ID
@@ -67,7 +99,9 @@ abstract class MessageFileItem : AbsMessageItem() {
class Holder : AbsMessageItem.Holder(STUB_ID) {
val progressLayout by bind(R.id.messageFileUploadProgressLayout)
val fileLayout by bind(R.id.messageFileLayout)
- val fileImageView by bind(R.id.messageFileImageView)
+ val fileImageView by bind(R.id.messageFileIconView)
+ val fileImageWrapper by bind(R.id.messageFileImageView)
+ val fileDownloadProgress by bind(R.id.messageFileProgressbar)
val filenameView by bind(R.id.messageFilenameView)
}
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 ab047fba0d..eeeb55ed15 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
@@ -49,6 +49,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
data class Data(
val eventId: String,
val filename: String,
+ val mimeType: String?,
val url: String?,
val elementToDecrypt: ElementToDecrypt?,
val height: Int?,
diff --git a/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt b/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
index ca6510a897..2be940d0c1 100644
--- a/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
+++ b/vector/src/main/java/im/vector/riotx/features/media/ImageMediaViewerActivity.kt
@@ -133,10 +133,11 @@ class ImageMediaViewerActivity : VectorBaseActivity() {
}
private fun onShareActionClicked() {
- session.downloadFile(
+ session.fileService().downloadFile(
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
mediaData.eventId,
mediaData.filename,
+ mediaData.mimeType,
mediaData.url,
mediaData.elementToDecrypt,
object : MatrixCallback {
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 833f795ecb..eb9105f792 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
@@ -40,6 +40,7 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
data class Data(
val eventId: String,
val filename: String,
+ val mimeType: String?,
val url: String?,
val elementToDecrypt: ElementToDecrypt?,
val thumbnailMediaData: ImageContentRenderer.Data
@@ -64,14 +65,15 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
thumbnailView.isVisible = true
loadingView.isVisible = true
- activeSessionHolder.getActiveSession()
+ activeSessionHolder.getActiveSession().fileService()
.downloadFile(
- FileService.DownloadMode.FOR_INTERNAL_USE,
- data.eventId,
- data.filename,
- data.url,
- data.elementToDecrypt,
- object : MatrixCallback {
+ downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
+ id = data.eventId,
+ fileName = data.filename,
+ mimeType = null,
+ url = data.url,
+ elementToDecrypt = data.elementToDecrypt,
+ callback = object : MatrixCallback {
override fun onSuccess(data: File) {
thumbnailView.isVisible = false
loadingView.isVisible = false
@@ -102,14 +104,15 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
thumbnailView.isVisible = true
loadingView.isVisible = true
- activeSessionHolder.getActiveSession()
+ activeSessionHolder.getActiveSession().fileService()
.downloadFile(
- FileService.DownloadMode.FOR_INTERNAL_USE,
- data.eventId,
- data.filename,
- data.url,
- null,
- object : MatrixCallback {
+ downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
+ id = data.eventId,
+ fileName = data.filename,
+ mimeType = data.mimeType,
+ url = data.url,
+ elementToDecrypt = null,
+ callback = object : MatrixCallback {
override fun onSuccess(data: File) {
thumbnailView.isVisible = false
loadingView.isVisible = false
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 6985278ad0..6ef8927f00 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
@@ -78,10 +78,11 @@ class VideoMediaViewerActivity : VectorBaseActivity() {
}
private fun onShareActionClicked() {
- session.downloadFile(
+ session.fileService().downloadFile(
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
mediaData.eventId,
mediaData.filename,
+ mediaData.mimeType,
mediaData.url,
mediaData.elementToDecrypt,
object : MatrixCallback {
diff --git a/vector/src/main/java/im/vector/riotx/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/riotx/features/notifications/NotificationUtils.kt
index 9dc518bbc9..d7dabd0778 100755
--- a/vector/src/main/java/im/vector/riotx/features/notifications/NotificationUtils.kt
+++ b/vector/src/main/java/im/vector/riotx/features/notifications/NotificationUtils.kt
@@ -480,6 +480,26 @@ class NotificationUtils @Inject constructor(private val context: Context,
.build()
}
+ fun buildDownloadFileNotification(uri: Uri, fileName: String, mimeType: String): Notification {
+ return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
+ .setGroup(stringProvider.getString(R.string.app_name))
+ .setSmallIcon(R.drawable.ic_download)
+ .setContentText(stringProvider.getString(R.string.downloaded_file, fileName))
+ .setAutoCancel(true)
+ .apply {
+ val intent = Intent(Intent.ACTION_VIEW).apply {
+ setDataAndType(uri, mimeType)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ }
+ PendingIntent.getActivity(
+ context, System.currentTimeMillis().toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT
+ ).let {
+ setContentIntent(it)
+ }
+ }
+ .build()
+ }
+
/**
* Build a notification for a Room
*/
diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsFragment.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsFragment.kt
index 99aeb4231b..47dab994b4 100644
--- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsFragment.kt
+++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsFragment.kt
@@ -22,7 +22,6 @@ import androidx.core.net.toUri
import com.airbnb.mvrx.args
import com.airbnb.mvrx.fragmentViewModel
import com.airbnb.mvrx.withState
-import com.google.android.material.snackbar.Snackbar
import com.google.android.material.tabs.TabLayoutMediator
import im.vector.matrix.android.api.util.toMatrixItem
import im.vector.riotx.R
@@ -33,6 +32,7 @@ import im.vector.riotx.core.resources.StringProvider
import im.vector.riotx.core.utils.saveMedia
import im.vector.riotx.core.utils.shareMedia
import im.vector.riotx.features.home.AvatarRenderer
+import im.vector.riotx.features.notifications.NotificationUtils
import im.vector.riotx.features.roomprofile.RoomProfileArgs
import kotlinx.android.synthetic.main.fragment_room_uploads.*
import javax.inject.Inject
@@ -40,7 +40,8 @@ import javax.inject.Inject
class RoomUploadsFragment @Inject constructor(
private val viewModelFactory: RoomUploadsViewModel.Factory,
private val stringProvider: StringProvider,
- private val avatarRenderer: AvatarRenderer
+ private val avatarRenderer: AvatarRenderer,
+ private val notificationUtils: NotificationUtils
) : VectorBaseFragment(), RoomUploadsViewModel.Factory by viewModelFactory {
private val roomProfileArgs: RoomProfileArgs by args()
@@ -70,17 +71,13 @@ class RoomUploadsFragment @Inject constructor(
shareMedia(requireContext(), it.file, getMimeTypeFromUri(requireContext(), it.file.toUri()))
}
is RoomUploadsViewEvents.FileReadyForSaving -> {
- val saved = saveMedia(
+ saveMedia(
context = requireContext(),
file = it.file,
title = it.title,
- mediaMimeType = getMimeTypeFromUri(requireContext(), it.file.toUri())
+ mediaMimeType = getMimeTypeFromUri(requireContext(), it.file.toUri()),
+ notificationUtils = notificationUtils
)
- if (saved) {
- Snackbar.make(roomUploadsCoordinator, R.string.media_file_added_to_gallery, Snackbar.LENGTH_LONG).show()
- } else {
- Snackbar.make(roomUploadsCoordinator, R.string.error_adding_media_file_to_gallery, Snackbar.LENGTH_LONG).show()
- }
}
is RoomUploadsViewEvents.Failure -> showFailure(it.throwable)
}.exhaustive
diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt
index 952e80c035..10f0a5051e 100644
--- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt
+++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/RoomUploadsViewModel.kt
@@ -136,13 +136,14 @@ class RoomUploadsViewModel @AssistedInject constructor(
viewModelScope.launch {
try {
val file = awaitCallback {
- session.downloadFile(
- FileService.DownloadMode.FOR_EXTERNAL_SHARE,
- action.uploadEvent.eventId,
- action.uploadEvent.contentWithAttachmentContent.body,
- action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
- action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
- it
+ session.fileService().downloadFile(
+ downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
+ id = action.uploadEvent.eventId,
+ fileName = action.uploadEvent.contentWithAttachmentContent.body,
+ url = action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
+ mimeType = action.uploadEvent.contentWithAttachmentContent.mimeType,
+ elementToDecrypt = action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
+ callback = it
)
}
_viewEvents.post(RoomUploadsViewEvents.FileReadyForSharing(file))
@@ -156,11 +157,12 @@ class RoomUploadsViewModel @AssistedInject constructor(
viewModelScope.launch {
try {
val file = awaitCallback {
- session.downloadFile(
+ session.fileService().downloadFile(
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
action.uploadEvent.eventId,
action.uploadEvent.contentWithAttachmentContent.body,
action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
+ action.uploadEvent.contentWithAttachmentContent.mimeType,
action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
it)
}
diff --git a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsMediaController.kt b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsMediaController.kt
index cd3e401dc5..72e4cb6d06 100644
--- a/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsMediaController.kt
+++ b/vector/src/main/java/im/vector/riotx/features/roomprofile/uploads/media/UploadsMediaController.kt
@@ -115,6 +115,7 @@ class UploadsMediaController @Inject constructor(
eventId = eventId,
filename = messageContent.body,
url = messageContent.getFileUrl(),
+ mimeType = messageContent.mimeType,
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
height = messageContent.info?.height,
maxHeight = itemSize,
@@ -129,6 +130,7 @@ class UploadsMediaController @Inject constructor(
val thumbnailData = ImageContentRenderer.Data(
eventId = eventId,
filename = messageContent.body,
+ mimeType = messageContent.mimeType,
url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height,
@@ -140,6 +142,7 @@ class UploadsMediaController @Inject constructor(
return VideoContentRenderer.Data(
eventId = eventId,
filename = messageContent.body,
+ mimeType = messageContent.mimeType,
url = messageContent.getFileUrl(),
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
thumbnailMediaData = thumbnailData
diff --git a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt
index 17739c2503..18fa9d95ed 100644
--- a/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt
+++ b/vector/src/main/java/im/vector/riotx/features/settings/VectorSettingsGeneralFragment.kt
@@ -237,7 +237,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
// clear medias cache
findPreference(VectorPreferences.SETTINGS_CLEAR_MEDIA_CACHE_PREFERENCE_KEY)!!.let {
- val size = getSizeOfFiles(File(requireContext().cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR))
+ val size = getSizeOfFiles(File(requireContext().cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR)) + session.fileService().getCacheSize()
it.summary = TextUtils.formatFileSize(requireContext(), size.toLong())
@@ -247,6 +247,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
displayLoadingView()
Glide.get(requireContext()).clearMemory()
+ session.fileService().clearCache()
var newSize = 0
@@ -255,6 +256,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
Glide.get(requireContext()).clearDiskCache()
newSize = getSizeOfFiles(File(requireContext().cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR))
+ newSize += session.fileService().getCacheSize()
}
it.summary = TextUtils.formatFileSize(requireContext(), newSize.toLong())
diff --git a/vector/src/main/res/drawable/file_progress_bar.xml b/vector/src/main/res/drawable/file_progress_bar.xml
new file mode 100644
index 0000000000..4c96aaebf6
--- /dev/null
+++ b/vector/src/main/res/drawable/file_progress_bar.xml
@@ -0,0 +1,19 @@
+
+
+
+ -
+
+
+
+
+
+
+ -
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vector/src/main/res/drawable/ic_cross.xml b/vector/src/main/res/drawable/ic_cross.xml
new file mode 100644
index 0000000000..7703468867
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_cross.xml
@@ -0,0 +1,20 @@
+
+
+
+
diff --git a/vector/src/main/res/drawable/ic_download_anim.xml b/vector/src/main/res/drawable/ic_download_anim.xml
new file mode 100644
index 0000000000..5d1b80a16f
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_download_anim.xml
@@ -0,0 +1,102 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/vector/src/main/res/drawable/ic_headphones.xml b/vector/src/main/res/drawable/ic_headphones.xml
new file mode 100644
index 0000000000..86f3d8ab7f
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_headphones.xml
@@ -0,0 +1,16 @@
+
+
+
+
diff --git a/vector/src/main/res/drawable/ic_paperclip.xml b/vector/src/main/res/drawable/ic_paperclip.xml
new file mode 100644
index 0000000000..57405db7a5
--- /dev/null
+++ b/vector/src/main/res/drawable/ic_paperclip.xml
@@ -0,0 +1,13 @@
+
+
+
diff --git a/vector/src/main/res/layout/item_timeline_event_file_stub.xml b/vector/src/main/res/layout/item_timeline_event_file_stub.xml
index 1c185ca973..a15de8bd34 100644
--- a/vector/src/main/res/layout/item_timeline_event_file_stub.xml
+++ b/vector/src/main/res/layout/item_timeline_event_file_stub.xml
@@ -12,22 +12,33 @@
android:id="@+id/messageFilee2eIcon"
android:layout_width="14dp"
android:layout_height="14dp"
- android:src="@drawable/e2e_verified"
+ android:src="@drawable/ic_shield_black"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
-
+ app:layout_constraintTop_toTopOf="parent">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/vector/src/main/res/values/colors_riotx.xml b/vector/src/main/res/values/colors_riotx.xml
index 348deb57bb..a9cb32c3fd 100644
--- a/vector/src/main/res/values/colors_riotx.xml
+++ b/vector/src/main/res/values/colors_riotx.xml
@@ -21,6 +21,7 @@
#FFFF4B55
#FF61708B
+ #1E61708B
#FF368BD6
#FF03b381
diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml
index 05aa7d27bb..d8aad6f539 100644
--- a/vector/src/main/res/values/strings.xml
+++ b/vector/src/main/res/values/strings.xml
@@ -2404,6 +2404,7 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
Media file added to the Gallery
Could not add media file to the Gallery
+ Could not save media file
Set a new account password…
Use the latest Riot on your other devices, Riot Web, Riot Desktop, Riot iOS, RiotX for Android, or another cross-signing capable Matrix client
diff --git a/vector/src/main/res/values/styles_riot.xml b/vector/src/main/res/values/styles_riot.xml
index c4b42fe4fe..64da6ee48c 100644
--- a/vector/src/main/res/values/styles_riot.xml
+++ b/vector/src/main/res/values/styles_riot.xml
@@ -378,4 +378,11 @@
- 8dp
+
+
\ No newline at end of file