Media: start to play with uploading media

This commit is contained in:
ganfra 2019-04-03 22:54:48 +02:00
parent 96a67a44ac
commit 18591d0287
16 changed files with 544 additions and 52 deletions

View File

@ -1,6 +1,7 @@
apply plugin: 'com.android.library' apply plugin: 'com.android.library'
apply plugin: 'kotlin-android' apply plugin: 'kotlin-android'
apply plugin: 'kotlin-kapt' apply plugin: 'kotlin-kapt'
apply plugin: 'kotlin-android-extensions'
apply plugin: 'realm-android' apply plugin: 'realm-android'
apply plugin: 'okreplay' apply plugin: 'okreplay'


@ -19,6 +20,10 @@ repositories {
jcenter() jcenter()
} }


androidExtensions {
experimental = true
}

android { android {
compileSdkVersion 28 compileSdkVersion 28
testOptions.unitTests.includeAndroidResources = true testOptions.unitTests.includeAndroidResources = true

View File

@ -19,6 +19,7 @@ package im.vector.matrix.android.api.session.room.send
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.util.Cancelable import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.session.room.media.MediaAttachment


/** /**
* This interface defines methods to send events in a room. It's implemented at the room level. * This interface defines methods to send events in a room. It's implemented at the room level.
@ -33,5 +34,6 @@ interface SendService {
*/ */
fun sendTextMessage(text: String, callback: MatrixCallback<Event>): Cancelable fun sendTextMessage(text: String, callback: MatrixCallback<Event>): Cancelable


fun sendMedia(attachment: MediaAttachment, callback: MatrixCallback<Event>): Cancelable


} }

View File

@ -21,7 +21,7 @@ import im.vector.matrix.android.api.session.content.ContentUrlResolver




private const val MATRIX_CONTENT_URI_SCHEME = "mxc://" private const val MATRIX_CONTENT_URI_SCHEME = "mxc://"
private const val URI_PREFIX_CONTENT_API = "/_matrix/media/v1/" internal const val URI_PREFIX_CONTENT_API = "/_matrix/media/v1/"


internal class DefaultContentUrlResolver(private val homeServerConnectionConfig: HomeServerConnectionConfig) : ContentUrlResolver { internal class DefaultContentUrlResolver(private val homeServerConnectionConfig: HomeServerConnectionConfig) : ContentUrlResolver {



View File

@ -17,7 +17,6 @@
package im.vector.matrix.android.internal.session.room package im.vector.matrix.android.internal.session.room


import com.zhuinden.monarchy.Monarchy import com.zhuinden.monarchy.Monarchy
import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.room.Room import im.vector.matrix.android.api.session.room.Room
import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor import im.vector.matrix.android.internal.session.room.members.RoomMemberExtractor
@ -33,7 +32,6 @@ import im.vector.matrix.android.internal.task.TaskExecutor


internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask, internal class RoomFactory(private val loadRoomMembersTask: LoadRoomMembersTask,
private val monarchy: Monarchy, private val monarchy: Monarchy,
private val credentials: Credentials,
private val paginationTask: PaginationTask, private val paginationTask: PaginationTask,
private val contextOfEventTask: GetContextOfEventTask, private val contextOfEventTask: GetContextOfEventTask,
private val setReadMarkersTask: SetReadMarkersTask, private val setReadMarkersTask: SetReadMarkersTask,

View File

@ -17,6 +17,7 @@
package im.vector.matrix.android.internal.session.room package im.vector.matrix.android.internal.session.room


import im.vector.matrix.android.internal.session.DefaultSession import im.vector.matrix.android.internal.session.DefaultSession
import im.vector.matrix.android.internal.session.room.media.MediaUploader
import im.vector.matrix.android.internal.session.room.members.DefaultLoadRoomMembersTask import im.vector.matrix.android.internal.session.room.members.DefaultLoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask import im.vector.matrix.android.internal.session.room.members.LoadRoomMembersTask
import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask
@ -61,7 +62,11 @@ class RoomModule {
} }


scope(DefaultSession.SCOPE) { scope(DefaultSession.SCOPE) {
RoomFactory(get(), get(), get(), get(), get(), get(), get(), get()) MediaUploader(get(), get())
}

scope(DefaultSession.SCOPE) {
RoomFactory(get(), get(), get(), get(), get(), get(), get())
} }


} }

View File

@ -0,0 +1,36 @@
/*
*
* * 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.internal.session.room.media

import android.net.Uri
import android.os.Parcelable
import kotlinx.android.parcel.Parcelize

@Parcelize
data class MediaAttachment(
val size: Long = 0,
val duration: Long = 0,
val date: Long = 0,
val height: Long = 0,
val width: Long = 0,
val name: String? = null,
val thumbnail: Uri? = null,
val path: String? = null,
val mimeType: String? = null
) : Parcelable

View File

@ -0,0 +1,60 @@
/*
*
* * 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.internal.session.room.media

import arrow.core.Try
import im.vector.matrix.android.api.auth.data.SessionParams
import im.vector.matrix.android.internal.session.content.URI_PREFIX_CONTENT_API
import okhttp3.MediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody
import java.io.File
import java.io.IOException


internal class MediaUploader(private val okHttpClient: OkHttpClient,
private val sessionParams: SessionParams) {

fun uploadFile(attachment: MediaAttachment): Try<String> {
if (attachment.path == null || attachment.mimeType == null) {
return Try.raise(RuntimeException())
}
val urlString = sessionParams.homeServerConnectionConfig.homeServerUri.toString() + URI_PREFIX_CONTENT_API + "upload"
val file = File(attachment.path)

// create RequestBody instance from file
val requestFile = RequestBody.create(
MediaType.parse(attachment.mimeType),
file
)
val request = Request.Builder()
.url(urlString)
.post(requestFile)
.build()

return okHttpClient.newCall(request).execute().use { response ->
if (response.isSuccessful) {
Try.raise(IOException(""))
} else {
Try.just(response.message())
}
}
}
}

View File

@ -0,0 +1,49 @@
/*
*
* * 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.internal.session.room.media

import android.content.Context
import androidx.work.Worker
import androidx.work.WorkerParameters
import com.squareup.moshi.JsonClass
import im.vector.matrix.android.internal.di.MatrixKoinComponent
import im.vector.matrix.android.internal.util.WorkerParamsFactory
import org.koin.standalone.inject

internal class UploadMediaWorker(context: Context, params: WorkerParameters)
: Worker(context, params), MatrixKoinComponent {

private val mediaUploader by inject<MediaUploader>()

@JsonClass(generateAdapter = true)
internal data class Params(
val attachment: MediaAttachment
)

override fun doWork(): Result {
val params = WorkerParamsFactory.fromData<Params>(inputData)
?: return Result.failure()

return mediaUploader
.uploadFile(params.attachment)
.fold({ Result.retry() }, { Result.success() })
}


}

View File

@ -25,6 +25,8 @@ import im.vector.matrix.android.api.util.Cancelable
import im.vector.matrix.android.internal.database.helper.add import im.vector.matrix.android.internal.database.helper.add
import im.vector.matrix.android.internal.database.model.ChunkEntity import im.vector.matrix.android.internal.database.model.ChunkEntity
import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom import im.vector.matrix.android.internal.database.query.findLastLiveChunkFromRoom
import im.vector.matrix.android.internal.session.room.media.MediaAttachment
import im.vector.matrix.android.internal.session.room.media.UploadMediaWorker
import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection import im.vector.matrix.android.internal.session.room.timeline.PaginationDirection
import im.vector.matrix.android.internal.util.CancelableWork import im.vector.matrix.android.internal.util.CancelableWork
import im.vector.matrix.android.internal.util.WorkerParamsFactory import im.vector.matrix.android.internal.util.WorkerParamsFactory
@ -32,39 +34,70 @@ import im.vector.matrix.android.internal.util.tryTransactionAsync
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit


private const val SEND_WORK = "SEND_WORK" private const val SEND_WORK = "SEND_WORK"
private const val BACKOFF_DELAY = 10_000L
private val WORK_CONSTRAINTS = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()


internal class DefaultSendService(private val roomId: String, internal class DefaultSendService(private val roomId: String,
private val eventFactory: EventFactory, private val eventFactory: EventFactory,
private val monarchy: Monarchy) : SendService { private val monarchy: Monarchy) : SendService {


private val sendConstraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.CONNECTED)
.build()


override fun sendTextMessage(text: String, callback: MatrixCallback<Event>): Cancelable { override fun sendTextMessage(text: String, callback: MatrixCallback<Event>): Cancelable {
val event = eventFactory.createTextEvent(roomId, text) val event = eventFactory.createTextEvent(roomId, text)

saveLiveEvent(event)
monarchy.tryTransactionAsync { realm -> val sendWork = createSendEventWork(event)
val chunkEntity = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
?: return@tryTransactionAsync
chunkEntity.add(roomId, event, PaginationDirection.FORWARDS)
}

val sendContentWorkerParams = SendEventWorker.Params(roomId, event)
val workData = WorkerParamsFactory.toData(sendContentWorkerParams)

val sendWork = OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(sendConstraints)
.setInputData(workData)
.setBackoffCriteria(BackoffPolicy.LINEAR, 10_000, TimeUnit.MILLISECONDS)
.build()

WorkManager.getInstance() WorkManager.getInstance()
.beginUniqueWork(SEND_WORK, ExistingWorkPolicy.APPEND, sendWork) .beginUniqueWork(SEND_WORK, ExistingWorkPolicy.APPEND, sendWork)
.enqueue() .enqueue()


return CancelableWork(sendWork.id) return CancelableWork(sendWork.id)
}


override fun sendMedia(attachment: MediaAttachment, callback: MatrixCallback<Event>): Cancelable {
// Create an event with the media file path
val event = eventFactory.createImageEvent(roomId, attachment)
saveLiveEvent(event)

val uploadWork = createUploadMediaWork(attachment)
val sendWork = createSendEventWork(event)

WorkManager.getInstance()
.beginUniqueWork(SEND_WORK, ExistingWorkPolicy.APPEND, uploadWork)
.then(sendWork)
.enqueue()
return CancelableWork(sendWork.id)
}

private fun saveLiveEvent(event: Event) {
monarchy.tryTransactionAsync { realm ->
val chunkEntity = ChunkEntity.findLastLiveChunkFromRoom(realm, roomId)
?: return@tryTransactionAsync
chunkEntity.add(roomId, event, PaginationDirection.FORWARDS)
}
}

private fun createSendEventWork(event: Event): OneTimeWorkRequest {
val sendContentWorkerParams = SendEventWorker.Params(roomId, event)
val sendWorkData = WorkerParamsFactory.toData(sendContentWorkerParams)

return OneTimeWorkRequestBuilder<SendEventWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(sendWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
}

private fun createUploadMediaWork(attachment: MediaAttachment): OneTimeWorkRequest {
val uploadMediaWorkerParams = UploadMediaWorker.Params(attachment)
val uploadWorkData = WorkerParamsFactory.toData(uploadMediaWorkerParams)

return OneTimeWorkRequestBuilder<UploadMediaWorker>()
.setConstraints(WORK_CONSTRAINTS)
.setInputData(uploadWorkData)
.setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS)
.build()
} }


} }

View File

@ -17,27 +17,45 @@
package im.vector.matrix.android.internal.session.room.send package im.vector.matrix.android.internal.session.room.send


import im.vector.matrix.android.api.auth.data.Credentials import im.vector.matrix.android.api.auth.data.Credentials
import im.vector.matrix.android.api.session.events.model.Content
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.api.session.events.model.EventType import im.vector.matrix.android.api.session.events.model.EventType
import im.vector.matrix.android.api.session.events.model.toContent
import im.vector.matrix.android.api.session.events.model.toModel
import im.vector.matrix.android.api.session.room.model.message.MessageImageContent
import im.vector.matrix.android.api.session.room.model.message.MessageTextContent import im.vector.matrix.android.api.session.room.model.message.MessageTextContent
import im.vector.matrix.android.api.session.room.model.message.MessageType import im.vector.matrix.android.api.session.room.model.message.MessageType
import im.vector.matrix.android.internal.di.MoshiProvider import im.vector.matrix.android.internal.session.room.media.MediaAttachment


internal class EventFactory(private val credentials: Credentials) { internal class EventFactory(private val credentials: Credentials) {


private val moshi = MoshiProvider.providesMoshi()

fun createTextEvent(roomId: String, text: String): Event { fun createTextEvent(roomId: String, text: String): Event {
val content = MessageTextContent(type = MessageType.MSGTYPE_TEXT, body = text) val content = MessageTextContent(type = MessageType.MSGTYPE_TEXT, body = text)
return createEvent(roomId, content)
}


fun createImageEvent(roomId: String, attachment: MediaAttachment): Event {
val content = MessageImageContent(
type = MessageType.MSGTYPE_IMAGE,
body = attachment.name ?: "image",
url = attachment.path
)
return createEvent(roomId, content)
}

fun updateImageEvent(event: Event, url: String): Event {
val imageContent = event.content.toModel<MessageImageContent>() ?: return event
val updatedContent = imageContent.copy(url = url)
return event.copy(content = updatedContent.toContent())
}

fun createEvent(roomId: String, content: Any? = null): Event {
return Event( return Event(
roomId = roomId, roomId = roomId,
originServerTs = dummyOriginServerTs(), originServerTs = dummyOriginServerTs(),
sender = credentials.userId, sender = credentials.userId,
eventId = dummyEventId(roomId), eventId = dummyEventId(roomId),
type = EventType.MESSAGE, type = EventType.MESSAGE,
content = toContent(content) content = content.toContent()
) )
} }


@ -48,13 +66,4 @@ internal class EventFactory(private val credentials: Credentials) {
private fun dummyEventId(roomId: String): String { private fun dummyEventId(roomId: String): String {
return roomId + "-" + dummyOriginServerTs() return roomId + "-" + dummyOriginServerTs()
} }

@Suppress("UNCHECKED_CAST")
private inline fun <reified T> toContent(data: T?): Content? {
val moshiAdapter = moshi.adapter(T::class.java)
val jsonValue = moshiAdapter.toJsonValue(data)
return jsonValue as? Content?
}


} }

View File

@ -149,7 +149,7 @@ dependencies {
def coroutines_version = "1.0.1" def coroutines_version = "1.0.1"
def markwon_version = '3.0.0-SNAPSHOT' def markwon_version = '3.0.0-SNAPSHOT'
def big_image_viewer_version = '1.5.6' def big_image_viewer_version = '1.5.6'
def glide_version = '4.8.0' def glide_version = '4.9.0'


implementation project(":matrix-sdk-android") implementation project(":matrix-sdk-android")
implementation project(":matrix-sdk-android-rx") implementation project(":matrix-sdk-android-rx")
@ -209,6 +209,9 @@ dependencies {
implementation "com.github.bumptech.glide:glide:$glide_version" implementation "com.github.bumptech.glide:glide:$glide_version"
kapt "com.github.bumptech.glide:compiler:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version"


implementation 'com.github.jaiselrahman:FilePicker:1.2.0'


// DI // DI
implementation "org.koin:koin-android:$koin_version" implementation "org.koin:koin-android:$koin_version"
implementation "org.koin:koin-android-scope:$koin_version" implementation "org.koin:koin-android-scope:$koin_version"

View File

@ -0,0 +1,240 @@
/*
*
* * 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.riotredesign.core.utils

import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.ContentValues
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import android.provider.Browser
import android.provider.MediaStore
import androidx.core.content.FileProvider
import androidx.fragment.app.Fragment
import im.vector.riotredesign.BuildConfig
import im.vector.riotredesign.R
import timber.log.Timber
import java.io.File
import java.text.SimpleDateFormat
import java.util.*

/**
* Open a url in the internet browser of the system
*/
fun openUrlInExternalBrowser(context: Context, url: String?) {
url?.let {
openUrlInExternalBrowser(context, Uri.parse(it))
}
}

/**
* Open a uri in the internet browser of the system
*/
fun openUrlInExternalBrowser(context: Context, uri: Uri?) {
uri?.let {
val browserIntent = Intent(Intent.ACTION_VIEW, it).apply {
putExtra(Browser.EXTRA_APPLICATION_ID, context.packageName)
}

try {
context.startActivity(browserIntent)
} catch (activityNotFoundException: ActivityNotFoundException) {
context.toast(R.string.error_no_external_application_found)
}
}
}

/**
* Open sound recorder external application
*/
fun openSoundRecorder(activity: Activity, requestCode: Int) {
val recordSoundIntent = Intent(MediaStore.Audio.Media.RECORD_SOUND_ACTION)

// Create chooser
val chooserIntent = Intent.createChooser(recordSoundIntent, activity.getString(R.string.go_on_with))

try {
activity.startActivityForResult(chooserIntent, requestCode)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Open file selection activity
*/
fun openFileSelection(activity: Activity,
fragment: Fragment?,
allowMultipleSelection: Boolean,
requestCode: Int) {
val fileIntent = Intent(Intent.ACTION_GET_CONTENT)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
fileIntent.putExtra(Intent.EXTRA_ALLOW_MULTIPLE, allowMultipleSelection)
}

fileIntent.addCategory(Intent.CATEGORY_OPENABLE)
fileIntent.type = "*/*"

try {
fragment
?.startActivityForResult(fileIntent, requestCode)
?: run {
activity.startActivityForResult(fileIntent, requestCode)
}
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Open external video recorder
*/
fun openVideoRecorder(activity: Activity, requestCode: Int) {
val captureIntent = Intent(MediaStore.ACTION_VIDEO_CAPTURE)

// lowest quality
captureIntent.putExtra(MediaStore.EXTRA_VIDEO_QUALITY, 0)

try {
activity.startActivityForResult(captureIntent, requestCode)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Open external camera
* @return the latest taken picture camera uri
*/
fun openCamera(activity: Activity, titlePrefix: String, requestCode: Int): String? {
val captureIntent = Intent(MediaStore.ACTION_IMAGE_CAPTURE)

// the following is a fix for buggy 2.x devices
val date = Date()
val formatter = SimpleDateFormat("yyyyMMddHHmmss", Locale.US)
val values = ContentValues()
values.put(MediaStore.Images.Media.TITLE, titlePrefix + formatter.format(date))
// The Galaxy S not only requires the name of the file to output the image to, but will also not
// set the mime type of the picture it just took (!!!). We assume that the Galaxy S takes image/jpegs
// so the attachment uploader doesn't freak out about there being no mimetype in the content database.
values.put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
var dummyUri: Uri? = null
try {
dummyUri = activity.contentResolver.insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)

if (null == dummyUri) {
Timber.e("Cannot use the external storage media to save image")
}
} catch (uoe: UnsupportedOperationException) {
Timber.e(uoe, "Unable to insert camera URI into MediaStore.Images.Media.EXTERNAL_CONTENT_URI " +
"no SD card? Attempting to insert into device storage.")
} catch (e: Exception) {
Timber.e(e, "Unable to insert camera URI into MediaStore.Images.Media.EXTERNAL_CONTENT_URI. $e")
}

if (null == dummyUri) {
try {
dummyUri = activity.contentResolver.insert(MediaStore.Images.Media.INTERNAL_CONTENT_URI, values)
if (null == dummyUri) {
Timber.e("Cannot use the internal storage to save media to save image")
}

} catch (e: Exception) {
Timber.e(e, "Unable to insert camera URI into internal storage. Giving up. $e")
}
}

if (dummyUri != null) {
captureIntent.putExtra(MediaStore.EXTRA_OUTPUT, dummyUri)
Timber.d("trying to take a photo on " + dummyUri.toString())
} else {
Timber.d("trying to take a photo with no predefined uri")
}

// Store the dummy URI which will be set to a placeholder location. When all is lost on Samsung devices,
// this will point to the data we're looking for.
// Because Activities tend to use a single MediaProvider for all their intents, this field will only be the
// *latest* TAKE_PICTURE Uri. This is deemed acceptable as the normal flow is to create the intent then immediately
// fire it, meaning onActivityResult/getUri will be the next thing called, not another createIntentFor.
val result = if (dummyUri == null) null else dummyUri.toString()

try {
activity.startActivityForResult(captureIntent, requestCode)

return result
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}

return null
}

/**
* Send an email to address with optional subject and message
*/
fun sendMailTo(address: String, subject: String? = null, message: String? = null, activity: Activity) {
val intent = Intent(Intent.ACTION_SENDTO, Uri.fromParts(
"mailto", address, null))
intent.putExtra(Intent.EXTRA_SUBJECT, subject)
intent.putExtra(Intent.EXTRA_TEXT, message)

try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Open an arbitrary uri
*/
fun openUri(activity: Activity, uri: String) {
val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri))

try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

/**
* Send media to a third party application.
*
* @param activity the activity
* @param savedMediaPath the media path
* @param mimeType the media mime type.
*/
fun openMedia(activity: Activity, savedMediaPath: String, mimeType: String) {
val file = File(savedMediaPath)
val uri = FileProvider.getUriForFile(activity, BuildConfig.APPLICATION_ID + ".fileProvider", file)

val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mimeType)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}

try {
activity.startActivity(intent)
} catch (activityNotFoundException: ActivityNotFoundException) {
activity.toast(R.string.error_no_external_application_found)
}
}

View File

@ -16,12 +16,14 @@


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


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


sealed class RoomDetailActions { sealed class RoomDetailActions {


data class SendMessage(val text: String) : RoomDetailActions() data class SendMessage(val text: String) : RoomDetailActions()
data class SendMedia(val mediaFiles: List<MediaFile>) : RoomDetailActions()
object IsDisplayed : RoomDetailActions() object IsDisplayed : RoomDetailActions()
data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions() data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions()
data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions() data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions()

View File

@ -16,6 +16,8 @@


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


import android.app.Activity.RESULT_OK
import android.content.Intent
import android.os.Bundle import android.os.Bundle
import android.os.Parcelable import android.os.Parcelable
import android.view.LayoutInflater import android.view.LayoutInflater
@ -26,6 +28,9 @@ import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.airbnb.epoxy.EpoxyVisibilityTracker import com.airbnb.epoxy.EpoxyVisibilityTracker
import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.fragmentViewModel
import com.jaiselrahman.filepicker.activity.FilePickerActivity
import com.jaiselrahman.filepicker.config.Configurations
import com.jaiselrahman.filepicker.model.MediaFile
import im.vector.matrix.android.api.session.room.timeline.TimelineEvent import im.vector.matrix.android.api.session.room.timeline.TimelineEvent
import im.vector.riotredesign.R import im.vector.riotredesign.R
import im.vector.riotredesign.core.dialogs.DialogListItem import im.vector.riotredesign.core.dialogs.DialogListItem
@ -33,11 +38,7 @@ import im.vector.riotredesign.core.dialogs.DialogSendItemAdapter
import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer import im.vector.riotredesign.core.epoxy.LayoutManagerStateRestorer
import im.vector.riotredesign.core.platform.RiotFragment import im.vector.riotredesign.core.platform.RiotFragment
import im.vector.riotredesign.core.platform.ToolbarConfigurable import im.vector.riotredesign.core.platform.ToolbarConfigurable
import im.vector.riotredesign.core.utils.PERMISSIONS_FOR_TAKING_PHOTO import im.vector.riotredesign.core.utils.*
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_CAMERA
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA
import im.vector.riotredesign.core.utils.PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA
import im.vector.riotredesign.core.utils.checkPermissions
import im.vector.riotredesign.features.home.AvatarRenderer import im.vector.riotredesign.features.home.AvatarRenderer
import im.vector.riotredesign.features.home.HomeModule import im.vector.riotredesign.features.home.HomeModule
import im.vector.riotredesign.features.home.HomePermalinkHandler import im.vector.riotredesign.features.home.HomePermalinkHandler
@ -51,6 +52,7 @@ import org.koin.android.ext.android.inject
import org.koin.android.scope.ext.android.bindScope import org.koin.android.scope.ext.android.bindScope
import org.koin.android.scope.ext.android.getOrCreateScope import org.koin.android.scope.ext.android.getOrCreateScope
import org.koin.core.parameter.parametersOf import org.koin.core.parameter.parametersOf
import timber.log.Timber




@Parcelize @Parcelize
@ -60,6 +62,10 @@ data class RoomDetailArgs(
) : Parcelable ) : Parcelable




private const val CAMERA_VALUE_TITLE = "attachment"
private const val REQUEST_FILES_REQUEST_CODE = 0
private const val TAKE_IMAGE_REQUEST_CODE = 1

class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {


companion object { companion object {
@ -91,6 +97,16 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
roomDetailViewModel.subscribe { renderState(it) } roomDetailViewModel.subscribe { renderState(it) }
} }


override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (resultCode == RESULT_OK && data != null) {
when (requestCode) {
REQUEST_FILES_REQUEST_CODE, TAKE_IMAGE_REQUEST_CODE -> handleMediaIntent(data)
}
}
}


override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
roomDetailViewModel.process(RoomDetailActions.IsDisplayed) roomDetailViewModel.process(RoomDetailActions.IsDisplayed)
@ -150,7 +166,7 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
*/ */


// Send sticker // Send sticker
items.add(DialogListItem.SendSticker) //items.add(DialogListItem.SendSticker)
// Camera // Camera


//if (PreferencesManager.useNativeCamera(this)) { //if (PreferencesManager.useNativeCamera(this)) {
@ -161,18 +177,24 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
// } // }
val adapter = DialogSendItemAdapter(requireContext(), items) val adapter = DialogSendItemAdapter(requireContext(), items)
AlertDialog.Builder(requireContext()) AlertDialog.Builder(requireContext())
.setAdapter(adapter, { dialog, which -> .setAdapter(adapter) { _, position ->
onSendChoiceClicked(items[which]) onSendChoiceClicked(items[position])
}) }
.setNegativeButton(R.string.cancel, null) .setNegativeButton(R.string.cancel, null)
.show() .show()
} }
} }


private fun onSendChoiceClicked(dialogListItem: DialogListItem) { private fun onSendChoiceClicked(dialogListItem: DialogListItem) {
Timber.v("On send choice clicked: $dialogListItem")
when (dialogListItem) { when (dialogListItem) {
is DialogListItem.SendFile -> { is DialogListItem.SendFile -> {
//launchFileSelectionIntent() val intent = Intent(requireContext(), FilePickerActivity::class.java)
intent.putExtra(FilePickerActivity.CONFIGS, Configurations.Builder()
.setCheckPermission(true)
.setSkipZeroSizeFiles(true)
.build())
startActivityForResult(intent, REQUEST_FILES_REQUEST_CODE)
} }
is DialogListItem.SendVoice -> { is DialogListItem.SendVoice -> {
//launchAudioRecorderIntent() //launchAudioRecorderIntent()
@ -184,7 +206,7 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
// launchCamera() // launchCamera()
} }
is DialogListItem.TakePhoto -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA)) { is DialogListItem.TakePhoto -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_CAMERA)) {
// launchNativeCamera() openCamera(requireActivity(), CAMERA_VALUE_TITLE, TAKE_IMAGE_REQUEST_CODE)
} }
is DialogListItem.TakeVideo -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA)) { is DialogListItem.TakeVideo -> if (checkPermissions(PERMISSIONS_FOR_TAKING_PHOTO, requireActivity(), PERMISSION_REQUEST_CODE_LAUNCH_NATIVE_VIDEO_CAMERA)) {
// launchNativeVideoRecorder() // launchNativeVideoRecorder()
@ -192,6 +214,11 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback {
} }
} }


private fun handleMediaIntent(data: Intent) {
val files: ArrayList<MediaFile> = data.getParcelableArrayListExtra(FilePickerActivity.MEDIA_FILES)
roomDetailViewModel.process(RoomDetailActions.SendMedia(files))
}

private fun renderState(state: RoomDetailViewState) { private fun renderState(state: RoomDetailViewState) {
renderRoomSummary(state) renderRoomSummary(state)
timelineEventController.setTimeline(state.timeline) timelineEventController.setTimeline(state.timeline)

View File

@ -22,6 +22,7 @@ import com.jakewharton.rxrelay2.BehaviorRelay
import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.MatrixCallback
import im.vector.matrix.android.api.session.Session import im.vector.matrix.android.api.session.Session
import im.vector.matrix.android.api.session.events.model.Event import im.vector.matrix.android.api.session.events.model.Event
import im.vector.matrix.android.internal.session.room.media.MediaAttachment
import im.vector.matrix.rx.rx import im.vector.matrix.rx.rx
import im.vector.riotredesign.core.platform.RiotViewModel import im.vector.riotredesign.core.platform.RiotViewModel
import im.vector.riotredesign.features.home.room.VisibleRoomStore import im.vector.riotredesign.features.home.room.VisibleRoomStore
@ -64,6 +65,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
fun process(action: RoomDetailActions) { fun process(action: RoomDetailActions) {
when (action) { when (action) {
is RoomDetailActions.SendMessage -> handleSendMessage(action) is RoomDetailActions.SendMessage -> handleSendMessage(action)
is RoomDetailActions.SendMedia -> handleSendMedia(action)
is RoomDetailActions.IsDisplayed -> handleIsDisplayed() is RoomDetailActions.IsDisplayed -> handleIsDisplayed()
is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action)
is RoomDetailActions.LoadMore -> handleLoadMore(action) is RoomDetailActions.LoadMore -> handleLoadMore(action)
@ -76,6 +78,25 @@ class RoomDetailViewModel(initialState: RoomDetailViewState,
room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {}) room.sendTextMessage(action.text, callback = object : MatrixCallback<Event> {})
} }


private fun handleSendMedia(action: RoomDetailActions.SendMedia) {
val attachment = action.mediaFiles.firstOrNull()
?.let {
MediaAttachment(
it.size,
it.duration,
it.date,
it.height,
it.width,
it.name,
it.thumbnail,
it.path,
it.mimeType
)
}
?: return
room.sendMedia(attachment, callback = object : MatrixCallback<Event> {})
}

private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) {
displayedEventsObservable.accept(action) displayedEventsObservable.accept(action)
} }

View File

@ -53,7 +53,9 @@ object MediaContentRenderer {
val resolvedUrl = when (mode) { val resolvedUrl = when (mode) {
Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url) Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url)
Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
} ?: return }
//Fallback to base url
?: data.url


GlideApp GlideApp
.with(imageView) .with(imageView)
@ -68,8 +70,8 @@ object MediaContentRenderer {
val fullSize = contentUrlResolver.resolveFullSize(data.url) val fullSize = contentUrlResolver.resolveFullSize(data.url)
val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE)
imageView.showImage( imageView.showImage(
Uri.parse(thumbnail), Uri.parse(thumbnail ?: data.url),
Uri.parse(fullSize) Uri.parse(fullSize ?: data.url)
) )
} }