Download and Open file securily

This commit is contained in:
Valere 2020-06-25 09:33:21 +02:00 committed by Benoit Marty
parent 80e8cd4191
commit 33698abfb2
47 changed files with 826 additions and 158 deletions

View file

@ -13,8 +13,20 @@
android:authorities="${applicationId}.workmanager-init" android:authorities="${applicationId}.workmanager-init"
android:exported="false" android:exported="false"
tools:node="remove" /> tools:node="remove" />
<!--
The SDK offers a secured File provider to access downloaded files.
Access to these file will be given via the FileService, with a temporary
read access permission
-->
<provider
android:name="im.vector.matrix.android.api.session.file.MatrixSDKFileProvider"
android:authorities="${applicationId}.mx-sdk.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/sdk_provider_paths" />
</provider>
</application> </application>
</manifest> </manifest>

View file

@ -46,6 +46,7 @@ import im.vector.matrix.android.api.session.terms.TermsService
import im.vector.matrix.android.api.session.typing.TypingUsersTracker import im.vector.matrix.android.api.session.typing.TypingUsersTracker
import im.vector.matrix.android.api.session.user.UserService import im.vector.matrix.android.api.session.user.UserService
import im.vector.matrix.android.api.session.widgets.WidgetService import im.vector.matrix.android.api.session.widgets.WidgetService
import im.vector.matrix.android.internal.session.download.ContentDownloadStateTracker
/** /**
* This interface defines interactions with a session. * This interface defines interactions with a session.
@ -152,6 +153,11 @@ interface Session :
*/ */
fun typingUsersTracker(): TypingUsersTracker fun typingUsersTracker(): TypingUsersTracker
/**
* Returns the ContentDownloadStateTracker associated with the session
*/
fun contentDownloadProgressTracker(): ContentDownloadStateTracker
/** /**
* Returns the cryptoService associated with the session * Returns the cryptoService associated with the session
*/ */

View file

@ -235,3 +235,11 @@ fun Event.isVideoMessage(): Boolean {
else -> false else -> false
} }
} }
fun Event.isFileMessage(): Boolean {
return getClearType() == EventType.MESSAGE
&& when (getClearContent()?.toModel<MessageContent>()?.msgType) {
MessageType.MSGTYPE_FILE -> true
else -> false
}
}

View file

@ -16,6 +16,7 @@
package im.vector.matrix.android.api.session.file 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.MatrixCallback
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
@ -50,7 +51,16 @@ interface FileService {
downloadMode: DownloadMode, downloadMode: DownloadMode,
id: String, id: String,
fileName: String, fileName: String,
mimeType: String?,
url: String?, url: String?,
elementToDecrypt: ElementToDecrypt?, elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback<File>): Cancelable callback: MatrixCallback<File>): 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?
} }

View file

@ -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"
}
}

View file

@ -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. * 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 @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
) : MessageWithAttachmentContent ) : MessageWithAttachmentContent {
override val mimeType: String?
get() = encryptedFileInfo?.mimetype ?: audioInfo?.mimeType
}

View file

@ -16,7 +16,7 @@
package im.vector.matrix.android.api.session.room.model.message 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.Json
import com.squareup.moshi.JsonClass import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.session.events.model.Content 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 @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
) : MessageWithAttachmentContent { ) : MessageWithAttachmentContent {
fun getMimeType(): String { override val mimeType: String?
// Mimetype default to plain text, should not be used get() = encryptedFileInfo?.mimetype
return encryptedFileInfo?.mimetype
?: info?.mimeType ?: info?.mimeType
?: ClipDescription.MIMETYPE_TEXT_PLAIN ?: MimeTypeMap.getFileExtensionFromUrl(filename ?: body)?.let { extension ->
} MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
}
fun getFileName(): String { fun getFileName(): String {
return filename ?: body return filename ?: body

View file

@ -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. * 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 @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
) : MessageImageInfoContent ) : MessageImageInfoContent {
override val mimeType: String?
get() = encryptedFileInfo?.mimetype ?: info?.mimeType ?: "image/*"
}

View file

@ -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. * 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 @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
) : MessageImageInfoContent ) : MessageImageInfoContent {
override val mimeType: String?
get() = encryptedFileInfo?.mimetype ?: info?.mimeType
}

View file

@ -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. * 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 @Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
) : MessageWithAttachmentContent ) : MessageWithAttachmentContent {
override val mimeType: String?
get() = encryptedFileInfo?.mimetype ?: videoInfo?.mimeType
}

View file

@ -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. * Required if the file is encrypted. Information on the encrypted file, as specified in End-to-end encryption.
*/ */
val encryptedFileInfo: EncryptedFileInfo? val encryptedFileInfo: EncryptedFileInfo?
val mimeType: String?
} }
/** /**
* Get the url of the encrypted file or of the file * Get the url of the encrypted file or of the file
*/ */
fun MessageWithAttachmentContent.getFileUrl() = encryptedFileInfo?.url ?: url fun MessageWithAttachmentContent.getFileUrl() = encryptedFileInfo?.url ?: url
fun MessageWithAttachmentContent.getFileName() = (this as? MessageFileContent)?.getFileName() ?: body

View file

@ -33,3 +33,7 @@ internal annotation class Unauthenticated
@Qualifier @Qualifier
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
internal annotation class UnauthenticatedWithCertificate internal annotation class UnauthenticatedWithCertificate
@Qualifier
@Retention(AnnotationRetention.RUNTIME)
internal annotation class WithProgress

View file

@ -24,7 +24,7 @@ internal annotation class SessionFilesDirectory
@Qualifier @Qualifier
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)
internal annotation class SessionCacheDirectory internal annotation class SessionDownloadsDirectory
@Qualifier @Qualifier
@Retention(AnnotationRetention.RUNTIME) @Retention(AnnotationRetention.RUNTIME)

View file

@ -16,6 +16,10 @@
package im.vector.matrix.android.internal.session 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 arrow.core.Try
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.content.ContentUrlResolver import im.vector.matrix.android.api.session.content.ContentUrlResolver
@ -25,8 +29,8 @@ import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments
import im.vector.matrix.android.internal.di.CacheDirectory import im.vector.matrix.android.internal.di.CacheDirectory
import im.vector.matrix.android.internal.di.ExternalFilesDirectory import im.vector.matrix.android.internal.di.ExternalFilesDirectory
import im.vector.matrix.android.internal.di.SessionCacheDirectory import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate import im.vector.matrix.android.internal.di.WithProgress
import im.vector.matrix.android.internal.extensions.foldToCallback import im.vector.matrix.android.internal.extensions.foldToCallback
import im.vector.matrix.android.internal.task.TaskExecutor import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
@ -39,22 +43,28 @@ import okhttp3.Request
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.io.IOException import java.io.IOException
import java.net.URLEncoder
import javax.inject.Inject import javax.inject.Inject
internal class DefaultFileService @Inject constructor( internal class DefaultFileService @Inject constructor(
private val context: Context,
@CacheDirectory @CacheDirectory
private val cacheDirectory: File, private val cacheDirectory: File,
@ExternalFilesDirectory @ExternalFilesDirectory
private val externalFilesDirectory: File?, private val externalFilesDirectory: File?,
@SessionCacheDirectory @SessionDownloadsDirectory
private val sessionCacheDirectory: File, private val sessionCacheDirectory: File,
private val contentUrlResolver: ContentUrlResolver, private val contentUrlResolver: ContentUrlResolver,
@UnauthenticatedWithCertificate @WithProgress
private val okHttpClient: OkHttpClient, private val okHttpClient: OkHttpClient,
private val coroutineDispatchers: MatrixCoroutineDispatchers, private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val taskExecutor: TaskExecutor private val taskExecutor: TaskExecutor
) : FileService { ) : FileService {
private fun String.safeFileName() = URLEncoder.encode(this, Charsets.US_ASCII.displayName())
private val downloadFolder = File(sessionCacheDirectory, "MF")
/** /**
* Download file in the cache folder, and eventually decrypt it * Download file in the cache folder, and eventually decrypt it
* TODO implement clear file, to delete "MF" * TODO implement clear file, to delete "MF"
@ -63,23 +73,28 @@ internal class DefaultFileService @Inject constructor(
override fun downloadFile(downloadMode: FileService.DownloadMode, override fun downloadFile(downloadMode: FileService.DownloadMode,
id: String, id: String,
fileName: String, fileName: String,
mimeType: String?,
url: String?, url: String?,
elementToDecrypt: ElementToDecrypt?, elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback<File>): Cancelable { callback: MatrixCallback<File>): Cancelable {
return taskExecutor.executorScope.launch(coroutineDispatchers.main) { return taskExecutor.executorScope.launch(coroutineDispatchers.main) {
withContext(coroutineDispatchers.io) { withContext(coroutineDispatchers.io) {
Try { Try {
val folder = File(sessionCacheDirectory, "MF") val unwrappedUrl = url ?: throw IllegalArgumentException("url is null")
if (!folder.exists()) { if (!downloadFolder.exists()) {
folder.mkdirs() 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 -> }.flatMap { destFile ->
if (!destFile.exists()) { if (!destFile.exists()) {
val resolvedUrl = contentUrlResolver.resolveFullSize(url) ?: return@flatMap Try.Failure(IllegalArgumentException("url is null")) val resolvedUrl = contentUrlResolver.resolveFullSize(url) ?: return@flatMap Try.Failure(IllegalArgumentException("url is null"))
val request = Request.Builder() val request = Request.Builder()
.url(resolvedUrl) .url(resolvedUrl)
.header("matrix-sdk:mxc_URL", url ?: "")
.build() .build()
val response = try { val response = try {
@ -121,6 +136,27 @@ internal class DefaultFileService @Inject constructor(
}.toCancelable() }.toCancelable()
} }
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()
}
/**
* 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 { private fun copyFile(file: File, downloadMode: FileService.DownloadMode): File {
return when (downloadMode) { return when (downloadMode) {
FileService.DownloadMode.TO_EXPORT -> FileService.DownloadMode.TO_EXPORT ->

View file

@ -53,6 +53,7 @@ import im.vector.matrix.android.internal.auth.SessionParamsStore
import im.vector.matrix.android.internal.crypto.DefaultCryptoService import im.vector.matrix.android.internal.crypto.DefaultCryptoService
import im.vector.matrix.android.internal.di.SessionId import im.vector.matrix.android.internal.di.SessionId
import im.vector.matrix.android.internal.di.WorkManagerProvider import im.vector.matrix.android.internal.di.WorkManagerProvider
import im.vector.matrix.android.internal.session.download.ContentDownloadStateTracker
import im.vector.matrix.android.internal.session.identity.DefaultIdentityService import im.vector.matrix.android.internal.session.identity.DefaultIdentityService
import im.vector.matrix.android.internal.session.room.timeline.TimelineEventDecryptor import im.vector.matrix.android.internal.session.room.timeline.TimelineEventDecryptor
import im.vector.matrix.android.internal.session.sync.SyncTokenStore import im.vector.matrix.android.internal.session.sync.SyncTokenStore
@ -100,6 +101,7 @@ internal class DefaultSession @Inject constructor(
private val sessionParamsStore: SessionParamsStore, private val sessionParamsStore: SessionParamsStore,
private val contentUploadProgressTracker: ContentUploadStateTracker, private val contentUploadProgressTracker: ContentUploadStateTracker,
private val typingUsersTracker: TypingUsersTracker, private val typingUsersTracker: TypingUsersTracker,
private val contentDownloadStateTracker: ContentDownloadStateTracker,
private val initialSyncProgressService: Lazy<InitialSyncProgressService>, private val initialSyncProgressService: Lazy<InitialSyncProgressService>,
private val homeServerCapabilitiesService: Lazy<HomeServerCapabilitiesService>, private val homeServerCapabilitiesService: Lazy<HomeServerCapabilitiesService>,
private val accountDataService: Lazy<AccountDataService>, private val accountDataService: Lazy<AccountDataService>,
@ -239,6 +241,8 @@ internal class DefaultSession @Inject constructor(
override fun typingUsersTracker() = typingUsersTracker override fun typingUsersTracker() = typingUsersTracker
override fun contentDownloadProgressTracker(): ContentDownloadStateTracker = contentDownloadStateTracker
override fun cryptoService(): CryptoService = cryptoService.get() override fun cryptoService(): CryptoService = cryptoService.get()
override fun identityService() = defaultIdentityService override fun identityService() = defaultIdentityService

View file

@ -43,14 +43,15 @@ import im.vector.matrix.android.internal.crypto.verification.VerificationMessage
import im.vector.matrix.android.internal.database.SessionRealmConfigurationFactory import im.vector.matrix.android.internal.database.SessionRealmConfigurationFactory
import im.vector.matrix.android.internal.di.Authenticated import im.vector.matrix.android.internal.di.Authenticated
import im.vector.matrix.android.internal.di.DeviceId 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.SessionDatabase
import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
import im.vector.matrix.android.internal.di.SessionFilesDirectory import im.vector.matrix.android.internal.di.SessionFilesDirectory
import im.vector.matrix.android.internal.di.SessionId import im.vector.matrix.android.internal.di.SessionId
import im.vector.matrix.android.internal.di.Unauthenticated import im.vector.matrix.android.internal.di.Unauthenticated
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate
import im.vector.matrix.android.internal.di.UserId import im.vector.matrix.android.internal.di.UserId
import im.vector.matrix.android.internal.di.UserMd5 import im.vector.matrix.android.internal.di.UserMd5
import im.vector.matrix.android.internal.di.WithProgress
import im.vector.matrix.android.internal.eventbus.EventBusTimberLogger import im.vector.matrix.android.internal.eventbus.EventBusTimberLogger
import im.vector.matrix.android.internal.network.DefaultNetworkConnectivityChecker import im.vector.matrix.android.internal.network.DefaultNetworkConnectivityChecker
import im.vector.matrix.android.internal.network.FallbackNetworkCallbackStrategy import im.vector.matrix.android.internal.network.FallbackNetworkCallbackStrategy
@ -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.RetrofitFactory
import im.vector.matrix.android.internal.network.httpclient.addAccessTokenInterceptor 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.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.AccessTokenProvider
import im.vector.matrix.android.internal.network.token.HomeserverAccessTokenProvider 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.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.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.homeserver.DefaultHomeServerCapabilitiesService import im.vector.matrix.android.internal.session.homeserver.DefaultHomeServerCapabilitiesService
import im.vector.matrix.android.internal.session.identity.DefaultIdentityService import im.vector.matrix.android.internal.session.identity.DefaultIdentityService
@ -160,10 +163,10 @@ internal abstract class SessionModule {
@JvmStatic @JvmStatic
@Provides @Provides
@SessionCacheDirectory @SessionDownloadsDirectory
fun providesCacheDir(@SessionId sessionId: String, fun providesCacheDir(@SessionId sessionId: String,
context: Context): File { context: Context): File {
return File(context.cacheDir, sessionId) return File(context.cacheDir, "downloads/$sessionId")
} }
@JvmStatic @JvmStatic
@ -216,6 +219,27 @@ internal abstract class SessionModule {
.build() .build()
} }
@JvmStatic
@Provides
@SessionScope
@WithProgress
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<CurlLoggingInterceptor>()
interceptors().removeAll(existingCurlInterceptors)
addInterceptor(downloadProgressInterceptor)
// Re add eventually the curl logging interceptors
existingCurlInterceptors.forEach {
addInterceptor(it)
}
}.build()
}
@JvmStatic @JvmStatic
@Provides @Provides
@SessionScope @SessionScope

View file

@ -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.crypto.CryptoModule
import im.vector.matrix.android.internal.database.RealmKeysUtils import im.vector.matrix.android.internal.database.RealmKeysUtils
import im.vector.matrix.android.internal.di.CryptoDatabase 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.SessionDatabase
import im.vector.matrix.android.internal.di.SessionFilesDirectory import im.vector.matrix.android.internal.di.SessionFilesDirectory
import im.vector.matrix.android.internal.di.SessionId import im.vector.matrix.android.internal.di.SessionId
@ -44,7 +44,7 @@ internal class CleanupSession @Inject constructor(
@SessionDatabase private val clearSessionDataTask: ClearCacheTask, @SessionDatabase private val clearSessionDataTask: ClearCacheTask,
@CryptoDatabase private val clearCryptoDataTask: ClearCacheTask, @CryptoDatabase private val clearCryptoDataTask: ClearCacheTask,
@SessionFilesDirectory private val sessionFiles: File, @SessionFilesDirectory private val sessionFiles: File,
@SessionCacheDirectory private val sessionCache: File, @SessionDownloadsDirectory private val sessionCache: File,
private val realmKeysUtils: RealmKeysUtils, private val realmKeysUtils: RealmKeysUtils,
@SessionDatabase private val realmSessionConfiguration: RealmConfiguration, @SessionDatabase private val realmSessionConfiguration: RealmConfiguration,
@CryptoDatabase private val realmCryptoConfiguration: RealmConfiguration, @CryptoDatabase private val realmCryptoConfiguration: RealmConfiguration,

View file

@ -20,6 +20,8 @@ import dagger.Binds
import dagger.Module import dagger.Module
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker 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.content.ContentUrlResolver
import im.vector.matrix.android.internal.session.download.ContentDownloadStateTracker
import im.vector.matrix.android.internal.session.download.DefaultContentDownloadStateTracker
@Module @Module
internal abstract class ContentModule { internal abstract class ContentModule {
@ -27,6 +29,9 @@ internal abstract class ContentModule {
@Binds @Binds
abstract fun bindContentUploadStateTracker(tracker: DefaultContentUploadStateTracker): ContentUploadStateTracker abstract fun bindContentUploadStateTracker(tracker: DefaultContentUploadStateTracker): ContentUploadStateTracker
@Binds
abstract fun bindContentDownloadStateTracker(tracker: DefaultContentDownloadStateTracker): ContentDownloadStateTracker
@Binds @Binds
abstract fun bindContentUrlResolver(resolver: DefaultContentUrlResolver): ContentUrlResolver abstract fun bindContentUrlResolver(resolver: DefaultContentUrlResolver): ContentUrlResolver
} }

View file

@ -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.internal.session.download
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)
}
}

View file

@ -0,0 +1,71 @@
/*
* 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.internal.session.SessionScope
import timber.log.Timber
import javax.inject.Inject
@SessionScope
class DefaultContentDownloadStateTracker @Inject constructor() : ProgressListener, ContentDownloadStateTracker {
private val mainHandler = Handler(Looper.getMainLooper())
private val states = mutableMapOf<String, ContentDownloadStateTracker.State>()
private val listeners = mutableMapOf<String, MutableList<ContentDownloadStateTracker.UpdateListener>>()
override fun track(key: String, updateListener: ContentDownloadStateTracker.UpdateListener) {
val listeners = listeners.getOrPut(key) { ArrayList() }
if (!listeners.contains(updateListener)) {
listeners.add(updateListener)
}
}
override fun unTrack(key: String, updateListener: ContentDownloadStateTracker.UpdateListener) {
listeners[key]?.apply {
remove(updateListener)
}
}
override fun 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")
listeners[url]?.forEach {
tryThis {
if (done) {
it.onDownloadStateUpdate(ContentDownloadStateTracker.State.Success)
} else {
it.onDownloadStateUpdate(ContentDownloadStateTracker.State.Downloading(bytesRead, contentLength, contentLength == -1L))
}
}
}
}
override fun error(url: String, errorCode: Int) {
Timber.v("## DL Progress Error code:$errorCode")
listeners[url]?.forEach {
tryThis { it.onDownloadStateUpdate(ContentDownloadStateTracker.State.Failure(errorCode)) }
}
}
}

View file

@ -0,0 +1,45 @@
/*
* 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
class DownloadProgressInterceptor @Inject constructor(
private val downloadStateTracker: DefaultContentDownloadStateTracker
) : Interceptor {
override fun intercept(chain: Interceptor.Chain): Response {
val url = chain.request().url.toUrl()
val mxcURl = chain.request().header("matrix-sdk:mxc_URL")
val request = chain.request().newBuilder()
.removeHeader("matrix-sdk:mxc_URL")
.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()
}
}

View file

@ -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)
}

View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<cache-path
name="downloads"
path="/" />
</paths>

View file

@ -204,7 +204,7 @@
<service <service
android:name=".core.services.CallService" android:name=".core.services.CallService"
android:exported="false" > android:exported="false">
<!-- in order to get headset button events --> <!-- in order to get headset button events -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" /> <action android:name="android.intent.action.MEDIA_BUTTON" />
@ -239,7 +239,7 @@
A media button receiver receives and helps translate hardware media playback buttons, 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. such as those found on wired and wireless headsets, into the appropriate callbacks in your app.
--> -->
<receiver android:name="androidx.media.session.MediaButtonReceiver" > <receiver android:name="androidx.media.session.MediaButtonReceiver">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MEDIA_BUTTON" /> <action android:name="android.intent.action.MEDIA_BUTTON" />
</intent-filter> </intent-filter>
@ -254,7 +254,7 @@
android:grantUriPermissions="true"> android:grantUriPermissions="true">
<meta-data <meta-data
android:name="android.support.FILE_PROVIDER_PATHS" android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/riotx_provider_paths" /> android:resource="@xml/sdk_provider_paths" />
</provider> </provider>
</application> </application>

View file

@ -35,6 +35,7 @@ import im.vector.riotx.BuildConfig
import im.vector.riotx.R import im.vector.riotx.R
import okio.buffer import okio.buffer
import okio.sink import okio.sink
import okio.source
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
@ -335,11 +336,16 @@ fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String
} }
} }
context.contentResolver.insert(externalContentUri, values)?.let { uri -> context.contentResolver.insert(externalContentUri, values)?.let { uri ->
context.contentResolver.openOutputStream(uri)?.use { outputStream -> val source = file.inputStream().source().buffer()
outputStream.sink().buffer().write(file.inputStream().use { it.readBytes() }) context.contentResolver.openOutputStream(uri)?.sink()?.buffer()?.let { sink ->
return true source.use { input ->
sink.use { output ->
output.writeAll(input)
}
}
} }
} }
// TODO add notification?
} else { } else {
@Suppress("DEPRECATION") @Suppress("DEPRECATION")
Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE).also { mediaScanIntent -> Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE).also { mediaScanIntent ->

View file

@ -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.content.ContentAttachmentData
import im.vector.matrix.android.api.session.events.model.Event 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.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.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.platform.VectorViewModelAction 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 UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailAction()
data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction() data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction()
object MarkAllAsRead : RoomDetailAction() object MarkAllAsRead : RoomDetailAction()
data class 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() data class HandleTombstoneEvent(val event: Event) : RoomDetailAction()
object AcceptInvite : RoomDetailAction() object AcceptInvite : RoomDetailAction()
object RejectInvite : RoomDetailAction() object RejectInvite : RoomDetailAction()

View file

@ -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.events.model.toModel
import im.vector.matrix.android.api.session.file.FileService 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.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.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.MessageFormat
import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent 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.MessageStickerContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent 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.MessageVerificationRequestContent
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent 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.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.send.SendState import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.Timeline 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.setTextOrHide
import im.vector.riotx.core.extensions.showKeyboard import im.vector.riotx.core.extensions.showKeyboard
import im.vector.riotx.core.extensions.trackItemsVisibilityChange 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.glide.GlideApp
import im.vector.riotx.core.intent.getMimeTypeFromUri import im.vector.riotx.core.intent.getMimeTypeFromUri
import im.vector.riotx.core.platform.VectorBaseFragment 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_AUDIO_IP_CALL
import im.vector.riotx.core.utils.PERMISSIONS_FOR_VIDEO_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.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_INCOMING_URI
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_PICK_ATTACHMENT import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_PICK_ATTACHMENT
import im.vector.riotx.core.utils.TextUtils import im.vector.riotx.core.utils.TextUtils
@ -344,7 +341,7 @@ class RoomDetailFragment @Inject constructor(
is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it) is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it)
is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it) is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it) is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it)
is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode) is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode)
RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager() RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager()
is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it) is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it)
is RoomDetailViewEvents.DisplayEnableIntegrationsWarning -> displayDisabledIntegrationDialog() is RoomDetailViewEvents.DisplayEnableIntegrationsWarning -> displayDisabledIntegrationDialog()
@ -370,6 +367,21 @@ class RoomDetailFragment @Inject constructor(
navigator.openStickerPicker(this, roomDetailArgs.roomId, event.widget) 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() { private fun displayPromptForIntegrationManager() {
// The Sticker picker widget is not installed yet. Propose the user to install it // The Sticker picker widget is not installed yet. Propose the user to install it
val builder = AlertDialog.Builder(requireContext()) val builder = AlertDialog.Builder(requireContext())
@ -487,21 +499,22 @@ class RoomDetailFragment @Inject constructor(
val activity = requireActivity() val activity = requireActivity()
if (action.throwable != null) { if (action.throwable != null) {
activity.toast(errorFormatter.toHumanReadable(action.throwable)) activity.toast(errorFormatter.toHumanReadable(action.throwable))
} else if (action.file != null) {
addEntryToDownloadManager(activity, action.file, action.mimeType)?.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
).let { notification ->
notificationUtils.showNotificationMessage("DL", action.file.absolutePath.hashCode(), notification)
}
}
} }
// 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() { private fun setupNotificationView() {
@ -680,6 +693,8 @@ class RoomDetailFragment @Inject constructor(
} }
} }
} }
// TODO why don't we call super here?
// super.onActivityResult(requestCode, resultCode, data)
} }
// PRIVATE METHODS ***************************************************************************** // PRIVATE METHODS *****************************************************************************
@ -1163,31 +1178,32 @@ class RoomDetailFragment @Inject constructor(
navigator.openVideoViewer(requireActivity(), mediaData) navigator.openVideoViewer(requireActivity(), mediaData)
} }
override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) { // override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) {
val action = RoomDetailAction.DownloadFile(eventId, messageFileContent) // val isEncrypted = messageFileContent.encryptedFileInfo != null
// We need WRITE_EXTERNAL permission // val action = RoomDetailAction.DownloadOrOpen(eventId, messageFileContent, isEncrypted)
if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) { // // We need WRITE_EXTERNAL permission
showSnackWithMessage(getString(R.string.downloading_file, messageFileContent.getFileName())) // // if (!isEncrypted || checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) {
roomDetailViewModel.handle(action) // showSnackWithMessage(getString(R.string.downloading_file, messageFileContent.getFileName()))
} else { // roomDetailViewModel.handle(action)
roomDetailViewModel.pendingAction = action // // } else {
} // // roomDetailViewModel.pendingAction = action
} // // }
// }
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) { override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, grantResults: IntArray) {
if (allGranted(grantResults)) { if (allGranted(grantResults)) {
when (requestCode) { when (requestCode) {
PERMISSION_REQUEST_CODE_DOWNLOAD_FILE -> { // PERMISSION_REQUEST_CODE_DOWNLOAD_FILE -> {
val action = roomDetailViewModel.pendingAction // val action = roomDetailViewModel.pendingAction
if (action != null) { // if (action != null) {
(action as? RoomDetailAction.DownloadFile) // (action as? RoomDetailAction.DownloadFile)
?.messageFileContent // ?.messageFileContent
?.getFileName() // ?.getFileName()
?.let { showSnackWithMessage(getString(R.string.downloading_file, it)) } // ?.let { showSnackWithMessage(getString(R.string.downloading_file, it)) }
roomDetailViewModel.pendingAction = null // roomDetailViewModel.pendingAction = null
roomDetailViewModel.handle(action) // roomDetailViewModel.handle(action)
} // }
} // }
PERMISSION_REQUEST_CODE_INCOMING_URI -> { PERMISSION_REQUEST_CODE_INCOMING_URI -> {
val pendingUri = roomDetailViewModel.pendingUri val pendingUri = roomDetailViewModel.pendingUri
if (pendingUri != null) { if (pendingUri != null) {
@ -1227,9 +1243,9 @@ class RoomDetailFragment @Inject constructor(
} }
} }
override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) { // override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) {
vectorBaseActivity.notImplemented("open audio file") // vectorBaseActivity.notImplemented("open audio file")
} // }
override fun onLoadMore(direction: Timeline.Direction) { override fun onLoadMore(direction: Timeline.Direction) {
roomDetailViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction)) roomDetailViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction))
@ -1240,6 +1256,10 @@ class RoomDetailFragment @Inject constructor(
is MessageVerificationRequestContent -> { is MessageVerificationRequestContent -> {
roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null)) roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null))
} }
is MessageWithAttachmentContent -> {
val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, messageContent)
roomDetailViewModel.handle(action)
}
is EncryptedEventContent -> { is EncryptedEventContent -> {
roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId)) roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId))
} }
@ -1323,6 +1343,7 @@ class RoomDetailFragment @Inject constructor(
action.eventId, action.eventId,
action.messageContent.body, action.messageContent.body,
action.messageContent.getFileUrl(), action.messageContent.getFileUrl(),
action.messageContent.mimeType,
action.messageContent.encryptedFileInfo?.toElementToDecrypt(), action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
object : MatrixCallback<File> { object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {
@ -1336,12 +1357,13 @@ class RoomDetailFragment @Inject constructor(
private fun onSaveActionClicked(action: EventSharedAction.Save) { private fun onSaveActionClicked(action: EventSharedAction.Save) {
session.downloadFile( session.downloadFile(
FileService.DownloadMode.FOR_EXTERNAL_SHARE, downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
action.eventId, id = action.eventId,
action.messageContent.body, fileName = action.messageContent.body,
action.messageContent.getFileUrl(), mimeType = action.messageContent.mimeType,
action.messageContent.encryptedFileInfo?.toElementToDecrypt(), url = action.messageContent.getFileUrl(),
object : MatrixCallback<File> { elementToDecrypt = action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {
if (isAdded) { if (isAdded) {
val saved = saveMedia( val saved = saveMedia(

View file

@ -16,6 +16,7 @@
package im.vector.riotx.features.home.room.detail package im.vector.riotx.features.home.room.detail
import android.net.Uri
import androidx.annotation.StringRes import androidx.annotation.StringRes
import im.vector.matrix.android.api.session.widgets.model.Widget import im.vector.matrix.android.api.session.widgets.model.Widget
import im.vector.matrix.android.internal.crypto.model.event.WithHeldCode import im.vector.matrix.android.internal.crypto.model.event.WithHeldCode
@ -45,11 +46,17 @@ sealed class RoomDetailViewEvents : VectorViewEvents {
) : RoomDetailViewEvents() ) : RoomDetailViewEvents()
data class DownloadFileState( data class DownloadFileState(
val mimeType: String, val mimeType: String?,
val file: File?, val file: File?,
val throwable: Throwable? val throwable: Throwable?
) : RoomDetailViewEvents() ) : RoomDetailViewEvents()
data class OpenFile(
val mimeType: String?,
val uri: Uri?,
val throwable: Throwable?
) : RoomDetailViewEvents()
abstract class SendMessageResult : RoomDetailViewEvents() abstract class SendMessageResult : RoomDetailViewEvents()
object DisplayPromptForIntegrationManager: RoomDetailViewEvents() object DisplayPromptForIntegrationManager: RoomDetailViewEvents()

View file

@ -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.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType 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.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.message.getFileUrl
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
import im.vector.matrix.android.api.session.room.powerlevels.PowerLevelsHelper 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.EnterEditMode -> handleEditAction(action)
is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action) is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailAction.EnterReplyMode -> handleReplyAction(action) is RoomDetailAction.EnterReplyMode -> handleReplyAction(action)
is RoomDetailAction.DownloadFile -> handleDownloadFile(action) is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action)
is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action) is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action)
is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action) is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action)
is RoomDetailAction.ResendMessage -> handleResendEvent(action) is RoomDetailAction.ResendMessage -> handleResendEvent(action)
@ -858,30 +859,44 @@ class RoomDetailViewModel @AssistedInject constructor(
} }
} }
private fun handleDownloadFile(action: RoomDetailAction.DownloadFile) { private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) {
session.downloadFile( val mxcUrl = action.messageFileContent.getFileUrl()
FileService.DownloadMode.TO_EXPORT, val isDownloaded = mxcUrl?.let { session.isFileInCache(it, action.messageFileContent.mimeType) } ?: false
action.eventId, if (isDownloaded) {
action.messageFileContent.getFileName(), // we can open it
action.messageFileContent.getFileUrl(), session.getTemporarySharableURI(mxcUrl!!, action.messageFileContent.mimeType)?.let { uri ->
action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(), _viewEvents.post(RoomDetailViewEvents.OpenFile(
object : MatrixCallback<File> { action.messageFileContent.mimeType,
override fun onSuccess(data: File) { uri,
_viewEvents.post(RoomDetailViewEvents.DownloadFileState( null
action.messageFileContent.getMimeType(), ))
data, }
null } else {
)) session.downloadFile(
} FileService.DownloadMode.FOR_INTERNAL_USE,
action.eventId,
action.messageFileContent.getFileName(),
action.messageFileContent.mimeType,
mxcUrl,
action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(),
object : MatrixCallback<File> {
override fun onSuccess(data: File) {
_viewEvents.post(RoomDetailViewEvents.DownloadFileState(
action.messageFileContent.mimeType,
data,
null
))
}
override fun onFailure(failure: Throwable) { override fun onFailure(failure: Throwable) {
_viewEvents.post(RoomDetailViewEvents.DownloadFileState( _viewEvents.post(RoomDetailViewEvents.DownloadFileState(
action.messageFileContent.getMimeType(), action.messageFileContent.mimeType,
null, null,
failure failure
)) ))
} }
}) })
}
} }
private fun handleNavigateToEvent(action: RoomDetailAction.NavigateToEvent) { private fun handleNavigateToEvent(action: RoomDetailAction.NavigateToEvent) {

View file

@ -27,6 +27,7 @@ import com.airbnb.epoxy.EpoxyModel
import com.airbnb.epoxy.VisibilityState 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.MessageAudioContent
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent 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.model.message.MessageVideoContent
import im.vector.matrix.android.api.session.room.timeline.Timeline import im.vector.matrix.android.api.session.room.timeline.Timeline
@ -74,8 +75,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View) fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View) fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View)
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View) fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) // fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent)
fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) // fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
fun onEditedDecorationClicked(informationData: MessageInformationData) fun onEditedDecorationClicked(informationData: MessageInformationData)
// TODO move all callbacks to this? // TODO move all callbacks to this?

View file

@ -57,6 +57,7 @@ import im.vector.riotx.core.utils.containsOnlyEmojis
import im.vector.riotx.core.utils.isLocalFile 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.TimelineEventController
import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider 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.ContentUploadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory
import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory 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 messageInformationDataFactory: MessageInformationDataFactory,
private val messageItemAttributesFactory: MessageItemAttributesFactory, private val messageItemAttributesFactory: MessageItemAttributesFactory,
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder, private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
private val contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder,
private val defaultItemFactory: DefaultItemFactory, private val defaultItemFactory: DefaultItemFactory,
private val noticeItemFactory: NoticeItemFactory, private val noticeItemFactory: NoticeItemFactory,
private val avatarSizeProvider: AvatarSizeProvider, private val avatarSizeProvider: AvatarSizeProvider,
@ -140,8 +142,8 @@ class MessageItemFactory @Inject constructor(
is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes)
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes) is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, callback) is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, callback)
@ -184,20 +186,22 @@ class MessageItemFactory @Inject constructor(
@Suppress("UNUSED_PARAMETER") @Suppress("UNUSED_PARAMETER")
informationData: MessageInformationData, informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?, // callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageFileItem? { attributes: AbsMessageItem.Attributes): MessageFileItem? {
return MessageFileItem_() return MessageFileItem_()
.attributes(attributes) .attributes(attributes)
.izLocalFile(messageContent.getFileUrl().isLocalFile()) .izLocalFile(messageContent.getFileUrl().isLocalFile())
.mxcUrl(messageContent.getFileUrl() ?: "")
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.highlighted(highlight) .highlighted(highlight)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.filename(messageContent.body) .filename(messageContent.body)
.iconRes(R.drawable.filetype_audio) .iconRes(R.drawable.ic_paperclip)
.clickListener( // .clickListener(
DebouncedClickListener(View.OnClickListener { // DebouncedClickListener(View.OnClickListener {
callback?.onAudioMessageClicked(messageContent) // callback?.onAudioMessageClicked(messageContent)
})) // }))
} }
private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent, private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent,
@ -245,22 +249,24 @@ class MessageItemFactory @Inject constructor(
} }
private fun buildFileMessageItem(messageContent: MessageFileContent, private fun buildFileMessageItem(messageContent: MessageFileContent,
informationData: MessageInformationData, // informationData: MessageInformationData,
highlight: Boolean, highlight: Boolean,
callback: TimelineEventController.Callback?, // callback: TimelineEventController.Callback?,
attributes: AbsMessageItem.Attributes): MessageFileItem? { attributes: AbsMessageItem.Attributes): MessageFileItem? {
return MessageFileItem_() return MessageFileItem_()
.attributes(attributes) .attributes(attributes)
.leftGuideline(avatarSizeProvider.leftGuideline) .leftGuideline(avatarSizeProvider.leftGuideline)
.izLocalFile(messageContent.getFileUrl().isLocalFile()) .izLocalFile(messageContent.getFileUrl().isLocalFile())
.mxcUrl(messageContent.getFileUrl() ?: "")
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder) .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
.highlighted(highlight) .highlighted(highlight)
.filename(messageContent.body) .filename(messageContent.body)
.iconRes(R.drawable.filetype_attachment) .iconRes(R.drawable.ic_paperclip)
.clickListener( // .clickListener(
DebouncedClickListener(View.OnClickListener { // DebouncedClickListener(View.OnClickListener {
callback?.onFileMessageClicked(informationData.eventId, messageContent) // callback?.onFileMessageClicked(informationData.eventId, messageContent)
})) // }))
} }
private fun buildNotHandledMessageItem(messageContent: MessageContent, private fun buildNotHandledMessageItem(messageContent: MessageContent,
@ -282,6 +288,7 @@ class MessageItemFactory @Inject constructor(
val data = ImageContentRenderer.Data( val data = ImageContentRenderer.Data(
eventId = informationData.eventId, eventId = informationData.eventId,
filename = messageContent.body, filename = messageContent.body,
mimeType = messageContent.mimeType,
url = messageContent.getFileUrl(), url = messageContent.getFileUrl(),
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(), elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
height = messageContent.info?.height, height = messageContent.info?.height,
@ -318,6 +325,7 @@ class MessageItemFactory @Inject constructor(
val thumbnailData = ImageContentRenderer.Data( val thumbnailData = ImageContentRenderer.Data(
eventId = informationData.eventId, eventId = informationData.eventId,
filename = messageContent.body, filename = messageContent.body,
mimeType = messageContent.mimeType,
url = messageContent.videoInfo?.thumbnailFile?.url url = messageContent.videoInfo?.thumbnailFile?.url
?: messageContent.videoInfo?.thumbnailUrl, ?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
@ -330,6 +338,7 @@ class MessageItemFactory @Inject constructor(
val videoData = VideoContentRenderer.Data( val videoData = VideoContentRenderer.Data(
eventId = informationData.eventId, eventId = informationData.eventId,
filename = messageContent.body, filename = messageContent.body,
mimeType = messageContent.mimeType,
url = messageContent.getFileUrl(), url = messageContent.getFileUrl(),
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(), elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
thumbnailMediaData = thumbnailData thumbnailMediaData = thumbnailData

View file

@ -0,0 +1,112 @@
/*
* 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 im.vector.matrix.android.internal.session.download.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<String, ContentDownloadStateTracker.UpdateListener>()
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 {
downloadStateTracker.unTrack(mxcUrl, it)
}
}
}
fun clear() {
activeSessionHolder.getSafeActiveSession()?.also {
it.contentUploadProgressTracker().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()
}
}
// avoid blink effect when setting icon
private var hasDLResource = false
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 (!hasDLResource) {
holder.fileImageView.setImageResource(R.drawable.ic_download)
hasDLResource = true
}
}
private fun handleFailure() {
holder.fileDownloadProgress.isIndeterminate = false
holder.fileDownloadProgress.progress = 0
holder.fileImageView.setImageResource(R.drawable.ic_close_round)
}
private fun handleSuccess() {
holder.fileDownloadProgress.isIndeterminate = false
holder.fileDownloadProgress.progress = 100
holder.fileImageView.setImageResource(R.drawable.ic_paperclip)
}
}

View file

@ -21,9 +21,13 @@ package im.vector.riotx.features.home.room.detail.timeline.helper
import im.vector.matrix.android.api.extensions.orFalse import im.vector.matrix.android.api.extensions.orFalse
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.isFileMessage
import im.vector.matrix.android.api.session.events.model.toModel import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.ReferencesAggregatedContent import im.vector.matrix.android.api.session.room.model.ReferencesAggregatedContent
import im.vector.matrix.android.api.session.room.model.message.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageVerificationRequestContent import im.vector.matrix.android.api.session.room.model.message.MessageVerificationRequestContent
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.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
@ -111,7 +115,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
ReferencesInfoData(verificationState) ReferencesInfoData(verificationState)
}, },
sentByMe = event.root.senderId == session.myUserId, sentByMe = event.root.senderId == session.myUserId,
e2eDecoration = e2eDecoration e2eDecoration = e2eDecoration,
isDowloaded = isDownloaded
) )
} }

View file

@ -17,15 +17,16 @@
package im.vector.riotx.features.home.room.detail.timeline.item package im.vector.riotx.features.home.room.detail.timeline.item
import android.graphics.Paint import android.graphics.Paint
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.ImageView import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import androidx.annotation.DrawableRes import androidx.annotation.DrawableRes
import androidx.core.view.isVisible import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R 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 import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
@EpoxyModelClass(layout = R.layout.item_timeline_event_base) @EpoxyModelClass(layout = R.layout.item_timeline_event_base)
@ -33,16 +34,26 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
@EpoxyAttribute @EpoxyAttribute
var filename: CharSequence = "" var filename: CharSequence = ""
@EpoxyAttribute
var mxcUrl: String = ""
@EpoxyAttribute @EpoxyAttribute
@DrawableRes @DrawableRes
var iconRes: Int = 0 var iconRes: Int = 0
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var clickListener: View.OnClickListener? = null // @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
// var clickListener: View.OnClickListener? = null
@EpoxyAttribute @EpoxyAttribute
var izLocalFile = false var izLocalFile = false
@EpoxyAttribute @EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
@EpoxyAttribute
lateinit var contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder
override fun bind(holder: Holder) { override fun bind(holder: Holder) {
super.bind(holder) super.bind(holder)
renderSendState(holder.fileLayout, holder.filenameView) renderSendState(holder.fileLayout, holder.filenameView)
@ -51,15 +62,30 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
} else { } else {
holder.progressLayout.isVisible = false holder.progressLayout.isVisible = false
} }
if (!attributes.informationData.isDowloaded) {
contentDownloadStateTrackerBinder.bind(mxcUrl, holder)
}
holder.filenameView.text = filename holder.filenameView.text = filename
holder.fileImageView.setImageResource(iconRes) if (attributes.informationData.isDowloaded) {
holder.filenameView.setOnClickListener(clickListener) holder.fileImageView.setImageResource(iconRes)
holder.fileDownloadProgress.progress = 100
} else {
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) holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG)
} }
override fun unbind(holder: Holder) { override fun unbind(holder: Holder) {
super.unbind(holder) super.unbind(holder)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId) contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
contentDownloadStateTrackerBinder.unbind(mxcUrl)
} }
override fun getViewType() = STUB_ID override fun getViewType() = STUB_ID
@ -67,7 +93,9 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
class Holder : AbsMessageItem.Holder(STUB_ID) { class Holder : AbsMessageItem.Holder(STUB_ID) {
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout) val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
val fileLayout by bind<ViewGroup>(R.id.messageFileLayout) val fileLayout by bind<ViewGroup>(R.id.messageFileLayout)
val fileImageView by bind<ImageView>(R.id.messageFileImageView) val fileImageView by bind<ImageView>(R.id.messageFileIconView)
val fileImageWrapper by bind<ViewGroup>(R.id.messageFileImageView)
val fileDownloadProgress by bind<ProgressBar>(R.id.messageFileProgressbar)
val filenameView by bind<TextView>(R.id.messageFilenameView) val filenameView by bind<TextView>(R.id.messageFilenameView)
} }

View file

@ -41,7 +41,9 @@ data class MessageInformationData(
val readReceipts: List<ReadReceiptData> = emptyList(), val readReceipts: List<ReadReceiptData> = emptyList(),
val referencesInfoData: ReferencesInfoData? = null, val referencesInfoData: ReferencesInfoData? = null,
val sentByMe : Boolean, val sentByMe : Boolean,
val e2eDecoration: E2EDecoration = E2EDecoration.NONE val e2eDecoration: E2EDecoration = E2EDecoration.NONE,
// used for file messages
val isDowloaded: Boolean = true
) : Parcelable { ) : Parcelable {
val matrixItem: MatrixItem val matrixItem: MatrixItem

View file

@ -49,6 +49,7 @@ class ImageContentRenderer @Inject constructor(private val activeSessionHolder:
data class Data( data class Data(
val eventId: String, val eventId: String,
val filename: String, val filename: String,
val mimeType: String?,
val url: String?, val url: String?,
val elementToDecrypt: ElementToDecrypt?, val elementToDecrypt: ElementToDecrypt?,
val height: Int?, val height: Int?,

View file

@ -137,6 +137,7 @@ class ImageMediaViewerActivity : VectorBaseActivity() {
FileService.DownloadMode.FOR_EXTERNAL_SHARE, FileService.DownloadMode.FOR_EXTERNAL_SHARE,
mediaData.eventId, mediaData.eventId,
mediaData.filename, mediaData.filename,
mediaData.mimeType,
mediaData.url, mediaData.url,
mediaData.elementToDecrypt, mediaData.elementToDecrypt,
object : MatrixCallback<File> { object : MatrixCallback<File> {

View file

@ -40,6 +40,7 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
data class Data( data class Data(
val eventId: String, val eventId: String,
val filename: String, val filename: String,
val mimeType: String?,
val url: String?, val url: String?,
val elementToDecrypt: ElementToDecrypt?, val elementToDecrypt: ElementToDecrypt?,
val thumbnailMediaData: ImageContentRenderer.Data val thumbnailMediaData: ImageContentRenderer.Data
@ -66,12 +67,13 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
activeSessionHolder.getActiveSession() activeSessionHolder.getActiveSession()
.downloadFile( .downloadFile(
FileService.DownloadMode.FOR_INTERNAL_USE, downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
data.eventId, id = data.eventId,
data.filename, fileName = data.filename,
data.url, mimeType = null,
data.elementToDecrypt, url = data.url,
object : MatrixCallback<File> { elementToDecrypt = data.elementToDecrypt,
callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {
thumbnailView.isVisible = false thumbnailView.isVisible = false
loadingView.isVisible = false loadingView.isVisible = false
@ -104,12 +106,13 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
activeSessionHolder.getActiveSession() activeSessionHolder.getActiveSession()
.downloadFile( .downloadFile(
FileService.DownloadMode.FOR_INTERNAL_USE, downloadMode = FileService.DownloadMode.FOR_INTERNAL_USE,
data.eventId, id = data.eventId,
data.filename, fileName = data.filename,
data.url, mimeType = data.mimeType,
null, url = data.url,
object : MatrixCallback<File> { elementToDecrypt = null,
callback = object : MatrixCallback<File> {
override fun onSuccess(data: File) { override fun onSuccess(data: File) {
thumbnailView.isVisible = false thumbnailView.isVisible = false
loadingView.isVisible = false loadingView.isVisible = false

View file

@ -82,6 +82,7 @@ class VideoMediaViewerActivity : VectorBaseActivity() {
FileService.DownloadMode.FOR_EXTERNAL_SHARE, FileService.DownloadMode.FOR_EXTERNAL_SHARE,
mediaData.eventId, mediaData.eventId,
mediaData.filename, mediaData.filename,
mediaData.mimeType,
mediaData.url, mediaData.url,
mediaData.elementToDecrypt, mediaData.elementToDecrypt,
object : MatrixCallback<File> { object : MatrixCallback<File> {

View file

@ -137,12 +137,13 @@ class RoomUploadsViewModel @AssistedInject constructor(
try { try {
val file = awaitCallback<File> { val file = awaitCallback<File> {
session.downloadFile( session.downloadFile(
FileService.DownloadMode.FOR_EXTERNAL_SHARE, downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
action.uploadEvent.eventId, id = action.uploadEvent.eventId,
action.uploadEvent.contentWithAttachmentContent.body, fileName = action.uploadEvent.contentWithAttachmentContent.body,
action.uploadEvent.contentWithAttachmentContent.getFileUrl(), url = action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(), mimeType = action.uploadEvent.contentWithAttachmentContent.mimeType,
it elementToDecrypt = action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
callback = it
) )
} }
_viewEvents.post(RoomUploadsViewEvents.FileReadyForSharing(file)) _viewEvents.post(RoomUploadsViewEvents.FileReadyForSharing(file))
@ -161,6 +162,7 @@ class RoomUploadsViewModel @AssistedInject constructor(
action.uploadEvent.eventId, action.uploadEvent.eventId,
action.uploadEvent.contentWithAttachmentContent.body, action.uploadEvent.contentWithAttachmentContent.body,
action.uploadEvent.contentWithAttachmentContent.getFileUrl(), action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
action.uploadEvent.contentWithAttachmentContent.mimeType,
action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(), action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
it) it)
} }

View file

@ -115,6 +115,7 @@ class UploadsMediaController @Inject constructor(
eventId = eventId, eventId = eventId,
filename = messageContent.body, filename = messageContent.body,
url = messageContent.getFileUrl(), url = messageContent.getFileUrl(),
mimeType = messageContent.mimeType,
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(), elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
height = messageContent.info?.height, height = messageContent.info?.height,
maxHeight = itemSize, maxHeight = itemSize,
@ -129,6 +130,7 @@ class UploadsMediaController @Inject constructor(
val thumbnailData = ImageContentRenderer.Data( val thumbnailData = ImageContentRenderer.Data(
eventId = eventId, eventId = eventId,
filename = messageContent.body, filename = messageContent.body,
mimeType = messageContent.mimeType,
url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl, url = messageContent.videoInfo?.thumbnailFile?.url ?: messageContent.videoInfo?.thumbnailUrl,
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(), elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
height = messageContent.videoInfo?.height, height = messageContent.videoInfo?.height,
@ -140,6 +142,7 @@ class UploadsMediaController @Inject constructor(
return VideoContentRenderer.Data( return VideoContentRenderer.Data(
eventId = eventId, eventId = eventId,
filename = messageContent.body, filename = messageContent.body,
mimeType = messageContent.mimeType,
url = messageContent.getFileUrl(), url = messageContent.getFileUrl(),
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(), elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
thumbnailMediaData = thumbnailData thumbnailMediaData = thumbnailData

View file

@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?>
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:id="@android:id/background">
<shape>
<corners android:radius="8dp" />
<solid android:color="?attr/riotx_room_active_widgets_banner_bg" />
</shape>
</item>
<item android:id="@android:id/progress">
<clip>
<shape>
<corners android:radius="8dp" />
<solid android:color="@color/riotx_notice_secondary_alpha12" />
</shape>
</clip>
</item>
</layer-list>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M21.7184,11.4122L12.5284,20.6022C10.1839,22.9467 6.3828,22.9467 4.0384,20.6022C1.6939,18.2578 1.6939,14.4567 4.0384,12.1122L13.2284,2.9222C14.7913,1.3593 17.3254,1.3593 18.8884,2.9222C20.4513,4.4852 20.4513,7.0193 18.8884,8.5822L9.6884,17.7722C8.9069,18.5537 7.6399,18.5537 6.8584,17.7722C6.0769,16.9907 6.0769,15.7237 6.8584,14.9422L15.3484,6.4622"
android:strokeLineJoin="round"
android:strokeWidth="2"
android:fillColor="#00000000"
android:strokeColor="#2E2F32"
android:strokeLineCap="round"/>
</vector>

View file

@ -12,22 +12,33 @@
android:id="@+id/messageFilee2eIcon" android:id="@+id/messageFilee2eIcon"
android:layout_width="14dp" android:layout_width="14dp"
android:layout_height="14dp" android:layout_height="14dp"
android:src="@drawable/e2e_verified" android:src="@drawable/ic_shield_black"
android:visibility="gone" android:visibility="gone"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" /> tools:visibility="visible" />
<!-- the media type --> <!-- the media type -->
<ImageView <RelativeLayout
android:id="@+id/messageFileImageView" android:id="@+id/messageFileImageView"
android:layout_width="@dimen/chat_avatar_size" android:layout_width="40dp"
android:layout_height="@dimen/chat_avatar_size" android:layout_height="40dp"
android:layout_marginStart="4dp" android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
app:layout_constraintStart_toEndOf="@+id/messageFilee2eIcon" app:layout_constraintStart_toEndOf="@+id/messageFilee2eIcon"
app:layout_constraintTop_toTopOf="parent" app:layout_constraintTop_toTopOf="parent">
tools:src="@drawable/filetype_attachment" />
<include layout="@layout/view_file_icon" />
</RelativeLayout>
<!-- <ImageView-->
<!-- android:id="@+id/messageFileImageView"-->
<!-- android:layout_width="@dimen/chat_avatar_size"-->
<!-- android:layout_height="@dimen/chat_avatar_size"-->
<!-- android:layout_marginStart="4dp"-->
<!-- android:layout_marginLeft="4dp"-->
<!-- app:layout_constraintStart_toEndOf="@+id/messageFilee2eIcon"-->
<!-- app:layout_constraintTop_toTopOf="parent"-->
<!-- tools:src="@drawable/filetype_attachment" />-->
<!-- the media --> <!-- the media -->
<TextView <TextView

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
tools:parentTag="android.widget.RelativeLayout"
android:layout_width="50dp"
android:layout_height="50dp">
<ProgressBar
android:id="@+id/messageFileProgressbar"
style="@style/FileProgressBar"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:progress="40" />
<ImageView
android:id="@+id/messageFileIconView"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_centerInParent="true"
android:src="@drawable/ic_download"
android:tint="?vctr_notice_secondary"
tools:src="@drawable/ic_paperclip" />
</merge>

View file

@ -21,6 +21,7 @@
<color name="riotx_notice">#FFFF4B55</color> <color name="riotx_notice">#FFFF4B55</color>
<color name="riotx_notice_secondary">#FF61708B</color> <color name="riotx_notice_secondary">#FF61708B</color>
<color name="riotx_notice_secondary_alpha12">#1E61708B</color>
<color name="riotx_links">#FF368BD6</color> <color name="riotx_links">#FF368BD6</color>
<color name="riotx_avatar_fill_1">#FF03b381</color> <color name="riotx_avatar_fill_1">#FF03b381</color>

View file

@ -378,4 +378,11 @@
<item name="android:layout_marginTop">8dp</item> <item name="android:layout_marginTop">8dp</item>
</style> </style>
<style name="FileProgressBar" parent="android:Widget.ProgressBar.Horizontal">
<item name="android:indeterminateOnly">false</item>
<item name="android:progressDrawable">@drawable/file_progress_bar</item>
<item name="android:minHeight">10dp</item>
<item name="android:maxHeight">40dp</item>
</style>
</resources> </resources>