Download file - WIP

This commit is contained in:
Benoit Marty 2019-07-08 19:06:17 +02:00
parent 12bd85e0a9
commit a07f8b615e
14 changed files with 193 additions and 62 deletions

View File

@ -24,6 +24,7 @@ import im.vector.matrix.android.api.session.cache.CacheService
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.FileService
import im.vector.matrix.android.api.session.group.GroupService
import im.vector.matrix.android.api.session.pushers.PushersService
import im.vector.matrix.android.api.session.room.RoomDirectoryService
@ -46,6 +47,7 @@ interface Session :
CacheService,
SignOutService,
FilterService,
FileService,
PushRuleService,
PushersService {


View File

@ -105,13 +105,6 @@ interface CryptoService {

fun decryptEventAsync(event: Event, timeline: String, callback: MatrixCallback<MXEventDecryptionResult>)

/**
* Decrypt 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
*/
fun decryptFile(id: String, filename: String, url: String, elementToDecrypt: ElementToDecrypt, callback: MatrixCallback<File>)

fun getEncryptionAlgorithm(roomId: String): String?

fun shouldEncryptForInvitedMembers(roomId: String): Boolean

View File

@ -0,0 +1,52 @@
/*
* 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.matrix.android.api.session.file

import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
import java.io.File


/**
* This interface defines methods to get files.
*/
interface FileService {

enum class DownloadMode {
/**
* Download file in external storage
*/
TO_EXPORT,
/**
* Download file in cache
*/
FOR_INTERNAL_USE
}

/**
* 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
*/
fun downloadFile(
downloadMode: DownloadMode,
id: String,
fileName: String,
url: String?,
elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback<File>)
}

View File

@ -114,8 +114,6 @@ internal class CryptoManager @Inject constructor(
private val keysBackup: KeysBackup,
//
private val objectSigner: ObjectSigner,
// File decryptor
private val fileDecryptor: FileDecryptor,
//
private val oneTimeKeysUploader: OneTimeKeysUploader,
//
@ -611,10 +609,6 @@ internal class CryptoManager @Inject constructor(
}
}

override fun decryptFile(id: String, filename: String, url: String, elementToDecrypt: ElementToDecrypt, callback: MatrixCallback<File>) {
fileDecryptor.decryptFile(id, filename, url, elementToDecrypt, callback)
}

/**
* Decrypt an event
*

View File

@ -14,17 +14,18 @@
* limitations under the License.
*/

package im.vector.matrix.android.internal.crypto
package im.vector.matrix.android.internal.session

import android.content.Context
import android.os.Environment
import arrow.core.Try
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.session.content.ContentUrlResolver
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
import im.vector.matrix.android.internal.crypto.attachments.MXEncryptedAttachments
import im.vector.matrix.android.internal.extensions.foldToCallback
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.util.MatrixCoroutineDispatchers
import im.vector.matrix.android.internal.util.md5
import im.vector.matrix.android.internal.util.writeToFile
@ -38,33 +39,29 @@ import java.io.File
import java.io.IOException
import javax.inject.Inject

@SessionScope
internal class FileDecryptor @Inject constructor(private val context: Context,
internal class DefaultFileService @Inject constructor(private val context: Context,
private val sessionParams: SessionParams,
private val contentUrlResolver: ContentUrlResolver,
private val coroutineDispatchers: MatrixCoroutineDispatchers) {
private val coroutineDispatchers: MatrixCoroutineDispatchers) : FileService {

val okHttpClient = OkHttpClient()

fun decryptFile(id: String,
/**
* Download file in the cache folder, and eventually decrypt it
* TODO implement clear file, to delete "MF"
*/
override fun downloadFile(downloadMode: FileService.DownloadMode,
id: String,
fileName: String,
url: String,
elementToDecrypt: ElementToDecrypt,
url: String?,
elementToDecrypt: ElementToDecrypt?,
callback: MatrixCallback<File>) {
GlobalScope.launch(coroutineDispatchers.main) {
withContext(coroutineDispatchers.io) {
Try {
// Create dir tree:
// <cache>/DF/<md5(userId)>/<md5(id)>/
val tmpFolderRoot = File(context.cacheDir, "DF")
val tmpFolderUser = File(tmpFolderRoot, sessionParams.credentials.userId.md5())
val tmpFolder = File(tmpFolderUser, id.md5())
val folder = getFolder(downloadMode, id)

if (!tmpFolder.exists()) {
tmpFolder.mkdirs()
}

File(tmpFolder, fileName)
File(folder, fileName)
}.map { destFile ->
if (!destFile.exists()) {
Try {
@ -79,11 +76,16 @@ internal class FileDecryptor @Inject constructor(private val context: Context,
val response = okHttpClient.newCall(request).execute()
val inputStream = response.body()?.byteStream()
Timber.v("Response size ${response.body()?.contentLength()} - Stream available: ${inputStream?.available()}")
if (!response.isSuccessful) {
if (!response.isSuccessful
|| inputStream == null) {
throw IOException()
}

if (elementToDecrypt != null) {
MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt) ?: throw IllegalStateException("Decryption error")
} else {
inputStream
}
}
.map { inputStream ->
writeToFile(inputStream, destFile)
@ -96,4 +98,24 @@ internal class FileDecryptor @Inject constructor(private val context: Context,
.foldToCallback(callback)
}
}

private fun getFolder(downloadMode: FileService.DownloadMode, id: String): File {
return when (downloadMode) {
FileService.DownloadMode.FOR_INTERNAL_USE -> {
// Create dir tree (MF stands for Matrix File):
// <cache>/MF/<md5(userId)>/<md5(id)>/
val tmpFolderRoot = File(context.cacheDir, "MF")
val tmpFolderUser = File(tmpFolderRoot, sessionParams.credentials.userId.md5())
File(tmpFolderUser, id.md5())
}
FileService.DownloadMode.TO_EXPORT -> {
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
}
}
.also { folder ->
if (!folder.exists()) {
folder.mkdirs()
}
}
}
}

View File

@ -21,7 +21,6 @@ import android.os.Looper
import androidx.annotation.MainThread
import androidx.lifecycle.LiveData
import androidx.work.WorkManager
import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.api.pushrules.PushRuleService
@ -30,6 +29,7 @@ import im.vector.matrix.android.api.session.cache.CacheService
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.FileService
import im.vector.matrix.android.api.session.group.GroupService
import im.vector.matrix.android.api.session.pushers.PushersService
import im.vector.matrix.android.api.session.room.RoomDirectoryService
@ -61,6 +61,7 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se
private val pushRuleService: PushRuleService,
private val pushersService: PushersService,
private val cryptoService: CryptoManager,
private val fileService: FileService,
private val syncThread: SyncThread,
private val contentUrlResolver: ContentUrlResolver,
private val contentUploadProgressTracker: ContentUploadStateTracker)
@ -73,6 +74,7 @@ internal class DefaultSession @Inject constructor(override val sessionParams: Se
CacheService by cacheService,
SignOutService by signOutService,
FilterService by filterService,
FileService by fileService,
PushRuleService by pushRuleService,
PushersService by pushersService {


View File

@ -19,6 +19,7 @@ package im.vector.matrix.android.internal.session.room
import dagger.Binds
import dagger.Module
import dagger.Provides
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.api.session.room.RoomDirectoryService
import im.vector.matrix.android.api.session.room.RoomService
import im.vector.matrix.android.api.session.room.members.MembershipService
@ -27,6 +28,7 @@ import im.vector.matrix.android.api.session.room.read.ReadService
import im.vector.matrix.android.api.session.room.send.SendService
import im.vector.matrix.android.api.session.room.state.StateService
import im.vector.matrix.android.api.session.room.timeline.TimelineService
import im.vector.matrix.android.internal.session.DefaultFileService
import im.vector.matrix.android.internal.session.SessionScope
import im.vector.matrix.android.internal.session.room.create.CreateRoomTask
import im.vector.matrix.android.internal.session.room.create.DefaultCreateRoomTask
@ -138,4 +140,6 @@ internal abstract class RoomModule {
@Binds
abstract fun bindTimelineService(timelineService: DefaultTimelineService): TimelineService

@Binds
abstract fun bindFileService(fileService: DefaultFileService): FileService
}

View File

@ -18,6 +18,7 @@ package im.vector.riotx.features.home.room.detail

import com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.room.model.EditAggregatedSummary
import im.vector.matrix.android.api.session.room.model.message.MessageFileContent
import im.vector.matrix.android.api.session.room.timeline.Timeline
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent

@ -33,6 +34,7 @@ sealed class RoomDetailActions {
data class UpdateQuickReactAction(val targetEventId: String, val selectedReaction: String, val add: Boolean) : RoomDetailActions()
data class ShowEditHistoryAction(val event: String, val editAggregatedSummary: EditAggregatedSummary) : RoomDetailActions()
data class NavigateToEvent(val eventId: String, val position: Int?) : RoomDetailActions()
data class DownloadFile(val eventId: String, val messageFileContent: MessageFileContent) : RoomDetailActions()
object AcceptInvite : RoomDetailActions()
object RejectInvite : RoomDetailActions()


View File

@ -63,19 +63,14 @@ import im.vector.riotx.R
import im.vector.riotx.core.di.ScreenComponent
import im.vector.riotx.core.dialogs.DialogListItem
import im.vector.riotx.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotx.core.error.ErrorFormatter
import im.vector.riotx.core.extensions.hideKeyboard
import im.vector.riotx.core.extensions.observeEvent
import im.vector.riotx.core.extensions.setTextOrHide
import im.vector.riotx.core.files.addEntryToDownloadManager
import im.vector.riotx.core.glide.GlideApp
import im.vector.riotx.core.platform.VectorBaseFragment
import im.vector.riotx.core.utils.PERMISSIONS_FOR_TAKING_PHOTO
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA
import im.vector.riotx.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA
import im.vector.riotx.core.utils.checkPermissions
import im.vector.riotx.core.utils.copyToClipboard
import im.vector.riotx.core.utils.openCamera
import im.vector.riotx.core.utils.shareMedia
import im.vector.riotx.core.utils.*
import im.vector.riotx.features.autocomplete.command.AutocompleteCommandPresenter
import im.vector.riotx.features.autocomplete.command.CommandAutocompletePolicy
import im.vector.riotx.features.autocomplete.user.AutocompleteUserPresenter
@ -180,6 +175,7 @@ class RoomDetailFragment :
@Inject lateinit var notificationDrawerManager: NotificationDrawerManager
@Inject lateinit var roomDetailViewModelFactory: RoomDetailViewModel.Factory
@Inject lateinit var textComposerViewModelFactory: TextComposerViewModel.Factory
@Inject lateinit var errorFormatter: ErrorFormatter
private lateinit var scrollOnNewMessageCallback: ScrollOnNewMessageCallback
private lateinit var scrollOnHighlightedEventCallback: ScrollOnHighlightedEventCallback

@ -220,6 +216,15 @@ class RoomDetailFragment :
scrollOnHighlightedEventCallback.scheduleScrollTo(it)
}

roomDetailViewModel.downloadedFileEvent.observeEvent(this) { downloadFileState ->
if (downloadFileState.throwable != null) {
requireActivity().toast(errorFormatter.toHumanReadable(downloadFileState.throwable))
} else if (downloadFileState.file != null) {
requireActivity().toast(getString(R.string.downloaded_file, downloadFileState.file.path))
addEntryToDownloadManager(requireContext(), downloadFileState.file, downloadFileState.mimeType)
}
}

roomDetailViewModel.selectSubscribe(
RoomDetailViewState::sendMode,
RoomDetailViewState::selectedEvent,
@ -615,8 +620,8 @@ class RoomDetailFragment :
startActivity(intent)
}

override fun onFileMessageClicked(messageFileContent: MessageFileContent) {
vectorBaseActivity.notImplemented("open file")
override fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent) {
roomDetailViewModel.process(RoomDetailActions.DownloadFile(eventId, messageFileContent))
}

override fun onAudioMessageClicked(messageAudioContent: MessageAudioContent) {

View File

@ -16,6 +16,7 @@

package im.vector.riotx.features.home.room.detail

import android.content.ClipDescription
import android.net.Uri
import android.text.TextUtils
import androidx.lifecycle.LiveData
@ -31,10 +32,12 @@ import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.content.ContentAttachmentData
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.MessageContent
import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.matrix.android.internal.crypto.attachments.toElementToDecrypt
import im.vector.matrix.rx.rx
import im.vector.riotx.R
import im.vector.riotx.core.intent.getFilenameFromUri
@ -50,6 +53,7 @@ import io.reactivex.rxkotlin.subscribeBy
import org.commonmark.parser.Parser
import org.commonmark.renderer.html.HtmlRenderer
import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
import java.util.concurrent.TimeUnit
@ -113,6 +117,7 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
is RoomDetailActions.EnterEditMode -> handleEditAction(action)
is RoomDetailActions.EnterQuoteMode -> handleQuoteAction(action)
is RoomDetailActions.EnterReplyMode -> handleReplyAction(action)
is RoomDetailActions.DownloadFile -> handleDownloadFile(action)
is RoomDetailActions.NavigateToEvent -> handleNavigateToEvent(action)
else -> Timber.e("Unhandled Action: $action")
}
@ -149,6 +154,10 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
val navigateToEvent: LiveData<LiveEvent<String>>
get() = _navigateToEvent

private val _downloadedFileEvent = MutableLiveData<LiveEvent<DownloadFileState>>()
val downloadedFileEvent: LiveData<LiveEvent<DownloadFileState>>
get() = _downloadedFileEvent


// PRIVATE METHODS *****************************************************************************

@ -433,6 +442,46 @@ class RoomDetailViewModel @AssistedInject constructor(@Assisted initialState: Ro
}
}

data class DownloadFileState(
val mimeType: String,
val file: File?,
val throwable: Throwable?
)

private fun handleDownloadFile(action: RoomDetailActions.DownloadFile) {
session.downloadFile(
FileService.DownloadMode.TO_EXPORT,
action.eventId,
action.messageFileContent.filename ?: "file.dat",
action.messageFileContent.url,
action.messageFileContent.encryptedFileInfo?.toElementToDecrypt(),
object : MatrixCallback<File> {
override fun onSuccess(data: File) {
_downloadedFileEvent.postValue(LiveEvent(DownloadFileState(
// Mimetype default to plain text, should not be used
action.messageFileContent.encryptedFileInfo?.mimetype
?: action.messageFileContent.info?.mimeType
?: ClipDescription.MIMETYPE_TEXT_PLAIN,
data,
null
)))
}

override fun onFailure(failure: Throwable) {
_downloadedFileEvent.postValue(LiveEvent(DownloadFileState(
// Mimetype default to plain text, should not be used
action.messageFileContent.encryptedFileInfo?.mimetype
?: action.messageFileContent.info?.mimeType
?: ClipDescription.MIMETYPE_TEXT_PLAIN,
null,
failure
)))
}
})

}


private fun handleNavigateToEvent(action: RoomDetailActions.NavigateToEvent) {
val targetEventId = action.eventId


View File

@ -57,7 +57,7 @@ class TimelineEventController @Inject constructor(private val dateFormatter: Tim
fun onEncryptedMessageClicked(informationData: MessageInformationData, view: View)
fun onImageMessageClicked(messageImageContent: MessageImageContent, mediaData: ImageContentRenderer.Data, view: View)
fun onVideoMessageClicked(messageVideoContent: MessageVideoContent, mediaData: VideoContentRenderer.Data, view: View)
fun onFileMessageClicked(messageFileContent: MessageFileContent)
fun onFileMessageClicked(eventId: String, messageFileContent: MessageFileContent)
fun onAudioMessageClicked(messageAudioContent: MessageAudioContent)
fun onEditedDecorationClicked(informationData: MessageInformationData, editAggregatedSummary: EditAggregatedSummary?)
}

View File

@ -162,7 +162,7 @@ class MessageItemFactory @Inject constructor(
}
.clickListener(
DebouncedClickListener(View.OnClickListener { _ ->
callback?.onFileMessageClicked(messageContent)
callback?.onFileMessageClicked(informationData.eventId, messageContent)
}))
}


View File

@ -23,6 +23,7 @@ import android.widget.TextView
import android.widget.VideoView
import androidx.core.view.isVisible
import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.file.FileService
import im.vector.matrix.android.internal.crypto.attachments.ElementToDecrypt
import im.vector.riotx.R
import im.vector.riotx.core.di.ActiveSessionHolder
@ -64,7 +65,9 @@ class VideoContentRenderer @Inject constructor(private val activeSessionHolder:
loadingView.isVisible = true

activeSessionHolder.getActiveSession()
.decryptFile(data.eventId,
.downloadFile(
FileService.DownloadMode.FOR_INTERNAL_USE,
data.eventId,
data.filename,
data.url,
data.elementToDecrypt,

View File

@ -11,5 +11,8 @@
<string name="send_file_step_encrypting_file">Encrypting file…</string>
<string name="send_file_step_sending_file">Sending file (%1$s / %2$s)</string>

<string name="downloading_file">Downloading file %1$s…</string>
<string name="downloaded_file">File %1$s has been downloaded!</string>


</resources>