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