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,
private val sessionParams: SessionParams,
private val contentUrlResolver: ContentUrlResolver,
private val coroutineDispatchers: MatrixCoroutineDispatchers) {
internal class DefaultFileService @Inject constructor(private val context: Context,
private val sessionParams: SessionParams,
private val contentUrlResolver: ContentUrlResolver,
private val coroutineDispatchers: MatrixCoroutineDispatchers) : FileService {
val okHttpClient = OkHttpClient()
fun decryptFile(id: String,
fileName: String,
url: String,
elementToDecrypt: ElementToDecrypt,
callback: MatrixCallback<File>) {
/**
* 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?,
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()
}
MXEncryptedAttachments.decryptAttachment(inputStream, elementToDecrypt) ?: throw IllegalStateException("Decryption error")
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,20 +61,22 @@ 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)
: Session,
RoomService by roomService,
RoomDirectoryService by roomDirectoryService,
GroupService by groupService,
UserService by userService,
CryptoService by cryptoService,
CacheService by cacheService,
SignOutService by signOutService,
FilterService by filterService,
PushRuleService by pushRuleService,
PushersService by pushersService {
RoomService by roomService,
RoomDirectoryService by roomDirectoryService,
GroupService by groupService,
UserService by userService,
CryptoService by cryptoService,
CacheService by cacheService,
SignOutService by signOutService,
FilterService by filterService,
FileService by fileService,
PushRuleService by pushRuleService,
PushersService by pushersService {
private var isOpen = false

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>