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:exported="false"
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>
</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.user.UserService
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.
@ -152,6 +153,11 @@ interface Session :
*/
fun typingUsersTracker(): TypingUsersTracker
/**
* Returns the ContentDownloadStateTracker associated with the session
*/
fun contentDownloadProgressTracker(): ContentDownloadStateTracker
/**
* Returns the cryptoService associated with the session
*/

View file

@ -235,3 +235,11 @@ fun Event.isVideoMessage(): Boolean {
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
import android.net.Uri
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
@ -50,7 +51,16 @@ interface FileService {
downloadMode: DownloadMode,
id: String,
fileName: String,
mimeType: String?,
url: String?,
elementToDecrypt: ElementToDecrypt?,
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.
*/
@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
import android.content.ClipDescription
import android.webkit.MimeTypeMap
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.api.session.events.model.Content
@ -59,11 +59,11 @@ data class MessageFileContent(
@Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
) : MessageWithAttachmentContent {
fun getMimeType(): String {
// Mimetype default to plain text, should not be used
return encryptedFileInfo?.mimetype
override val mimeType: String?
get() = encryptedFileInfo?.mimetype
?: info?.mimeType
?: ClipDescription.MIMETYPE_TEXT_PLAIN
?: MimeTypeMap.getFileExtensionFromUrl(filename ?: body)?.let { extension ->
MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
}
fun getFileName(): String {

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.
*/
@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.
*/
@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.
*/
@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.
*/
val encryptedFileInfo: EncryptedFileInfo?
val mimeType: String?
}
/**
* Get the url of the encrypted file or of the file
*/
fun MessageWithAttachmentContent.getFileUrl() = encryptedFileInfo?.url ?: url
fun MessageWithAttachmentContent.getFileName() = (this as? MessageFileContent)?.getFileName() ?: body

View file

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

View file

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

View file

@ -16,6 +16,10 @@
package im.vector.matrix.android.internal.session
import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.core.content.FileProvider
import arrow.core.Try
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.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.di.CacheDirectory
import im.vector.matrix.android.internal.di.ExternalFilesDirectory
import im.vector.matrix.android.internal.di.SessionCacheDirectory
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate
import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
import im.vector.matrix.android.internal.di.WithProgress
import im.vector.matrix.android.internal.extensions.foldToCallback
import im.vector.matrix.android.internal.task.TaskExecutor
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
@ -39,22 +43,28 @@ import okhttp3.Request
import timber.log.Timber
import java.io.File
import java.io.IOException
import java.net.URLEncoder
import javax.inject.Inject
internal class DefaultFileService @Inject constructor(
private val context: Context,
@CacheDirectory
private val cacheDirectory: File,
@ExternalFilesDirectory
private val externalFilesDirectory: File?,
@SessionCacheDirectory
@SessionDownloadsDirectory
private val sessionCacheDirectory: File,
private val contentUrlResolver: ContentUrlResolver,
@UnauthenticatedWithCertificate
@WithProgress
private val okHttpClient: OkHttpClient,
private val coroutineDispatchers: MatrixCoroutineDispatchers,
private val taskExecutor: TaskExecutor
) : FileService {
private fun String.safeFileName() = URLEncoder.encode(this, Charsets.US_ASCII.displayName())
private val downloadFolder = File(sessionCacheDirectory, "MF")
/**
* Download file in the cache folder, and eventually decrypt it
* TODO implement clear file, to delete "MF"
@ -63,23 +73,28 @@ internal class DefaultFileService @Inject constructor(
override fun downloadFile(downloadMode: FileService.DownloadMode,
id: String,
fileName: String,
mimeType: String?,
url: String?,
elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback<File>): Cancelable {
return taskExecutor.executorScope.launch(coroutineDispatchers.main) {
withContext(coroutineDispatchers.io) {
Try {
val folder = File(sessionCacheDirectory, "MF")
if (!folder.exists()) {
folder.mkdirs()
val unwrappedUrl = url ?: throw IllegalArgumentException("url is null")
if (!downloadFolder.exists()) {
downloadFolder.mkdirs()
}
File(folder, fileName)
// ensure we use unique file name by using URL (mapped to suitable file name)
// Also we need to add extension for the FileProvider, if not it lot's of app that it's
// shared with will not function well (even if mime type is passed in the intent)
File(downloadFolder, fileForUrl(unwrappedUrl, mimeType))
}.flatMap { destFile ->
if (!destFile.exists()) {
val resolvedUrl = contentUrlResolver.resolveFullSize(url) ?: return@flatMap Try.Failure(IllegalArgumentException("url is null"))
val request = Request.Builder()
.url(resolvedUrl)
.header("matrix-sdk:mxc_URL", url ?: "")
.build()
val response = try {
@ -121,6 +136,27 @@ internal class DefaultFileService @Inject constructor(
}.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 {
return when (downloadMode) {
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.di.SessionId
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.room.timeline.TimelineEventDecryptor
import im.vector.matrix.android.internal.session.sync.SyncTokenStore
@ -100,6 +101,7 @@ internal class DefaultSession @Inject constructor(
private val sessionParamsStore: SessionParamsStore,
private val contentUploadProgressTracker: ContentUploadStateTracker,
private val typingUsersTracker: TypingUsersTracker,
private val contentDownloadStateTracker: ContentDownloadStateTracker,
private val initialSyncProgressService: Lazy<InitialSyncProgressService>,
private val homeServerCapabilitiesService: Lazy<HomeServerCapabilitiesService>,
private val accountDataService: Lazy<AccountDataService>,
@ -239,6 +241,8 @@ internal class DefaultSession @Inject constructor(
override fun typingUsersTracker() = typingUsersTracker
override fun contentDownloadProgressTracker(): ContentDownloadStateTracker = contentDownloadStateTracker
override fun cryptoService(): CryptoService = cryptoService.get()
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.di.Authenticated
import im.vector.matrix.android.internal.di.DeviceId
import im.vector.matrix.android.internal.di.SessionCacheDirectory
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
import im.vector.matrix.android.internal.di.SessionFilesDirectory
import im.vector.matrix.android.internal.di.SessionId
import im.vector.matrix.android.internal.di.Unauthenticated
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate
import im.vector.matrix.android.internal.di.UserId
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.network.DefaultNetworkConnectivityChecker
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.httpclient.addAccessTokenInterceptor
import im.vector.matrix.android.internal.network.httpclient.addSocketFactory
import im.vector.matrix.android.internal.network.interceptors.CurlLoggingInterceptor
import im.vector.matrix.android.internal.network.token.AccessTokenProvider
import im.vector.matrix.android.internal.network.token.HomeserverAccessTokenProvider
import im.vector.matrix.android.internal.session.call.CallEventObserver
import im.vector.matrix.android.internal.session.download.DownloadProgressInterceptor
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
import im.vector.matrix.android.internal.session.homeserver.DefaultHomeServerCapabilitiesService
import im.vector.matrix.android.internal.session.identity.DefaultIdentityService
@ -160,10 +163,10 @@ internal abstract class SessionModule {
@JvmStatic
@Provides
@SessionCacheDirectory
@SessionDownloadsDirectory
fun providesCacheDir(@SessionId sessionId: String,
context: Context): File {
return File(context.cacheDir, sessionId)
return File(context.cacheDir, "downloads/$sessionId")
}
@JvmStatic
@ -216,6 +219,27 @@ internal abstract class SessionModule {
.build()
}
@JvmStatic
@Provides
@SessionScope
@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
@Provides
@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.database.RealmKeysUtils
import im.vector.matrix.android.internal.di.CryptoDatabase
import im.vector.matrix.android.internal.di.SessionCacheDirectory
import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
import im.vector.matrix.android.internal.di.SessionDatabase
import im.vector.matrix.android.internal.di.SessionFilesDirectory
import im.vector.matrix.android.internal.di.SessionId
@ -44,7 +44,7 @@ internal class CleanupSession @Inject constructor(
@SessionDatabase private val clearSessionDataTask: ClearCacheTask,
@CryptoDatabase private val clearCryptoDataTask: ClearCacheTask,
@SessionFilesDirectory private val sessionFiles: File,
@SessionCacheDirectory private val sessionCache: File,
@SessionDownloadsDirectory private val sessionCache: File,
private val realmKeysUtils: RealmKeysUtils,
@SessionDatabase private val realmSessionConfiguration: RealmConfiguration,
@CryptoDatabase private val realmCryptoConfiguration: RealmConfiguration,

View file

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

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

@ -254,7 +254,7 @@
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/riotx_provider_paths" />
android:resource="@xml/sdk_provider_paths" />
</provider>
</application>

View file

@ -35,6 +35,7 @@ import im.vector.riotx.BuildConfig
import im.vector.riotx.R
import okio.buffer
import okio.sink
import okio.source
import timber.log.Timber
import java.io.File
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.openOutputStream(uri)?.use { outputStream ->
outputStream.sink().buffer().write(file.inputStream().use { it.readBytes() })
return true
val source = file.inputStream().source().buffer()
context.contentResolver.openOutputStream(uri)?.sink()?.buffer()?.let { sink ->
source.use { input ->
sink.use { output ->
output.writeAll(input)
}
}
}
}
// TODO add notification?
} else {
@Suppress("DEPRECATION")
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.events.model.Event
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.model.message.MessageStickerContent
import im.vector.matrix.android.api.session.room.model.message.MessageWithAttachmentContent
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotx.core.platform.VectorViewModelAction
@ -39,7 +39,7 @@ sealed class RoomDetailAction : VectorViewModelAction {
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailAction()
data class NavigateToEvent(val eventId: String, val highlight: Boolean) : RoomDetailAction()
object MarkAllAsRead : RoomDetailAction()
data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailAction()
data class DownloadOrOpen(val eventId: String, val messageFileContent: MessageWithAttachmentContent) : RoomDetailAction()
data class HandleTombstoneEvent(val event: Event) : RoomDetailAction()
object AcceptInvite : RoomDetailAction()
object RejectInvite : RoomDetailAction()

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

View file

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

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

View file

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

View file

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

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.session.Session
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.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.MessageWithAttachmentContent
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
import im.vector.matrix.android.api.session.room.send.SendState
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.api.session.room.timeline.getLastMessageContent
@ -111,7 +115,8 @@ class MessageInformationDataFactory @Inject constructor(private val session: Ses
ReferencesInfoData(verificationState)
},
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
import android.graphics.Paint
import android.view.View
import android.view.ViewGroup
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
import androidx.annotation.DrawableRes
import androidx.core.view.isVisible
import com.airbnb.epoxy.EpoxyAttribute
import com.airbnb.epoxy.EpoxyModelClass
import im.vector.riotx.R
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
@ -33,16 +34,26 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
@EpoxyAttribute
var filename: CharSequence = ""
@EpoxyAttribute
var mxcUrl: String = ""
@EpoxyAttribute
@DrawableRes
var iconRes: Int = 0
@EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
var clickListener: View.OnClickListener? = null
// @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash)
// var clickListener: View.OnClickListener? = null
@EpoxyAttribute
var izLocalFile = false
@EpoxyAttribute
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
@EpoxyAttribute
lateinit var contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder
override fun bind(holder: Holder) {
super.bind(holder)
renderSendState(holder.fileLayout, holder.filenameView)
@ -51,15 +62,30 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
} else {
holder.progressLayout.isVisible = false
}
if (!attributes.informationData.isDowloaded) {
contentDownloadStateTrackerBinder.bind(mxcUrl, holder)
}
holder.filenameView.text = filename
if (attributes.informationData.isDowloaded) {
holder.fileImageView.setImageResource(iconRes)
holder.filenameView.setOnClickListener(clickListener)
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)
}
override fun unbind(holder: Holder) {
super.unbind(holder)
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
contentDownloadStateTrackerBinder.unbind(mxcUrl)
}
override fun getViewType() = STUB_ID
@ -67,7 +93,9 @@ abstract class MessageFileItem : AbsMessageItem<MessageFileItem.Holder>() {
class Holder : AbsMessageItem.Holder(STUB_ID) {
val progressLayout by bind<ViewGroup>(R.id.messageFileUploadProgressLayout)
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)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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:layout_width="14dp"
android:layout_height="14dp"
android:src="@drawable/e2e_verified"
android:src="@drawable/ic_shield_black"
android:visibility="gone"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:visibility="visible" />
<!-- the media type -->
<ImageView
<RelativeLayout
android:id="@+id/messageFileImageView"
android:layout_width="@dimen/chat_avatar_size"
android:layout_height="@dimen/chat_avatar_size"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginStart="4dp"
android:layout_marginLeft="4dp"
app:layout_constraintStart_toEndOf="@+id/messageFilee2eIcon"
app:layout_constraintTop_toTopOf="parent"
tools:src="@drawable/filetype_attachment" />
app:layout_constraintTop_toTopOf="parent">
<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 -->
<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_secondary">#FF61708B</color>
<color name="riotx_notice_secondary_alpha12">#1E61708B</color>
<color name="riotx_links">#FF368BD6</color>
<color name="riotx_avatar_fill_1">#FF03b381</color>

View file

@ -378,4 +378,11 @@
<item name="android:layout_marginTop">8dp</item>
</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>