Merge pull request #1536 from vector-im/feature/save_file
Improove file download
This commit is contained in:
commit
901cf15a79
|
@ -12,6 +12,7 @@ Improvements 🙌:
|
|||
- Room Settings: Name, Topic, Photo, Aliases, History Visibility (#1455)
|
||||
- Update user avatar (#1054)
|
||||
- Allow self-signed certificate (#1564)
|
||||
- Improve file download and open in timeline
|
||||
|
||||
Bugfix 🐛:
|
||||
- Fix dark theme issue on login screen (#1097)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -28,6 +28,7 @@ import im.vector.matrix.android.api.session.call.CallSignalingService
|
|||
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
|
||||
import im.vector.matrix.android.api.session.content.ContentUrlResolver
|
||||
import im.vector.matrix.android.api.session.crypto.CryptoService
|
||||
import im.vector.matrix.android.api.session.file.ContentDownloadStateTracker
|
||||
import im.vector.matrix.android.api.session.file.FileService
|
||||
import im.vector.matrix.android.api.session.group.GroupService
|
||||
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilitiesService
|
||||
|
@ -59,7 +60,6 @@ interface Session :
|
|||
CacheService,
|
||||
SignOutService,
|
||||
FilterService,
|
||||
FileService,
|
||||
TermsService,
|
||||
ProfileService,
|
||||
PushRuleService,
|
||||
|
@ -152,6 +152,11 @@ interface Session :
|
|||
*/
|
||||
fun typingUsersTracker(): TypingUsersTracker
|
||||
|
||||
/**
|
||||
* Returns the ContentDownloadStateTracker associated with the session
|
||||
*/
|
||||
fun contentDownloadProgressTracker(): ContentDownloadStateTracker
|
||||
|
||||
/**
|
||||
* Returns the cryptoService associated with the session
|
||||
*/
|
||||
|
@ -177,6 +182,11 @@ interface Session :
|
|||
*/
|
||||
fun callSignalingService(): CallSignalingService
|
||||
|
||||
/**
|
||||
* Returns the file download service associated with the session
|
||||
*/
|
||||
fun fileService(): FileService
|
||||
|
||||
/**
|
||||
* Add a listener to the session.
|
||||
* @param listener the listener to add.
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,35 @@
|
|||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.api.session.file
|
||||
|
||||
interface ContentDownloadStateTracker {
|
||||
fun track(key: String, updateListener: UpdateListener)
|
||||
fun unTrack(key: String, updateListener: UpdateListener)
|
||||
fun clear()
|
||||
|
||||
sealed class State {
|
||||
object Idle : State()
|
||||
data class Downloading(val current: Long, val total: Long, val indeterminate: Boolean) : State()
|
||||
object Decrypting : State()
|
||||
object Success : State()
|
||||
data class Failure(val errorCode: Int) : State()
|
||||
}
|
||||
|
||||
interface UpdateListener {
|
||||
fun onDownloadStateUpdate(state: State)
|
||||
}
|
||||
}
|
|
@ -16,6 +16,7 @@
|
|||
|
||||
package im.vector.matrix.android.api.session.file
|
||||
|
||||
import android.net.Uri
|
||||
import im.vector.matrix.android.api.MatrixCallback
|
||||
import im.vector.matrix.android.api.util.Cancelable
|
||||
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
|
||||
|
@ -31,26 +32,58 @@ interface FileService {
|
|||
* Download file in external storage
|
||||
*/
|
||||
TO_EXPORT,
|
||||
|
||||
/**
|
||||
* Download file in cache
|
||||
*/
|
||||
FOR_INTERNAL_USE,
|
||||
|
||||
/**
|
||||
* Download file in file provider path
|
||||
*/
|
||||
FOR_EXTERNAL_SHARE
|
||||
}
|
||||
|
||||
enum class FileState {
|
||||
IN_CACHE,
|
||||
DOWNLOADING,
|
||||
UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file.
|
||||
* Result will be a decrypted file, stored in the cache folder. id parameter will be used to create a sub folder to avoid name collision.
|
||||
* You can pass the eventId
|
||||
* Result will be a decrypted file, stored in the cache folder. url parameter will be used to create unique filename to avoid name collision.
|
||||
*/
|
||||
fun downloadFile(
|
||||
downloadMode: DownloadMode,
|
||||
id: String,
|
||||
fileName: String,
|
||||
mimeType: String?,
|
||||
url: String?,
|
||||
elementToDecrypt: ElementToDecrypt?,
|
||||
callback: MatrixCallback<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?
|
||||
|
||||
/**
|
||||
* Get information on the given file.
|
||||
* Mimetype should be the same one as passed to downloadFile (limitation for now)
|
||||
*/
|
||||
fun fileState(mxcUrl: String, mimeType: String?): FileState
|
||||
|
||||
/**
|
||||
* Clears all the files downloaded by the service
|
||||
*/
|
||||
fun clearCache()
|
||||
|
||||
/**
|
||||
* Get size of cached files
|
||||
*/
|
||||
fun getCacheSize(): Int
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
|
||||
package im.vector.matrix.android.api.session.room.model.message
|
||||
|
||||
import android.content.ClipDescription
|
||||
import android.webkit.MimeTypeMap
|
||||
import com.squareup.moshi.Json
|
||||
import com.squareup.moshi.JsonClass
|
||||
import im.vector.matrix.android.api.session.events.model.Content
|
||||
|
@ -59,12 +59,12 @@ data class MessageFileContent(
|
|||
@Json(name = "file") override val encryptedFileInfo: EncryptedFileInfo? = null
|
||||
) : MessageWithAttachmentContent {
|
||||
|
||||
fun getMimeType(): String {
|
||||
// Mimetype default to plain text, should not be used
|
||||
return encryptedFileInfo?.mimetype
|
||||
override val mimeType: String?
|
||||
get() = encryptedFileInfo?.mimetype
|
||||
?: info?.mimeType
|
||||
?: ClipDescription.MIMETYPE_TEXT_PLAIN
|
||||
}
|
||||
?: MimeTypeMap.getFileExtensionFromUrl(filename ?: body)?.let { extension ->
|
||||
MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)
|
||||
}
|
||||
|
||||
fun getFileName(): String {
|
||||
return filename ?: body
|
||||
|
|
|
@ -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/*"
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -33,3 +33,7 @@ internal annotation class Unauthenticated
|
|||
@Qualifier
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
internal annotation class UnauthenticatedWithCertificate
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
internal annotation class UnauthenticatedWithCertificateWithProgress
|
||||
|
|
|
@ -24,7 +24,7 @@ internal annotation class SessionFilesDirectory
|
|||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
internal annotation class SessionCacheDirectory
|
||||
internal annotation class SessionDownloadsDirectory
|
||||
|
||||
@Qualifier
|
||||
@Retention(AnnotationRetention.RUNTIME)
|
||||
|
|
|
@ -16,18 +16,24 @@
|
|||
|
||||
package im.vector.matrix.android.internal.session
|
||||
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.core.content.FileProvider
|
||||
import arrow.core.Try
|
||||
import im.vector.matrix.android.api.MatrixCallback
|
||||
import im.vector.matrix.android.api.extensions.tryThis
|
||||
import im.vector.matrix.android.api.session.content.ContentUrlResolver
|
||||
import im.vector.matrix.android.api.session.file.FileService
|
||||
import im.vector.matrix.android.api.util.Cancelable
|
||||
import im.vector.matrix.android.api.util.NoOpCancellable
|
||||
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
|
||||
import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments
|
||||
import im.vector.matrix.android.internal.di.CacheDirectory
|
||||
import im.vector.matrix.android.internal.di.ExternalFilesDirectory
|
||||
import im.vector.matrix.android.internal.di.SessionCacheDirectory
|
||||
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate
|
||||
import im.vector.matrix.android.internal.extensions.foldToCallback
|
||||
import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
|
||||
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificateWithProgress
|
||||
import im.vector.matrix.android.internal.session.download.DownloadProgressInterceptor.Companion.DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER
|
||||
import im.vector.matrix.android.internal.task.TaskExecutor
|
||||
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
|
||||
import im.vector.matrix.android.internal.util.toCancelable
|
||||
|
@ -36,49 +42,88 @@ import kotlinx.coroutines.launch
|
|||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.net.URLEncoder
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DefaultFileService @Inject constructor(
|
||||
private val context: Context,
|
||||
@CacheDirectory
|
||||
private val cacheDirectory: File,
|
||||
@ExternalFilesDirectory
|
||||
private val externalFilesDirectory: File?,
|
||||
@SessionCacheDirectory
|
||||
@SessionDownloadsDirectory
|
||||
private val sessionCacheDirectory: File,
|
||||
private val contentUrlResolver: ContentUrlResolver,
|
||||
@UnauthenticatedWithCertificate
|
||||
@UnauthenticatedWithCertificateWithProgress
|
||||
private val okHttpClient: OkHttpClient,
|
||||
private val coroutineDispatchers: MatrixCoroutineDispatchers,
|
||||
private val taskExecutor: TaskExecutor
|
||||
) : FileService {
|
||||
|
||||
private fun String.safeFileName() = URLEncoder.encode(this, Charsets.US_ASCII.displayName())
|
||||
|
||||
private val downloadFolder = File(sessionCacheDirectory, "MF")
|
||||
|
||||
/**
|
||||
* Retain ongoing downloads to avoid re-downloading and already downloading file
|
||||
* map of mxCurl to callbacks
|
||||
*/
|
||||
private val ongoing = mutableMapOf<String, ArrayList<MatrixCallback<File>>>()
|
||||
|
||||
/**
|
||||
* Download file in the cache folder, and eventually decrypt it
|
||||
* TODO implement clear file, to delete "MF"
|
||||
* TODO looks like files are copied 3 times
|
||||
*/
|
||||
override fun downloadFile(downloadMode: FileService.DownloadMode,
|
||||
id: String,
|
||||
fileName: String,
|
||||
mimeType: String?,
|
||||
url: String?,
|
||||
elementToDecrypt: ElementToDecrypt?,
|
||||
callback: MatrixCallback<File>): Cancelable {
|
||||
val unwrappedUrl = url ?: return NoOpCancellable.also {
|
||||
callback.onFailure(IllegalArgumentException("url is null"))
|
||||
}
|
||||
|
||||
Timber.v("## FileService downloadFile $unwrappedUrl")
|
||||
|
||||
synchronized(ongoing) {
|
||||
val existing = ongoing[unwrappedUrl]
|
||||
if (existing != null) {
|
||||
Timber.v("## FileService downloadFile is already downloading.. ")
|
||||
existing.add(callback)
|
||||
return NoOpCancellable
|
||||
} else {
|
||||
// mark as tracked
|
||||
ongoing[unwrappedUrl] = ArrayList()
|
||||
// and proceed to download
|
||||
}
|
||||
}
|
||||
|
||||
return taskExecutor.executorScope.launch(coroutineDispatchers.main) {
|
||||
withContext(coroutineDispatchers.io) {
|
||||
Try {
|
||||
val folder = File(sessionCacheDirectory, "MF")
|
||||
if (!folder.exists()) {
|
||||
folder.mkdirs()
|
||||
if (!downloadFolder.exists()) {
|
||||
downloadFolder.mkdirs()
|
||||
}
|
||||
File(folder, fileName)
|
||||
// ensure we use unique file name by using URL (mapped to suitable file name)
|
||||
// Also we need to add extension for the FileProvider, if not it lot's of app that it's
|
||||
// shared with will not function well (even if mime type is passed in the intent)
|
||||
File(downloadFolder, fileForUrl(unwrappedUrl, mimeType))
|
||||
}.flatMap { destFile ->
|
||||
if (!destFile.exists()) {
|
||||
val resolvedUrl = contentUrlResolver.resolveFullSize(url) ?: return@flatMap Try.Failure(IllegalArgumentException("url is null"))
|
||||
|
||||
val request = Request.Builder()
|
||||
.url(resolvedUrl)
|
||||
.header(DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER, url)
|
||||
.build()
|
||||
|
||||
val response = try {
|
||||
|
@ -87,30 +132,104 @@ internal class DefaultFileService @Inject constructor(
|
|||
return@flatMap Try.Failure(e)
|
||||
}
|
||||
|
||||
var inputStream = response.body?.byteStream()
|
||||
Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${inputStream?.available()}")
|
||||
|
||||
if (!response.isSuccessful || inputStream == null) {
|
||||
if (!response.isSuccessful) {
|
||||
return@flatMap Try.Failure(IOException())
|
||||
}
|
||||
|
||||
val source = response.body?.source()
|
||||
?: return@flatMap Try.Failure(IOException())
|
||||
|
||||
Timber.v("Response size ${response.body?.contentLength()} - Stream available: ${!source.exhausted()}")
|
||||
|
||||
if (elementToDecrypt != null) {
|
||||
Timber.v("## decrypt file")
|
||||
inputStream = MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt)
|
||||
?: return@flatMap Try.Failure(IllegalStateException("Decryption error"))
|
||||
val decryptedStream = MXEncryptedAttachments.decryptAttachment(source.inputStream(), elementToDecrypt)
|
||||
response.close()
|
||||
if (decryptedStream == null) {
|
||||
return@flatMap Try.Failure(IllegalStateException("Decryption error"))
|
||||
} else {
|
||||
decryptedStream.use {
|
||||
writeToFile(decryptedStream, destFile)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
writeToFile(source.inputStream(), destFile)
|
||||
response.close()
|
||||
}
|
||||
|
||||
writeToFile(inputStream, destFile)
|
||||
}
|
||||
|
||||
Try.just(copyFile(destFile, downloadMode))
|
||||
}
|
||||
}
|
||||
.foldToCallback(callback)
|
||||
}.fold({
|
||||
callback.onFailure(it)
|
||||
// notify concurrent requests
|
||||
val toNotify = synchronized(ongoing) {
|
||||
ongoing[unwrappedUrl]?.also {
|
||||
ongoing.remove(unwrappedUrl)
|
||||
}
|
||||
}
|
||||
toNotify?.forEach { otherCallbacks ->
|
||||
tryThis { otherCallbacks.onFailure(it) }
|
||||
}
|
||||
}, { file ->
|
||||
callback.onSuccess(file)
|
||||
// notify concurrent requests
|
||||
val toNotify = synchronized(ongoing) {
|
||||
ongoing[unwrappedUrl]?.also {
|
||||
ongoing.remove(unwrappedUrl)
|
||||
}
|
||||
}
|
||||
Timber.v("## FileService additional to notify ${toNotify?.size ?: 0} ")
|
||||
toNotify?.forEach { otherCallbacks ->
|
||||
tryThis { otherCallbacks.onSuccess(file) }
|
||||
}
|
||||
})
|
||||
}.toCancelable()
|
||||
}
|
||||
|
||||
fun storeDataFor(url: String, mimeType: String?, inputStream: InputStream) {
|
||||
val file = File(downloadFolder, fileForUrl(url, mimeType))
|
||||
val source = inputStream.source().buffer()
|
||||
file.sink().buffer().let { sink ->
|
||||
source.use { input ->
|
||||
sink.use { output ->
|
||||
output.writeAll(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun fileForUrl(url: String, mimeType: String?): String {
|
||||
val extension = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) }
|
||||
return if (extension != null) "${url.safeFileName()}.$extension" else url.safeFileName()
|
||||
}
|
||||
|
||||
override fun isFileInCache(mxcUrl: String, mimeType: String?): Boolean {
|
||||
return File(downloadFolder, fileForUrl(mxcUrl, mimeType)).exists()
|
||||
}
|
||||
|
||||
override fun fileState(mxcUrl: String, mimeType: String?): FileService.FileState {
|
||||
if (isFileInCache(mxcUrl, mimeType)) return FileService.FileState.IN_CACHE
|
||||
val isDownloading = synchronized(ongoing) {
|
||||
ongoing[mxcUrl] != null
|
||||
}
|
||||
return if (isDownloading) FileService.FileState.DOWNLOADING else FileService.FileState.UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* Use this URI and pass it to intent using flag Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
* (if not other app won't be able to access it)
|
||||
*/
|
||||
override fun getTemporarySharableURI(mxcUrl: String, mimeType: String?): Uri? {
|
||||
// this string could be extracted no?
|
||||
val authority = "${context.packageName}.mx-sdk.fileprovider"
|
||||
val targetFile = File(downloadFolder, fileForUrl(mxcUrl, mimeType))
|
||||
if (!targetFile.exists()) return null
|
||||
return FileProvider.getUriForFile(context, authority, targetFile)
|
||||
}
|
||||
|
||||
private fun copyFile(file: File, downloadMode: FileService.DownloadMode): File {
|
||||
// TODO some of this seems outdated, will need to be re-worked
|
||||
return when (downloadMode) {
|
||||
FileService.DownloadMode.TO_EXPORT ->
|
||||
file.copyTo(File(externalFilesDirectory, file.name), true)
|
||||
|
@ -120,4 +239,17 @@ internal class DefaultFileService @Inject constructor(
|
|||
file
|
||||
}
|
||||
}
|
||||
|
||||
override fun getCacheSize(): Int {
|
||||
return downloadFolder.walkTopDown()
|
||||
.onEnter {
|
||||
Timber.v("Get size of ${it.absolutePath}")
|
||||
true
|
||||
}
|
||||
.sumBy { it.length().toInt() }
|
||||
}
|
||||
|
||||
override fun clearCache() {
|
||||
downloadFolder.deleteRecursively()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -32,6 +32,7 @@ import im.vector.matrix.android.api.session.call.CallSignalingService
|
|||
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
|
||||
import im.vector.matrix.android.api.session.content.ContentUrlResolver
|
||||
import im.vector.matrix.android.api.session.crypto.CryptoService
|
||||
import im.vector.matrix.android.api.session.file.ContentDownloadStateTracker
|
||||
import im.vector.matrix.android.api.session.file.FileService
|
||||
import im.vector.matrix.android.api.session.group.GroupService
|
||||
import im.vector.matrix.android.api.session.homeserver.HomeServerCapabilitiesService
|
||||
|
@ -90,7 +91,7 @@ internal class DefaultSession @Inject constructor(
|
|||
private val pushersService: Lazy<PushersService>,
|
||||
private val termsService: Lazy<TermsService>,
|
||||
private val cryptoService: Lazy<DefaultCryptoService>,
|
||||
private val fileService: Lazy<FileService>,
|
||||
private val defaultFileService: Lazy<FileService>,
|
||||
private val secureStorageService: Lazy<SecureStorageService>,
|
||||
private val profileService: Lazy<ProfileService>,
|
||||
private val widgetService: Lazy<WidgetService>,
|
||||
|
@ -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>,
|
||||
|
@ -120,7 +122,6 @@ internal class DefaultSession @Inject constructor(
|
|||
FilterService by filterService.get(),
|
||||
PushRuleService by pushRuleService.get(),
|
||||
PushersService by pushersService.get(),
|
||||
FileService by fileService.get(),
|
||||
TermsService by termsService.get(),
|
||||
InitialSyncProgressService by initialSyncProgressService.get(),
|
||||
SecureStorageService by secureStorageService.get(),
|
||||
|
@ -239,10 +240,14 @@ internal class DefaultSession @Inject constructor(
|
|||
|
||||
override fun typingUsersTracker() = typingUsersTracker
|
||||
|
||||
override fun contentDownloadProgressTracker(): ContentDownloadStateTracker = contentDownloadStateTracker
|
||||
|
||||
override fun cryptoService(): CryptoService = cryptoService.get()
|
||||
|
||||
override fun identityService() = defaultIdentityService
|
||||
|
||||
override fun fileService(): FileService = defaultFileService.get()
|
||||
|
||||
override fun widgetService(): WidgetService = widgetService.get()
|
||||
|
||||
override fun integrationManagerService() = integrationManagerService
|
||||
|
|
|
@ -43,12 +43,13 @@ import im.vector.matrix.android.internal.crypto.verification.VerificationMessage
|
|||
import im.vector.matrix.android.internal.database.SessionRealmConfigurationFactory
|
||||
import im.vector.matrix.android.internal.di.Authenticated
|
||||
import im.vector.matrix.android.internal.di.DeviceId
|
||||
import im.vector.matrix.android.internal.di.SessionCacheDirectory
|
||||
import im.vector.matrix.android.internal.di.SessionDatabase
|
||||
import im.vector.matrix.android.internal.di.SessionDownloadsDirectory
|
||||
import im.vector.matrix.android.internal.di.SessionFilesDirectory
|
||||
import im.vector.matrix.android.internal.di.SessionId
|
||||
import im.vector.matrix.android.internal.di.Unauthenticated
|
||||
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificate
|
||||
import im.vector.matrix.android.internal.di.UnauthenticatedWithCertificateWithProgress
|
||||
import im.vector.matrix.android.internal.di.UserId
|
||||
import im.vector.matrix.android.internal.di.UserMd5
|
||||
import im.vector.matrix.android.internal.eventbus.EventBusTimberLogger
|
||||
|
@ -60,9 +61,11 @@ import im.vector.matrix.android.internal.network.PreferredNetworkCallbackStrateg
|
|||
import im.vector.matrix.android.internal.network.RetrofitFactory
|
||||
import im.vector.matrix.android.internal.network.httpclient.addAccessTokenInterceptor
|
||||
import im.vector.matrix.android.internal.network.httpclient.addSocketFactory
|
||||
import im.vector.matrix.android.internal.network.interceptors.CurlLoggingInterceptor
|
||||
import im.vector.matrix.android.internal.network.token.AccessTokenProvider
|
||||
import im.vector.matrix.android.internal.network.token.HomeserverAccessTokenProvider
|
||||
import im.vector.matrix.android.internal.session.call.CallEventObserver
|
||||
import im.vector.matrix.android.internal.session.download.DownloadProgressInterceptor
|
||||
import im.vector.matrix.android.internal.session.group.GroupSummaryUpdater
|
||||
import im.vector.matrix.android.internal.session.homeserver.DefaultHomeServerCapabilitiesService
|
||||
import im.vector.matrix.android.internal.session.identity.DefaultIdentityService
|
||||
|
@ -160,10 +163,10 @@ internal abstract class SessionModule {
|
|||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@SessionCacheDirectory
|
||||
@SessionDownloadsDirectory
|
||||
fun providesCacheDir(@SessionId sessionId: String,
|
||||
context: Context): File {
|
||||
return File(context.cacheDir, sessionId)
|
||||
return File(context.cacheDir, "downloads/$sessionId")
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
|
@ -216,6 +219,27 @@ internal abstract class SessionModule {
|
|||
.build()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@SessionScope
|
||||
@UnauthenticatedWithCertificateWithProgress
|
||||
fun providesProgressOkHttpClient(@UnauthenticatedWithCertificate okHttpClient: OkHttpClient,
|
||||
downloadProgressInterceptor: DownloadProgressInterceptor): OkHttpClient {
|
||||
return okHttpClient.newBuilder()
|
||||
.apply {
|
||||
// Remove the previous CurlLoggingInterceptor, to add it after the accessTokenInterceptor
|
||||
val existingCurlInterceptors = interceptors().filterIsInstance<CurlLoggingInterceptor>()
|
||||
interceptors().removeAll(existingCurlInterceptors)
|
||||
|
||||
addInterceptor(downloadProgressInterceptor)
|
||||
|
||||
// Re add eventually the curl logging interceptors
|
||||
existingCurlInterceptors.forEach {
|
||||
addInterceptor(it)
|
||||
}
|
||||
}.build()
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@Provides
|
||||
@SessionScope
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -20,6 +20,8 @@ import dagger.Binds
|
|||
import dagger.Module
|
||||
import im.vector.matrix.android.api.session.content.ContentUploadStateTracker
|
||||
import im.vector.matrix.android.api.session.content.ContentUrlResolver
|
||||
import im.vector.matrix.android.api.session.file.ContentDownloadStateTracker
|
||||
import im.vector.matrix.android.internal.session.download.DefaultContentDownloadStateTracker
|
||||
|
||||
@Module
|
||||
internal abstract class ContentModule {
|
||||
|
@ -27,6 +29,9 @@ internal abstract class ContentModule {
|
|||
@Binds
|
||||
abstract fun bindContentUploadStateTracker(tracker: DefaultContentUploadStateTracker): ContentUploadStateTracker
|
||||
|
||||
@Binds
|
||||
abstract fun bindContentDownloadStateTracker(tracker: DefaultContentDownloadStateTracker): ContentDownloadStateTracker
|
||||
|
||||
@Binds
|
||||
abstract fun bindContentUrlResolver(resolver: DefaultContentUrlResolver): ContentUrlResolver
|
||||
}
|
||||
|
|
|
@ -35,6 +35,7 @@ import im.vector.matrix.android.api.session.room.model.message.MessageVideoConte
|
|||
import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments
|
||||
import im.vector.matrix.android.internal.crypto.model.rest.EncryptedFileInfo
|
||||
import im.vector.matrix.android.internal.network.ProgressRequestBody
|
||||
import im.vector.matrix.android.internal.session.DefaultFileService
|
||||
import im.vector.matrix.android.internal.session.room.send.MultipleEventSendingDispatcherWorker
|
||||
import im.vector.matrix.android.internal.worker.SessionWorkerParams
|
||||
import im.vector.matrix.android.internal.worker.WorkerParamsFactory
|
||||
|
@ -71,6 +72,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
|
||||
@Inject lateinit var fileUploader: FileUploader
|
||||
@Inject lateinit var contentUploadStateTracker: DefaultContentUploadStateTracker
|
||||
@Inject lateinit var fileService: DefaultFileService
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val params = WorkerParamsFactory.fromData<Params>(inputData)
|
||||
|
@ -210,6 +212,13 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
|
|||
.uploadFile(cacheFile, attachment.name, attachment.getSafeMimeType(), progressListener)
|
||||
}
|
||||
|
||||
// If it's a file update the file service so that it does not redownload?
|
||||
if (params.attachment.type == ContentAttachmentData.Type.FILE) {
|
||||
context.contentResolver.openInputStream(attachment.queryUri)?.let {
|
||||
fileService.storeDataFor(contentUploadResponse.contentUri, params.attachment.getSafeMimeType(), it)
|
||||
}
|
||||
}
|
||||
|
||||
handleSuccess(params,
|
||||
contentUploadResponse.contentUri,
|
||||
uploadedFileEncryptedFileInfo,
|
||||
|
|
|
@ -0,0 +1,85 @@
|
|||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.internal.session.download
|
||||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import im.vector.matrix.android.api.extensions.tryThis
|
||||
import im.vector.matrix.android.api.session.file.ContentDownloadStateTracker
|
||||
import im.vector.matrix.android.internal.session.SessionScope
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
@SessionScope
|
||||
internal class DefaultContentDownloadStateTracker @Inject constructor() : ProgressListener, ContentDownloadStateTracker {
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
private val states = mutableMapOf<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)
|
||||
}
|
||||
val currentState = states[key] ?: ContentDownloadStateTracker.State.Idle
|
||||
mainHandler.post {
|
||||
try {
|
||||
updateListener.onDownloadStateUpdate(currentState)
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "## ContentUploadStateTracker.onUpdate() failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun unTrack(key: String, updateListener: ContentDownloadStateTracker.UpdateListener) {
|
||||
listeners[key]?.apply {
|
||||
remove(updateListener)
|
||||
}
|
||||
}
|
||||
|
||||
override fun clear() {
|
||||
states.clear()
|
||||
listeners.clear()
|
||||
}
|
||||
|
||||
// private fun URL.toKey() = toString()
|
||||
|
||||
override fun update(url: String, bytesRead: Long, contentLength: Long, done: Boolean) {
|
||||
Timber.v("## DL Progress url:$url read:$bytesRead total:$contentLength done:$done")
|
||||
if (done) {
|
||||
updateState(url, ContentDownloadStateTracker.State.Success)
|
||||
} else {
|
||||
updateState(url, ContentDownloadStateTracker.State.Downloading(bytesRead, contentLength, contentLength == -1L))
|
||||
}
|
||||
}
|
||||
|
||||
override fun error(url: String, errorCode: Int) {
|
||||
Timber.v("## DL Progress Error code:$errorCode")
|
||||
updateState(url, ContentDownloadStateTracker.State.Failure(errorCode))
|
||||
listeners[url]?.forEach {
|
||||
tryThis { it.onDownloadStateUpdate(ContentDownloadStateTracker.State.Failure(errorCode)) }
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateState(url: String, state: ContentDownloadStateTracker.State) {
|
||||
states[url] = state
|
||||
listeners[url]?.forEach {
|
||||
tryThis { it.onDownloadStateUpdate(state) }
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2020 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.matrix.android.internal.session.download
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import javax.inject.Inject
|
||||
|
||||
internal class DownloadProgressInterceptor @Inject constructor(
|
||||
private val downloadStateTracker: DefaultContentDownloadStateTracker
|
||||
) : Interceptor {
|
||||
|
||||
companion object {
|
||||
const val DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER = "matrix-sdk:mxc_URL"
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val url = chain.request().url.toUrl()
|
||||
val mxcURl = chain.request().header(DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER)
|
||||
|
||||
val request = chain.request().newBuilder()
|
||||
.removeHeader(DOWNLOAD_PROGRESS_INTERCEPTOR_HEADER)
|
||||
.build()
|
||||
|
||||
val originalResponse = chain.proceed(request)
|
||||
if (!originalResponse.isSuccessful) {
|
||||
downloadStateTracker.error(mxcURl ?: url.toExternalForm(), originalResponse.code)
|
||||
return originalResponse
|
||||
}
|
||||
val responseBody = originalResponse.body ?: return originalResponse
|
||||
return originalResponse.newBuilder()
|
||||
.body(ProgressResponseBody(responseBody, mxcURl ?: url.toExternalForm(), downloadStateTracker))
|
||||
.build()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
|
@ -17,10 +17,8 @@
|
|||
package im.vector.matrix.android.internal.util
|
||||
|
||||
import androidx.annotation.WorkerThread
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import java.io.File
|
||||
import java.io.FileOutputStream
|
||||
import java.io.InputStream
|
||||
|
||||
/**
|
||||
|
@ -28,9 +26,7 @@ import java.io.InputStream
|
|||
*/
|
||||
@WorkerThread
|
||||
fun writeToFile(inputStream: InputStream, outputFile: File) {
|
||||
inputStream.source().buffer().use { input ->
|
||||
outputFile.sink().buffer().use { output ->
|
||||
output.writeAll(input)
|
||||
}
|
||||
FileOutputStream(outputFile).use {
|
||||
inputStream.copyTo(it)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path
|
||||
name="downloads"
|
||||
path="/" />
|
||||
</paths>
|
|
@ -164,7 +164,7 @@ Formatter\.formatShortFileSize===1
|
|||
# android\.text\.TextUtils
|
||||
|
||||
### This is not a rule, but a warning: the number of "enum class" has changed. For Json classes, it is mandatory that they have `@JsonClass(generateAdapter = false)`. If it is ok, change the value in file forbidden_strings_in_code.txt
|
||||
enum class===73
|
||||
enum class===74
|
||||
|
||||
### Do not import temporary legacy classes
|
||||
import im.vector.matrix.android.internal.legacy.riot===3
|
||||
|
|
|
@ -204,7 +204,7 @@
|
|||
|
||||
<service
|
||||
android:name=".core.services.CallService"
|
||||
android:exported="false" >
|
||||
android:exported="false">
|
||||
<!-- in order to get headset button events -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
|
@ -239,7 +239,7 @@
|
|||
A media button receiver receives and helps translate hardware media playback buttons,
|
||||
such as those found on wired and wireless headsets, into the appropriate callbacks in your app.
|
||||
-->
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver" >
|
||||
<receiver android:name="androidx.media.session.MediaButtonReceiver">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MEDIA_BUTTON" />
|
||||
</intent-filter>
|
||||
|
@ -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>
|
||||
|
||||
|
|
|
@ -19,12 +19,14 @@ package im.vector.riotx.core.files
|
|||
import android.app.DownloadManager
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.provider.MediaStore
|
||||
import androidx.annotation.WorkerThread
|
||||
import arrow.core.Try
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
|
||||
|
@ -56,7 +58,7 @@ fun addEntryToDownloadManager(context: Context,
|
|||
file: File,
|
||||
mimeType: String,
|
||||
title: String = file.name,
|
||||
description: String = file.name) {
|
||||
description: String = file.name) : Uri? {
|
||||
try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val contentValues = ContentValues().apply {
|
||||
|
@ -65,17 +67,31 @@ fun addEntryToDownloadManager(context: Context,
|
|||
put(MediaStore.Downloads.MIME_TYPE, mimeType)
|
||||
put(MediaStore.Downloads.SIZE, file.length())
|
||||
}
|
||||
context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)?.let { uri ->
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.sink().buffer().write(file.inputStream().use { it.readBytes() })
|
||||
return context.contentResolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, contentValues)
|
||||
?.let { uri ->
|
||||
Timber.v("## addEntryToDownloadManager(): $uri")
|
||||
val source = file.inputStream().source().buffer()
|
||||
context.contentResolver.openOutputStream(uri)?.sink()?.buffer()?.let { sink ->
|
||||
source.use { input ->
|
||||
sink.use { output ->
|
||||
output.writeAll(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
uri
|
||||
} ?: run {
|
||||
Timber.v("## addEntryToDownloadManager(): context.contentResolver.insert failed")
|
||||
|
||||
null
|
||||
}
|
||||
} else {
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager?
|
||||
val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as? DownloadManager
|
||||
@Suppress("DEPRECATION")
|
||||
downloadManager?.addCompletedDownload(title, description, true, mimeType, file.absolutePath, file.length(), true)
|
||||
return null
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Timber.e(e, "## addEntryToDownloadManager(): Exception")
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
|
|
@ -26,6 +26,7 @@ import android.net.Uri
|
|||
import android.os.Build
|
||||
import android.provider.Browser
|
||||
import android.provider.MediaStore
|
||||
import android.widget.Toast
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.browser.customtabs.CustomTabsSession
|
||||
import androidx.core.content.ContextCompat
|
||||
|
@ -33,8 +34,10 @@ import androidx.core.content.FileProvider
|
|||
import androidx.fragment.app.Fragment
|
||||
import im.vector.riotx.BuildConfig
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.features.notifications.NotificationUtils
|
||||
import okio.buffer
|
||||
import okio.sink
|
||||
import okio.source
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.text.SimpleDateFormat
|
||||
|
@ -296,7 +299,7 @@ fun shareMedia(context: Context, file: File, mediaMimeType: String?) {
|
|||
}
|
||||
}
|
||||
|
||||
fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String?): Boolean {
|
||||
fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String?, notificationUtils: NotificationUtils) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
val externalContentUri: Uri
|
||||
val values = ContentValues()
|
||||
|
@ -334,21 +337,34 @@ fun saveMedia(context: Context, file: File, title: String, mediaMimeType: String
|
|||
values.put(MediaStore.Downloads.DATE_TAKEN, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
context.contentResolver.insert(externalContentUri, values)?.let { uri ->
|
||||
context.contentResolver.openOutputStream(uri)?.use { outputStream ->
|
||||
outputStream.sink().buffer().write(file.inputStream().use { it.readBytes() })
|
||||
return true
|
||||
val uri = context.contentResolver.insert(externalContentUri, values)
|
||||
if (uri == null) {
|
||||
Toast.makeText(context, R.string.error_saving_media_file, Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
val source = file.inputStream().source().buffer()
|
||||
context.contentResolver.openOutputStream(uri)?.sink()?.buffer()?.let { sink ->
|
||||
source.use { input ->
|
||||
sink.use { output ->
|
||||
output.writeAll(input)
|
||||
}
|
||||
}
|
||||
}
|
||||
notificationUtils.buildDownloadFileNotification(
|
||||
uri,
|
||||
title,
|
||||
mediaMimeType ?: "application/octet-stream"
|
||||
).let { notification ->
|
||||
notificationUtils.showNotificationMessage("DL", uri.hashCode(), notification)
|
||||
}
|
||||
}
|
||||
// TODO add notification?
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE).also { mediaScanIntent ->
|
||||
mediaScanIntent.data = Uri.fromFile(file)
|
||||
context.sendBroadcast(mediaScanIntent)
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -68,15 +68,14 @@ import im.vector.matrix.android.api.session.events.model.Event
|
|||
import im.vector.matrix.android.api.session.events.model.toModel
|
||||
import im.vector.matrix.android.api.session.file.FileService
|
||||
import im.vector.matrix.android.api.session.room.model.Membership
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageFormat
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageStickerContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageVerificationRequestContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageWithAttachmentContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
|
||||
import im.vector.matrix.android.api.session.room.send.SendState
|
||||
import im.vector.matrix.android.api.session.room.timeline.Timeline
|
||||
|
@ -98,7 +97,6 @@ import im.vector.riotx.core.extensions.hideKeyboard
|
|||
import im.vector.riotx.core.extensions.setTextOrHide
|
||||
import im.vector.riotx.core.extensions.showKeyboard
|
||||
import im.vector.riotx.core.extensions.trackItemsVisibilityChange
|
||||
import im.vector.riotx.core.files.addEntryToDownloadManager
|
||||
import im.vector.riotx.core.glide.GlideApp
|
||||
import im.vector.riotx.core.intent.getMimeTypeFromUri
|
||||
import im.vector.riotx.core.platform.VectorBaseFragment
|
||||
|
@ -112,7 +110,6 @@ import im.vector.riotx.core.utils.KeyboardStateUtils
|
|||
import im.vector.riotx.core.utils.PERMISSIONS_FOR_AUDIO_IP_CALL
|
||||
import im.vector.riotx.core.utils.PERMISSIONS_FOR_VIDEO_IP_CALL
|
||||
import im.vector.riotx.core.utils.PERMISSIONS_FOR_WRITING_FILES
|
||||
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_DOWNLOAD_FILE
|
||||
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_INCOMING_URI
|
||||
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_PICK_ATTACHMENT
|
||||
import im.vector.riotx.core.utils.TextUtils
|
||||
|
@ -167,6 +164,7 @@ import im.vector.riotx.features.invite.VectorInviteView
|
|||
import im.vector.riotx.features.media.ImageContentRenderer
|
||||
import im.vector.riotx.features.media.VideoContentRenderer
|
||||
import im.vector.riotx.features.notifications.NotificationDrawerManager
|
||||
import im.vector.riotx.features.notifications.NotificationUtils
|
||||
import im.vector.riotx.features.permalink.NavigationInterceptor
|
||||
import im.vector.riotx.features.permalink.PermalinkHandler
|
||||
import im.vector.riotx.features.reactions.EmojiReactionPickerActivity
|
||||
|
@ -209,6 +207,7 @@ class RoomDetailFragment @Inject constructor(
|
|||
private val eventHtmlRenderer: EventHtmlRenderer,
|
||||
private val vectorPreferences: VectorPreferences,
|
||||
private val colorProvider: ColorProvider,
|
||||
private val notificationUtils: NotificationUtils,
|
||||
private val webRtcPeerConnectionManager: WebRtcPeerConnectionManager) :
|
||||
VectorBaseFragment(),
|
||||
TimelineEventController.Callback,
|
||||
|
@ -342,11 +341,12 @@ class RoomDetailFragment @Inject constructor(
|
|||
is RoomDetailViewEvents.DownloadFileState -> handleDownloadFileState(it)
|
||||
is RoomDetailViewEvents.JoinRoomCommandSuccess -> handleJoinedToAnotherRoom(it)
|
||||
is RoomDetailViewEvents.SendMessageResult -> renderSendMessageResult(it)
|
||||
is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode)
|
||||
is RoomDetailViewEvents.ShowE2EErrorMessage -> displayE2eError(it.withHeldCode)
|
||||
RoomDetailViewEvents.DisplayPromptForIntegrationManager -> displayPromptForIntegrationManager()
|
||||
is RoomDetailViewEvents.OpenStickerPicker -> openStickerPicker(it)
|
||||
is RoomDetailViewEvents.DisplayEnableIntegrationsWarning -> displayDisabledIntegrationDialog()
|
||||
is RoomDetailViewEvents.OpenIntegrationManager -> openIntegrationManager()
|
||||
is RoomDetailViewEvents.OpenFile -> startOpenFileIntent(it)
|
||||
}.exhaustive
|
||||
}
|
||||
}
|
||||
|
@ -368,6 +368,21 @@ class RoomDetailFragment @Inject constructor(
|
|||
navigator.openStickerPicker(this, roomDetailArgs.roomId, event.widget)
|
||||
}
|
||||
|
||||
private fun startOpenFileIntent(action: RoomDetailViewEvents.OpenFile) {
|
||||
if (action.uri != null) {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndTypeAndNormalize(action.uri, action.mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
|
||||
if (intent.resolveActivity(requireActivity().packageManager) != null) {
|
||||
requireActivity().startActivity(intent)
|
||||
} else {
|
||||
requireActivity().toast(R.string.error_no_external_application_found)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayPromptForIntegrationManager() {
|
||||
// The Sticker picker widget is not installed yet. Propose the user to install it
|
||||
val builder = AlertDialog.Builder(requireContext())
|
||||
|
@ -485,10 +500,22 @@ class RoomDetailFragment @Inject constructor(
|
|||
val activity = requireActivity()
|
||||
if (action.throwable != null) {
|
||||
activity.toast(errorFormatter.toHumanReadable(action.throwable))
|
||||
} else if (action.file != null) {
|
||||
activity.toast(getString(R.string.downloaded_file, action.file.path))
|
||||
addEntryToDownloadManager(activity, action.file, action.mimeType)
|
||||
}
|
||||
// else if (action.file != null) {
|
||||
// addEntryToDownloadManager(activity, action.file, action.mimeType ?: "application/octet-stream")?.let {
|
||||
// // This is a temporary solution to help users find downloaded files
|
||||
// // there is a better way to do that
|
||||
// // On android Q+ this method returns the file URI, on older
|
||||
// // it returns null, and the download manager handles the notification
|
||||
// notificationUtils.buildDownloadFileNotification(
|
||||
// it,
|
||||
// action.file.name ?: "file",
|
||||
// action.mimeType ?: "application/octet-stream"
|
||||
// ).let { notification ->
|
||||
// notificationUtils.showNotificationMessage("DL", action.file.absolutePath.hashCode(), notification)
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
private fun setupNotificationView() {
|
||||
|
@ -524,24 +551,24 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
R.id.voice_call,
|
||||
R.id.video_call -> {
|
||||
val activeCall = sharedCallActionViewModel.activeCall.value
|
||||
val isVideoCall = item.itemId == R.id.video_call
|
||||
if (activeCall != null) {
|
||||
// resume existing if same room, if not prompt to kill and then restart new call?
|
||||
if (activeCall.roomId == roomDetailArgs.roomId) {
|
||||
onTapToReturnToCall()
|
||||
}
|
||||
val activeCall = sharedCallActionViewModel.activeCall.value
|
||||
val isVideoCall = item.itemId == R.id.video_call
|
||||
if (activeCall != null) {
|
||||
// resume existing if same room, if not prompt to kill and then restart new call?
|
||||
if (activeCall.roomId == roomDetailArgs.roomId) {
|
||||
onTapToReturnToCall()
|
||||
}
|
||||
// else {
|
||||
// TODO might not work well, and should prompt
|
||||
// TODO might not work well, and should prompt
|
||||
// webRtcPeerConnectionManager.endCall()
|
||||
// safeStartCall(it, isVideoCall)
|
||||
// }
|
||||
} else {
|
||||
safeStartCall(isVideoCall)
|
||||
}
|
||||
} else {
|
||||
safeStartCall(isVideoCall)
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.hangup_call -> {
|
||||
R.id.hangup_call -> {
|
||||
roomDetailViewModel.handle(RoomDetailAction.EndCall)
|
||||
true
|
||||
}
|
||||
|
@ -667,6 +694,8 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
// TODO why don't we call super here?
|
||||
// super.onActivityResult(requestCode, resultCode, data)
|
||||
}
|
||||
|
||||
// PRIVATE METHODS *****************************************************************************
|
||||
|
@ -1150,31 +1179,32 @@ class RoomDetailFragment @Inject constructor(
|
|||
navigator.openVideoViewer(requireActivity(), mediaData)
|
||||
}
|
||||
|
||||
override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) {
|
||||
val action = RoomDetailAction.DownloadFile(eventId, messageFileContent)
|
||||
// We need WRITE_EXTERNAL permission
|
||||
if (checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) {
|
||||
showSnackWithMessage(getString(R.string.downloading_file, messageFileContent.getFileName()))
|
||||
roomDetailViewModel.handle(action)
|
||||
} else {
|
||||
roomDetailViewModel.pendingAction = action
|
||||
}
|
||||
}
|
||||
// override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) {
|
||||
// val isEncrypted = messageFileContent.encryptedFileInfo != null
|
||||
// val action = RoomDetailAction.DownloadOrOpen(eventId, messageFileContent, isEncrypted)
|
||||
// // We need WRITE_EXTERNAL permission
|
||||
// // if (!isEncrypted || checkPermissions(PERMISSIONS_FOR_WRITING_FILES, this, PERMISSION_REQUEST_CODE_DOWNLOAD_FILE)) {
|
||||
// showSnackWithMessage(getString(R.string.downloading_file, messageFileContent.getFileName()))
|
||||
// roomDetailViewModel.handle(action)
|
||||
// // } else {
|
||||
// // roomDetailViewModel.pendingAction = action
|
||||
// // }
|
||||
// }
|
||||
|
||||
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<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) {
|
||||
|
@ -1214,9 +1244,9 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) {
|
||||
vectorBaseActivity.notImplemented("open audio file")
|
||||
}
|
||||
// override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) {
|
||||
// vectorBaseActivity.notImplemented("open audio file")
|
||||
// }
|
||||
|
||||
override fun onLoadMore(direction: Timeline.Direction) {
|
||||
roomDetailViewModel.handle(RoomDetailAction.LoadMoreTimelineEvents(direction))
|
||||
|
@ -1227,6 +1257,10 @@ class RoomDetailFragment @Inject constructor(
|
|||
is MessageVerificationRequestContent -> {
|
||||
roomDetailViewModel.handle(RoomDetailAction.ResumeVerification(informationData.eventId, null))
|
||||
}
|
||||
is MessageWithAttachmentContent -> {
|
||||
val action = RoomDetailAction.DownloadOrOpen(informationData.eventId, messageContent)
|
||||
roomDetailViewModel.handle(action)
|
||||
}
|
||||
is EncryptedEventContent -> {
|
||||
roomDetailViewModel.handle(RoomDetailAction.TapOnFailedToDecrypt(informationData.eventId))
|
||||
}
|
||||
|
@ -1305,11 +1339,12 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onShareActionClicked(action: EventSharedAction.Share) {
|
||||
session.downloadFile(
|
||||
session.fileService().downloadFile(
|
||||
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
|
||||
action.eventId,
|
||||
action.messageContent.body,
|
||||
action.messageContent.getFileUrl(),
|
||||
action.messageContent.mimeType,
|
||||
action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
object : MatrixCallback<File> {
|
||||
override fun onSuccess(data: File) {
|
||||
|
@ -1322,26 +1357,23 @@ class RoomDetailFragment @Inject constructor(
|
|||
}
|
||||
|
||||
private fun onSaveActionClicked(action: EventSharedAction.Save) {
|
||||
session.downloadFile(
|
||||
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
|
||||
action.eventId,
|
||||
action.messageContent.body,
|
||||
action.messageContent.getFileUrl(),
|
||||
action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
object : MatrixCallback<File> {
|
||||
session.fileService().downloadFile(
|
||||
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
|
||||
id = action.eventId,
|
||||
fileName = action.messageContent.body,
|
||||
mimeType = action.messageContent.mimeType,
|
||||
url = action.messageContent.getFileUrl(),
|
||||
elementToDecrypt = action.messageContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
callback = object : MatrixCallback<File> {
|
||||
override fun onSuccess(data: File) {
|
||||
if (isAdded) {
|
||||
val saved = saveMedia(
|
||||
saveMedia(
|
||||
context = requireContext(),
|
||||
file = data,
|
||||
title = action.messageContent.body,
|
||||
mediaMimeType = getMimeTypeFromUri(requireContext(), data.toUri())
|
||||
mediaMimeType = action.messageContent.mimeType ?: getMimeTypeFromUri(requireContext(), data.toUri()),
|
||||
notificationUtils = notificationUtils
|
||||
)
|
||||
if (saved) {
|
||||
Toast.makeText(requireContext(), R.string.media_file_added_to_gallery, Toast.LENGTH_LONG).show()
|
||||
} else {
|
||||
Toast.makeText(requireContext(), R.string.error_adding_media_file_to_gallery, Toast.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -48,6 +48,7 @@ import im.vector.matrix.android.api.session.room.model.RoomSummary
|
|||
import im.vector.matrix.android.api.session.room.model.message.MessageContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageType
|
||||
import im.vector.matrix.android.api.session.room.model.message.OptionItem
|
||||
import im.vector.matrix.android.api.session.room.model.message.getFileName
|
||||
import im.vector.matrix.android.api.session.room.model.message.getFileUrl
|
||||
import im.vector.matrix.android.api.session.room.model.tombstone.RoomTombstoneContent
|
||||
import im.vector.matrix.android.api.session.room.powerlevels.PowerLevelsHelper
|
||||
|
@ -243,7 +244,7 @@ class RoomDetailViewModel @AssistedInject constructor(
|
|||
is RoomDetailAction.EnterEditMode -> handleEditAction(action)
|
||||
is RoomDetailAction.EnterQuoteMode -> handleQuoteAction(action)
|
||||
is RoomDetailAction.EnterReplyMode -> handleReplyAction(action)
|
||||
is RoomDetailAction.DownloadFile -> handleDownloadFile(action)
|
||||
is RoomDetailAction.DownloadOrOpen -> handleOpenOrDownloadFile(action)
|
||||
is RoomDetailAction.NavigateToEvent -> handleNavigateToEvent(action)
|
||||
is RoomDetailAction.HandleTombstoneEvent -> handleTombstoneEvent(action)
|
||||
is RoomDetailAction.ResendMessage -> handleResendEvent(action)
|
||||
|
@ -858,30 +859,44 @@ class RoomDetailViewModel @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private fun handleDownloadFile(action: RoomDetailAction.DownloadFile) {
|
||||
session.downloadFile(
|
||||
FileService.DownloadMode.TO_EXPORT,
|
||||
action.eventId,
|
||||
action.messageFileContent.getFileName(),
|
||||
action.messageFileContent.getFileUrl(),
|
||||
action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
object : MatrixCallback<File> {
|
||||
override fun onSuccess(data: File) {
|
||||
_viewEvents.post(RoomDetailViewEvents.DownloadFileState(
|
||||
action.messageFileContent.getMimeType(),
|
||||
data,
|
||||
null
|
||||
))
|
||||
}
|
||||
private fun handleOpenOrDownloadFile(action: RoomDetailAction.DownloadOrOpen) {
|
||||
val mxcUrl = action.messageFileContent.getFileUrl()
|
||||
val isDownloaded = mxcUrl?.let { session.fileService().isFileInCache(it, action.messageFileContent.mimeType) } ?: false
|
||||
if (isDownloaded) {
|
||||
// we can open it
|
||||
session.fileService().getTemporarySharableURI(mxcUrl!!, action.messageFileContent.mimeType)?.let { uri ->
|
||||
_viewEvents.post(RoomDetailViewEvents.OpenFile(
|
||||
action.messageFileContent.mimeType,
|
||||
uri,
|
||||
null
|
||||
))
|
||||
}
|
||||
} else {
|
||||
session.fileService().downloadFile(
|
||||
FileService.DownloadMode.FOR_INTERNAL_USE,
|
||||
action.eventId,
|
||||
action.messageFileContent.getFileName(),
|
||||
action.messageFileContent.mimeType,
|
||||
mxcUrl,
|
||||
action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
object : MatrixCallback<File> {
|
||||
override fun onSuccess(data: File) {
|
||||
_viewEvents.post(RoomDetailViewEvents.DownloadFileState(
|
||||
action.messageFileContent.mimeType,
|
||||
data,
|
||||
null
|
||||
))
|
||||
}
|
||||
|
||||
override fun onFailure(failure: Throwable) {
|
||||
_viewEvents.post(RoomDetailViewEvents.DownloadFileState(
|
||||
action.messageFileContent.getMimeType(),
|
||||
null,
|
||||
failure
|
||||
))
|
||||
}
|
||||
})
|
||||
override fun onFailure(failure: Throwable) {
|
||||
_viewEvents.post(RoomDetailViewEvents.DownloadFileState(
|
||||
action.messageFileContent.mimeType,
|
||||
null,
|
||||
failure
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNavigateToEvent(action: RoomDetailAction.NavigateToEvent) {
|
||||
|
|
|
@ -25,8 +25,6 @@ import androidx.recyclerview.widget.RecyclerView
|
|||
import com.airbnb.epoxy.EpoxyController
|
||||
import com.airbnb.epoxy.EpoxyModel
|
||||
import com.airbnb.epoxy.VisibilityState
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageAudioContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageImageInfoContent
|
||||
import im.vector.matrix.android.api.session.room.model.message.MessageVideoContent
|
||||
import im.vector.matrix.android.api.session.room.timeline.Timeline
|
||||
|
@ -40,6 +38,7 @@ import im.vector.riotx.features.home.room.detail.RoomDetailViewState
|
|||
import im.vector.riotx.features.home.room.detail.UnreadState
|
||||
import im.vector.riotx.features.home.room.detail.timeline.factory.MergedHeaderItemFactory
|
||||
import im.vector.riotx.features.home.room.detail.timeline.factory.TimelineItemFactory
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.ReadMarkerVisibilityStateChangedListener
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.TimelineEventDiffUtilCallback
|
||||
|
@ -59,6 +58,7 @@ import javax.inject.Inject
|
|||
|
||||
class TimelineEventController @Inject constructor(private val dateFormatter: VectorDateFormatter,
|
||||
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
|
||||
private val contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder,
|
||||
private val timelineItemFactory: TimelineItemFactory,
|
||||
private val timelineMediaSizeProvider: TimelineMediaSizeProvider,
|
||||
private val mergedHeaderItemFactory: MergedHeaderItemFactory,
|
||||
|
@ -74,8 +74,8 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
|||
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
|
||||
fun onImageMessageClicked(messageImageContent: MessageImageInfoContent, mediaData: ImageContentRenderer.Data, view: View)
|
||||
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
|
||||
fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent)
|
||||
fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
|
||||
// fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent)
|
||||
// fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
|
||||
fun onEditedDecorationClicked(informationData: MessageInformationData)
|
||||
|
||||
// TODO move all callbacks to this?
|
||||
|
@ -226,6 +226,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Vec
|
|||
override fun onDetachedFromRecyclerView(recyclerView: RecyclerView) {
|
||||
timelineMediaSizeProvider.recyclerView = null
|
||||
contentUploadStateTrackerBinder.clear()
|
||||
contentDownloadStateTrackerBinder.clear()
|
||||
timeline?.removeListener(this)
|
||||
super.onDetachedFromRecyclerView(recyclerView)
|
||||
}
|
||||
|
|
|
@ -57,6 +57,7 @@ import im.vector.riotx.core.utils.containsOnlyEmojis
|
|||
import im.vector.riotx.core.utils.isLocalFile
|
||||
import im.vector.riotx.features.home.room.detail.timeline.TimelineEventController
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.AvatarSizeProvider
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.MessageInformationDataFactory
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.MessageItemAttributesFactory
|
||||
|
@ -99,6 +100,7 @@ class MessageItemFactory @Inject constructor(
|
|||
private val messageInformationDataFactory: MessageInformationDataFactory,
|
||||
private val messageItemAttributesFactory: MessageItemAttributesFactory,
|
||||
private val contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder,
|
||||
private val contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder,
|
||||
private val defaultItemFactory: DefaultItemFactory,
|
||||
private val noticeItemFactory: NoticeItemFactory,
|
||||
private val avatarSizeProvider: AvatarSizeProvider,
|
||||
|
@ -140,8 +142,8 @@ class MessageItemFactory @Inject constructor(
|
|||
is MessageImageInfoContent -> buildImageMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageNoticeContent -> buildNoticeMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageVideoContent -> buildVideoMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageFileContent -> buildFileMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageFileContent -> buildFileMessageItem(messageContent, highlight, attributes)
|
||||
is MessageAudioContent -> buildAudioMessageItem(messageContent, informationData, highlight, attributes)
|
||||
is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessageOptionsContent -> buildOptionsMessageItem(messageContent, informationData, highlight, callback, attributes)
|
||||
is MessagePollResponseContent -> noticeItemFactory.create(event, highlight, callback)
|
||||
|
@ -184,20 +186,17 @@ class MessageItemFactory @Inject constructor(
|
|||
@Suppress("UNUSED_PARAMETER")
|
||||
informationData: MessageInformationData,
|
||||
highlight: Boolean,
|
||||
callback: TimelineEventController.Callback?,
|
||||
attributes: AbsMessageItem.Attributes): MessageFileItem? {
|
||||
return MessageFileItem_()
|
||||
.attributes(attributes)
|
||||
.izLocalFile(messageContent.getFileUrl().isLocalFile())
|
||||
.mxcUrl(messageContent.getFileUrl() ?: "")
|
||||
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
|
||||
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
|
||||
.highlighted(highlight)
|
||||
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||
.filename(messageContent.body)
|
||||
.iconRes(R.drawable.filetype_audio)
|
||||
.clickListener(
|
||||
DebouncedClickListener(View.OnClickListener {
|
||||
callback?.onAudioMessageClicked(messageContent)
|
||||
}))
|
||||
.iconRes(R.drawable.ic_headphones)
|
||||
}
|
||||
|
||||
private fun buildVerificationRequestMessageItem(messageContent: MessageVerificationRequestContent,
|
||||
|
@ -232,35 +231,27 @@ class MessageItemFactory @Inject constructor(
|
|||
)
|
||||
)
|
||||
.callback(callback)
|
||||
// .izLocalFile(messageContent.getFileUrl().isLocalFile())
|
||||
// .contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
|
||||
.highlighted(highlight)
|
||||
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||
// .filename(messageContent.body)
|
||||
// .iconRes(R.drawable.filetype_audio)
|
||||
// .clickListener(
|
||||
// DebouncedClickListener(View.OnClickListener {
|
||||
// callback?.onAudioMessageClicked(messageContent)
|
||||
// }))
|
||||
}
|
||||
|
||||
private fun buildFileMessageItem(messageContent: MessageFileContent,
|
||||
informationData: MessageInformationData,
|
||||
// informationData: MessageInformationData,
|
||||
highlight: Boolean,
|
||||
callback: TimelineEventController.Callback?,
|
||||
// callback: TimelineEventController.Callback?,
|
||||
attributes: AbsMessageItem.Attributes): MessageFileItem? {
|
||||
val mxcUrl = messageContent.getFileUrl() ?: ""
|
||||
return MessageFileItem_()
|
||||
.attributes(attributes)
|
||||
.leftGuideline(avatarSizeProvider.leftGuideline)
|
||||
.izLocalFile(messageContent.getFileUrl().isLocalFile())
|
||||
.izDownloaded(session.fileService().isFileInCache(mxcUrl, messageContent.mimeType))
|
||||
.mxcUrl(mxcUrl)
|
||||
.contentUploadStateTrackerBinder(contentUploadStateTrackerBinder)
|
||||
.contentDownloadStateTrackerBinder(contentDownloadStateTrackerBinder)
|
||||
.highlighted(highlight)
|
||||
.filename(messageContent.body)
|
||||
.iconRes(R.drawable.filetype_attachment)
|
||||
.clickListener(
|
||||
DebouncedClickListener(View.OnClickListener {
|
||||
callback?.onFileMessageClicked(informationData.eventId, messageContent)
|
||||
}))
|
||||
.iconRes(R.drawable.ic_paperclip)
|
||||
}
|
||||
|
||||
private fun buildNotHandledMessageItem(messageContent: MessageContent,
|
||||
|
@ -282,6 +273,7 @@ class MessageItemFactory @Inject constructor(
|
|||
val data = ImageContentRenderer.Data(
|
||||
eventId = informationData.eventId,
|
||||
filename = messageContent.body,
|
||||
mimeType = messageContent.mimeType,
|
||||
url = messageContent.getFileUrl(),
|
||||
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
height = messageContent.info?.height,
|
||||
|
@ -318,6 +310,7 @@ class MessageItemFactory @Inject constructor(
|
|||
val thumbnailData = ImageContentRenderer.Data(
|
||||
eventId = informationData.eventId,
|
||||
filename = messageContent.body,
|
||||
mimeType = messageContent.mimeType,
|
||||
url = messageContent.videoInfo?.thumbnailFile?.url
|
||||
?: messageContent.videoInfo?.thumbnailUrl,
|
||||
elementToDecrypt = messageContent.videoInfo?.thumbnailFile?.toElementToDecrypt(),
|
||||
|
@ -330,6 +323,7 @@ class MessageItemFactory @Inject constructor(
|
|||
val videoData = VideoContentRenderer.Data(
|
||||
eventId = informationData.eventId,
|
||||
filename = messageContent.body,
|
||||
mimeType = messageContent.mimeType,
|
||||
url = messageContent.getFileUrl(),
|
||||
elementToDecrypt = messageContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
thumbnailMediaData = thumbnailData
|
||||
|
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* Copyright 2019 New Vector Ltd
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package im.vector.riotx.features.home.room.detail.timeline.helper
|
||||
|
||||
import android.graphics.drawable.Drawable
|
||||
import androidx.vectordrawable.graphics.drawable.Animatable2Compat
|
||||
import androidx.vectordrawable.graphics.drawable.AnimatedVectorDrawableCompat
|
||||
import im.vector.matrix.android.api.session.file.ContentDownloadStateTracker
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.core.di.ActiveSessionHolder
|
||||
import im.vector.riotx.core.di.ScreenScope
|
||||
import im.vector.riotx.core.error.ErrorFormatter
|
||||
import im.vector.riotx.features.home.room.detail.timeline.MessageColorProvider
|
||||
import im.vector.riotx.features.home.room.detail.timeline.item.MessageFileItem
|
||||
import javax.inject.Inject
|
||||
|
||||
@ScreenScope
|
||||
class ContentDownloadStateTrackerBinder @Inject constructor(private val activeSessionHolder: ActiveSessionHolder,
|
||||
private val messageColorProvider: MessageColorProvider,
|
||||
private val errorFormatter: ErrorFormatter) {
|
||||
|
||||
private val updateListeners = mutableMapOf<String, ContentDownloadUpdater>()
|
||||
|
||||
fun bind(mxcUrl: String,
|
||||
holder: MessageFileItem.Holder) {
|
||||
activeSessionHolder.getSafeActiveSession()?.also { session ->
|
||||
val downloadStateTracker = session.contentDownloadProgressTracker()
|
||||
val updateListener = ContentDownloadUpdater(holder, messageColorProvider, errorFormatter)
|
||||
updateListeners[mxcUrl] = updateListener
|
||||
downloadStateTracker.track(mxcUrl, updateListener)
|
||||
}
|
||||
}
|
||||
|
||||
fun unbind(mxcUrl: String) {
|
||||
activeSessionHolder.getSafeActiveSession()?.also { session ->
|
||||
val downloadStateTracker = session.contentDownloadProgressTracker()
|
||||
updateListeners[mxcUrl]?.also {
|
||||
it.stop()
|
||||
downloadStateTracker.unTrack(mxcUrl, it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun clear() {
|
||||
activeSessionHolder.getSafeActiveSession()?.also {
|
||||
it.contentDownloadProgressTracker().clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class ContentDownloadUpdater(private val holder: MessageFileItem.Holder,
|
||||
private val messageColorProvider: MessageColorProvider,
|
||||
private val errorFormatter: ErrorFormatter) : ContentDownloadStateTracker.UpdateListener {
|
||||
|
||||
override fun onDownloadStateUpdate(state: ContentDownloadStateTracker.State) {
|
||||
when (state) {
|
||||
ContentDownloadStateTracker.State.Idle -> handleIdle()
|
||||
is ContentDownloadStateTracker.State.Downloading -> handleProgress(state)
|
||||
ContentDownloadStateTracker.State.Decrypting -> handleDecrypting()
|
||||
ContentDownloadStateTracker.State.Success -> handleSuccess()
|
||||
is ContentDownloadStateTracker.State.Failure -> handleFailure()
|
||||
}
|
||||
}
|
||||
|
||||
private var animatedDrawable: AnimatedVectorDrawableCompat? = null
|
||||
private var animationLoopCallback = object : Animatable2Compat.AnimationCallback() {
|
||||
override fun onAnimationEnd(drawable: Drawable?) {
|
||||
animatedDrawable?.start()
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
animatedDrawable?.unregisterAnimationCallback(animationLoopCallback)
|
||||
animatedDrawable?.stop()
|
||||
animatedDrawable = null
|
||||
}
|
||||
|
||||
private fun handleIdle() {
|
||||
holder.fileDownloadProgress.progress = 0
|
||||
holder.fileDownloadProgress.isIndeterminate = false
|
||||
}
|
||||
|
||||
private fun handleDecrypting() {
|
||||
holder.fileDownloadProgress.isIndeterminate = true
|
||||
}
|
||||
|
||||
private fun handleProgress(state: ContentDownloadStateTracker.State.Downloading) {
|
||||
doHandleProgress(state.current, state.total)
|
||||
}
|
||||
|
||||
private fun doHandleProgress(current: Long, total: Long) {
|
||||
val percent = 100L * (current.toFloat() / total.toFloat())
|
||||
holder.fileDownloadProgress.isIndeterminate = false
|
||||
holder.fileDownloadProgress.progress = percent.toInt()
|
||||
if (animatedDrawable == null) {
|
||||
animatedDrawable = AnimatedVectorDrawableCompat.create(holder.view.context, R.drawable.ic_download_anim)
|
||||
holder.fileImageView.setImageDrawable(animatedDrawable)
|
||||
animatedDrawable?.start()
|
||||
animatedDrawable?.registerAnimationCallback(animationLoopCallback)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleFailure() {
|
||||
stop()
|
||||
holder.fileDownloadProgress.isIndeterminate = false
|
||||
holder.fileDownloadProgress.progress = 0
|
||||
holder.fileImageView.setImageResource(R.drawable.ic_close_round)
|
||||
}
|
||||
|
||||
private fun handleSuccess() {
|
||||
stop()
|
||||
holder.fileDownloadProgress.isIndeterminate = false
|
||||
holder.fileDownloadProgress.progress = 100
|
||||
holder.fileImageView.setImageResource(R.drawable.ic_paperclip)
|
||||
}
|
||||
}
|
|
@ -17,15 +17,16 @@
|
|||
package im.vector.riotx.features.home.room.detail.timeline.item
|
||||
|
||||
import android.graphics.Paint
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ImageView
|
||||
import android.widget.ProgressBar
|
||||
import android.widget.TextView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.core.view.isVisible
|
||||
import com.airbnb.epoxy.EpoxyAttribute
|
||||
import com.airbnb.epoxy.EpoxyModelClass
|
||||
import im.vector.riotx.R
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentDownloadStateTrackerBinder
|
||||
import im.vector.riotx.features.home.room.detail.timeline.helper.ContentUploadStateTrackerBinder
|
||||
|
||||
@EpoxyModelClass(layout = R.layout.item_timeline_event_base)
|
||||
|
@ -33,33 +34,64 @@ abstract class MessageFileItem : AbsMessageItem<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
|
||||
var izDownloaded = false
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var contentUploadStateTrackerBinder: ContentUploadStateTrackerBinder
|
||||
|
||||
@EpoxyAttribute
|
||||
lateinit var contentDownloadStateTrackerBinder: ContentDownloadStateTrackerBinder
|
||||
|
||||
override fun bind(holder: Holder) {
|
||||
super.bind(holder)
|
||||
renderSendState(holder.fileLayout, holder.filenameView)
|
||||
if (!attributes.informationData.sendState.hasFailed()) {
|
||||
contentUploadStateTrackerBinder.bind(attributes.informationData.eventId, izLocalFile, holder.progressLayout)
|
||||
} else {
|
||||
holder.fileImageView.setImageResource(R.drawable.ic_cross)
|
||||
holder.progressLayout.isVisible = false
|
||||
}
|
||||
holder.filenameView.text = filename
|
||||
holder.fileImageView.setImageResource(iconRes)
|
||||
holder.filenameView.setOnClickListener(clickListener)
|
||||
if (attributes.informationData.sendState.isSending()) {
|
||||
holder.fileImageView.setImageResource(iconRes)
|
||||
} else {
|
||||
if (izDownloaded) {
|
||||
holder.fileImageView.setImageResource(iconRes)
|
||||
holder.fileDownloadProgress.progress = 100
|
||||
} else {
|
||||
contentDownloadStateTrackerBinder.bind(mxcUrl, holder)
|
||||
holder.fileImageView.setImageResource(R.drawable.ic_download)
|
||||
holder.fileDownloadProgress.progress = 0
|
||||
}
|
||||
}
|
||||
// holder.view.setOnClickListener(clickListener)
|
||||
|
||||
holder.filenameView.setOnClickListener(attributes.itemClickListener)
|
||||
holder.filenameView.setOnLongClickListener(attributes.itemLongClickListener)
|
||||
holder.fileImageWrapper.setOnClickListener(attributes.itemClickListener)
|
||||
holder.fileImageWrapper.setOnLongClickListener(attributes.itemLongClickListener)
|
||||
holder.filenameView.paintFlags = (holder.filenameView.paintFlags or Paint.UNDERLINE_TEXT_FLAG)
|
||||
}
|
||||
|
||||
override fun unbind(holder: Holder) {
|
||||
super.unbind(holder)
|
||||
contentUploadStateTrackerBinder.unbind(attributes.informationData.eventId)
|
||||
contentDownloadStateTrackerBinder.unbind(mxcUrl)
|
||||
}
|
||||
|
||||
override fun getViewType() = STUB_ID
|
||||
|
@ -67,7 +99,9 @@ abstract class MessageFileItem : AbsMessageItem<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)
|
||||
}
|
||||
|
||||
|
|
|
@ -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?,
|
||||
|
|
|
@ -133,10 +133,11 @@ class ImageMediaViewerActivity : VectorBaseActivity() {
|
|||
}
|
||||
|
||||
private fun onShareActionClicked() {
|
||||
session.downloadFile(
|
||||
session.fileService().downloadFile(
|
||||
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
|
||||
mediaData.eventId,
|
||||
mediaData.filename,
|
||||
mediaData.mimeType,
|
||||
mediaData.url,
|
||||
mediaData.elementToDecrypt,
|
||||
object : MatrixCallback<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
|
||||
|
@ -64,14 +65,15 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
|
|||
thumbnailView.isVisible = true
|
||||
loadingView.isVisible = true
|
||||
|
||||
activeSessionHolder.getActiveSession()
|
||||
activeSessionHolder.getActiveSession().fileService()
|
||||
.downloadFile(
|
||||
FileService.DownloadMode.FOR_INTERNAL_USE,
|
||||
data.eventId,
|
||||
data.filename,
|
||||
data.url,
|
||||
data.elementToDecrypt,
|
||||
object : MatrixCallback<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
|
||||
|
@ -102,14 +104,15 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
|
|||
thumbnailView.isVisible = true
|
||||
loadingView.isVisible = true
|
||||
|
||||
activeSessionHolder.getActiveSession()
|
||||
activeSessionHolder.getActiveSession().fileService()
|
||||
.downloadFile(
|
||||
FileService.DownloadMode.FOR_INTERNAL_USE,
|
||||
data.eventId,
|
||||
data.filename,
|
||||
data.url,
|
||||
null,
|
||||
object : MatrixCallback<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
|
||||
|
|
|
@ -78,10 +78,11 @@ class VideoMediaViewerActivity : VectorBaseActivity() {
|
|||
}
|
||||
|
||||
private fun onShareActionClicked() {
|
||||
session.downloadFile(
|
||||
session.fileService().downloadFile(
|
||||
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
|
||||
mediaData.eventId,
|
||||
mediaData.filename,
|
||||
mediaData.mimeType,
|
||||
mediaData.url,
|
||||
mediaData.elementToDecrypt,
|
||||
object : MatrixCallback<File> {
|
||||
|
|
|
@ -480,6 +480,26 @@ class NotificationUtils @Inject constructor(private val context: Context,
|
|||
.build()
|
||||
}
|
||||
|
||||
fun buildDownloadFileNotification(uri: Uri, fileName: String, mimeType: String): Notification {
|
||||
return NotificationCompat.Builder(context, SILENT_NOTIFICATION_CHANNEL_ID)
|
||||
.setGroup(stringProvider.getString(R.string.app_name))
|
||||
.setSmallIcon(R.drawable.ic_download)
|
||||
.setContentText(stringProvider.getString(R.string.downloaded_file, fileName))
|
||||
.setAutoCancel(true)
|
||||
.apply {
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(uri, mimeType)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
PendingIntent.getActivity(
|
||||
context, System.currentTimeMillis().toInt(), intent, PendingIntent.FLAG_UPDATE_CURRENT
|
||||
).let {
|
||||
setContentIntent(it)
|
||||
}
|
||||
}
|
||||
.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a notification for a Room
|
||||
*/
|
||||
|
|
|
@ -22,7 +22,6 @@ import androidx.core.net.toUri
|
|||
import com.airbnb.mvrx.args
|
||||
import com.airbnb.mvrx.fragmentViewModel
|
||||
import com.airbnb.mvrx.withState
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import com.google.android.material.tabs.TabLayoutMediator
|
||||
import im.vector.matrix.android.api.util.toMatrixItem
|
||||
import im.vector.riotx.R
|
||||
|
@ -33,6 +32,7 @@ import im.vector.riotx.core.resources.StringProvider
|
|||
import im.vector.riotx.core.utils.saveMedia
|
||||
import im.vector.riotx.core.utils.shareMedia
|
||||
import im.vector.riotx.features.home.AvatarRenderer
|
||||
import im.vector.riotx.features.notifications.NotificationUtils
|
||||
import im.vector.riotx.features.roomprofile.RoomProfileArgs
|
||||
import kotlinx.android.synthetic.main.fragment_room_uploads.*
|
||||
import javax.inject.Inject
|
||||
|
@ -40,7 +40,8 @@ import javax.inject.Inject
|
|||
class RoomUploadsFragment @Inject constructor(
|
||||
private val viewModelFactory: RoomUploadsViewModel.Factory,
|
||||
private val stringProvider: StringProvider,
|
||||
private val avatarRenderer: AvatarRenderer
|
||||
private val avatarRenderer: AvatarRenderer,
|
||||
private val notificationUtils: NotificationUtils
|
||||
) : VectorBaseFragment(), RoomUploadsViewModel.Factory by viewModelFactory {
|
||||
|
||||
private val roomProfileArgs: RoomProfileArgs by args()
|
||||
|
@ -70,17 +71,13 @@ class RoomUploadsFragment @Inject constructor(
|
|||
shareMedia(requireContext(), it.file, getMimeTypeFromUri(requireContext(), it.file.toUri()))
|
||||
}
|
||||
is RoomUploadsViewEvents.FileReadyForSaving -> {
|
||||
val saved = saveMedia(
|
||||
saveMedia(
|
||||
context = requireContext(),
|
||||
file = it.file,
|
||||
title = it.title,
|
||||
mediaMimeType = getMimeTypeFromUri(requireContext(), it.file.toUri())
|
||||
mediaMimeType = getMimeTypeFromUri(requireContext(), it.file.toUri()),
|
||||
notificationUtils = notificationUtils
|
||||
)
|
||||
if (saved) {
|
||||
Snackbar.make(roomUploadsCoordinator, R.string.media_file_added_to_gallery, Snackbar.LENGTH_LONG).show()
|
||||
} else {
|
||||
Snackbar.make(roomUploadsCoordinator, R.string.error_adding_media_file_to_gallery, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
is RoomUploadsViewEvents.Failure -> showFailure(it.throwable)
|
||||
}.exhaustive
|
||||
|
|
|
@ -136,13 +136,14 @@ class RoomUploadsViewModel @AssistedInject constructor(
|
|||
viewModelScope.launch {
|
||||
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
|
||||
session.fileService().downloadFile(
|
||||
downloadMode = FileService.DownloadMode.FOR_EXTERNAL_SHARE,
|
||||
id = action.uploadEvent.eventId,
|
||||
fileName = action.uploadEvent.contentWithAttachmentContent.body,
|
||||
url = action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
|
||||
mimeType = action.uploadEvent.contentWithAttachmentContent.mimeType,
|
||||
elementToDecrypt = action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
callback = it
|
||||
)
|
||||
}
|
||||
_viewEvents.post(RoomUploadsViewEvents.FileReadyForSharing(file))
|
||||
|
@ -156,11 +157,12 @@ class RoomUploadsViewModel @AssistedInject constructor(
|
|||
viewModelScope.launch {
|
||||
try {
|
||||
val file = awaitCallback<File> {
|
||||
session.downloadFile(
|
||||
session.fileService().downloadFile(
|
||||
FileService.DownloadMode.FOR_EXTERNAL_SHARE,
|
||||
action.uploadEvent.eventId,
|
||||
action.uploadEvent.contentWithAttachmentContent.body,
|
||||
action.uploadEvent.contentWithAttachmentContent.getFileUrl(),
|
||||
action.uploadEvent.contentWithAttachmentContent.mimeType,
|
||||
action.uploadEvent.contentWithAttachmentContent.encryptedFileInfo?.toElementToDecrypt(),
|
||||
it)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -237,7 +237,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
|
|||
|
||||
// clear medias cache
|
||||
findPreference<VectorPreference>(VectorPreferences.SETTINGS_CLEAR_MEDIA_CACHE_PREFERENCE_KEY)!!.let {
|
||||
val size = getSizeOfFiles(File(requireContext().cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR))
|
||||
val size = getSizeOfFiles(File(requireContext().cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR)) + session.fileService().getCacheSize()
|
||||
|
||||
it.summary = TextUtils.formatFileSize(requireContext(), size.toLong())
|
||||
|
||||
|
@ -247,6 +247,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
|
|||
displayLoadingView()
|
||||
|
||||
Glide.get(requireContext()).clearMemory()
|
||||
session.fileService().clearCache()
|
||||
|
||||
var newSize = 0
|
||||
|
||||
|
@ -255,6 +256,7 @@ class VectorSettingsGeneralFragment : VectorSettingsBaseFragment() {
|
|||
Glide.get(requireContext()).clearDiskCache()
|
||||
|
||||
newSize = getSizeOfFiles(File(requireContext().cacheDir, DiskCache.Factory.DEFAULT_DISK_CACHE_DIR))
|
||||
newSize += session.fileService().getCacheSize()
|
||||
}
|
||||
|
||||
it.summary = TextUtils.formatFileSize(requireContext(), newSize.toLong())
|
||||
|
|
|
@ -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>
|
|
@ -0,0 +1,20 @@
|
|||
<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="M18,6L6,18"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#2E2F32"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M6,6L18,18"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#2E2F32"
|
||||
android:strokeLineCap="round"/>
|
||||
</vector>
|
|
@ -0,0 +1,102 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<animated-vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt">
|
||||
<aapt:attr name="android:drawable">
|
||||
<vector
|
||||
android:name="vector"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:name="path"
|
||||
android:pathData="M 3 17 L 3 20 C 3 21.105 3.895 22 5 22 L 19 22 C 20.105 22 21 21.105 21 20 L 21 17"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#2E2F32"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:name="path_1"
|
||||
android:pathData="M 8 12 L 12 16 L 16 12"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#2E2F32"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
<path
|
||||
android:name="path_2"
|
||||
android:pathData="M 12 2 L 12 16"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#2E2F32"
|
||||
android:strokeWidth="2"
|
||||
android:strokeLineCap="round"
|
||||
android:strokeLineJoin="round"/>
|
||||
</vector>
|
||||
</aapt:attr>
|
||||
<target android:name="path_2">
|
||||
<aapt:attr name="android:animation">
|
||||
<set>
|
||||
<objectAnimator
|
||||
android:propertyName="pathData"
|
||||
android:duration="682"
|
||||
android:valueFrom="M 12 2 L 12 16"
|
||||
android:valueTo="M 12 14 L 12 16"
|
||||
android:valueType="pathType"
|
||||
android:interpolator="@android:anim/accelerate_decelerate_interpolator"/>
|
||||
<objectAnimator
|
||||
android:propertyName="pathData"
|
||||
android:startOffset="682"
|
||||
android:duration="118"
|
||||
android:valueFrom="M 12 14 L 12 16"
|
||||
android:valueTo="M 12 2 L 12 16"
|
||||
android:valueType="pathType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</set>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
<target android:name="path_1">
|
||||
<aapt:attr name="android:animation">
|
||||
<set>
|
||||
<objectAnimator
|
||||
android:propertyName="pathData"
|
||||
android:duration="682"
|
||||
android:valueFrom="M 8 12 L 12 16 L 16 12"
|
||||
android:valueTo="M 8 14 L 12 18 L 16 14"
|
||||
android:valueType="pathType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
<objectAnimator
|
||||
android:propertyName="pathData"
|
||||
android:startOffset="682"
|
||||
android:duration="118"
|
||||
android:valueFrom="M 8 14 L 12 18 L 16 14"
|
||||
android:valueTo="M 8 12 L 12 16 L 16 12"
|
||||
android:valueType="pathType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</set>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
<target android:name="path">
|
||||
<aapt:attr name="android:animation">
|
||||
<set>
|
||||
<objectAnimator
|
||||
android:propertyName="pathData"
|
||||
android:startOffset="3"
|
||||
android:duration="697"
|
||||
android:valueFrom="M 3 18 L 3 20 C 3 21.105 3.895 22 5 22 L 19 22 C 20.105 22 21 21.105 21 20 L 21 18"
|
||||
android:valueTo="M 3 15 L 3 20 C 3 21.105 3.895 22 5 22 L 19 22 C 20.105 22 21 21.105 21 20 L 21 15"
|
||||
android:valueType="pathType"
|
||||
android:interpolator="@android:interpolator/linear_out_slow_in"/>
|
||||
<objectAnimator
|
||||
android:propertyName="pathData"
|
||||
android:startOffset="700"
|
||||
android:duration="100"
|
||||
android:valueFrom="M 3 15 L 3 20 C 3 21.105 3.895 22 5 22 L 19 22 C 20.105 22 21 21.105 21 20 L 21 15"
|
||||
android:valueTo="M 3 18 L 3 20 C 3 21.105 3.895 22 5 22 L 19 22 C 20.105 22 21 21.105 21 20 L 21 18"
|
||||
android:valueType="pathType"
|
||||
android:interpolator="@android:interpolator/fast_out_slow_in"/>
|
||||
</set>
|
||||
</aapt:attr>
|
||||
</target>
|
||||
</animated-vector>
|
|
@ -0,0 +1,16 @@
|
|||
<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="M3,18V12C3,7.0294 7.0294,3 12,3C16.9706,3 21,7.0294 21,12V18"
|
||||
android:strokeLineJoin="round"
|
||||
android:strokeWidth="2"
|
||||
android:fillColor="#00000000"
|
||||
android:strokeColor="#2E2F32"
|
||||
android:strokeLineCap="round"/>
|
||||
<path
|
||||
android:pathData="M21,14H22C22,13.4477 21.5523,13 21,13V14ZM3,14V13C2.4477,13 2,13.4477 2,14H3ZM20,19C20,19.5523 19.5523,20 19,20V22C20.6569,22 22,20.6569 22,19H20ZM19,20H18V22H19V20ZM18,20C17.4477,20 17,19.5523 17,19H15C15,20.6569 16.3431,22 18,22V20ZM17,19V16H15V19H17ZM17,16C17,15.4477 17.4477,15 18,15V13C16.3431,13 15,14.3431 15,16H17ZM18,15H21V13H18V15ZM20,14V19H22V14H20ZM2,19C2,20.6569 3.3431,22 5,22V20C4.4477,20 4,19.5523 4,19H2ZM5,22H6V20H5V22ZM6,22C7.6568,22 9,20.6569 9,19H7C7,19.5523 6.5523,20 6,20V22ZM9,19V16H7V19H9ZM9,16C9,14.3431 7.6568,13 6,13V15C6.5523,15 7,15.4477 7,16H9ZM6,13H3V15H6V13ZM2,14V19H4V14H2Z"
|
||||
android:fillColor="#2E2F32"/>
|
||||
</vector>
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -2404,6 +2404,7 @@ Not all features in Riot are implemented in RiotX yet. Main missing (and coming
|
|||
|
||||
<string name="media_file_added_to_gallery">Media file added to the Gallery</string>
|
||||
<string name="error_adding_media_file_to_gallery">Could not add media file to the Gallery</string>
|
||||
<string name="error_saving_media_file">Could not save media file</string>
|
||||
<string name="change_password_summary">Set a new account password…</string>
|
||||
|
||||
<string name="use_other_session_content_description">Use the latest Riot on your other devices, Riot Web, Riot Desktop, Riot iOS, RiotX for Android, or another cross-signing capable Matrix client</string>
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue