From 18591d02873d4fb04984f28e4fea7360ee80ea97 Mon Sep 17 00:00:00 2001 From: ganfra Date: Wed, 3 Apr 2019 22:54:48 +0200 Subject: [PATCH] Media: start to play with uploading media --- matrix-sdk-android/build.gradle | 5 + .../api/session/room/send/SendService.kt | 2 + .../content/DefaultContentUrlResolver.kt | 2 +- .../internal/session/room/RoomFactory.kt | 2 - .../internal/session/room/RoomModule.kt | 7 +- .../session/room/media/MediaAttachment.kt | 36 +++ .../session/room/media/MediaUploader.kt | 60 +++++ .../session/room/media/UploadMediaWorker.kt | 49 ++++ .../session/room/send/DefaultSendService.kt | 71 ++++-- .../session/room/send/EventFactory.kt | 37 ++- vector/build.gradle | 5 +- .../core/utils/ExternalApplicationsUtil.kt | 240 ++++++++++++++++++ .../home/room/detail/RoomDetailActions.kt | 2 + .../home/room/detail/RoomDetailFragment.kt | 49 +++- .../home/room/detail/RoomDetailViewModel.kt | 21 ++ .../features/media/MediaContentRenderer.kt | 8 +- 16 files changed, 544 insertions(+), 52 deletions(-) create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/MediaAttachment.kt create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/MediaUploader.kt create mode 100644 matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/UploadMediaWorker.kt create mode 100644 vector/src/main/java/im/vector/riotredesign/core/utils/ExternalApplicationsUtil.kt diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index 78c8eaab..875fbda7 100644 --- a/matrix-sdk-android/build.gradle +++ b/matrix-sdk-android/build.gradle @@ -1,6 +1,7 @@ apply plugin: 'com.android.library' apply plugin: 'kotlin-android' apply plugin: 'kotlin-kapt' +apply plugin: 'kotlin-android-extensions' apply plugin: 'realm-android' apply plugin: 'okreplay' @@ -19,6 +20,10 @@ repositories { jcenter() } +androidExtensions { + experimental = true +} + android { compileSdkVersion 28 testOptions.unitTests.includeAndroidResources = true diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt index fe4cb200..1821b7b4 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/api/session/room/send/SendService.kt @@ -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.session.events.model.Event 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. @@ -33,5 +34,6 @@ interface SendService { */ fun sendTextMessage(text: String, callback: MatrixCallback): Cancelable + fun sendMedia(attachment: MediaAttachment, callback: MatrixCallback): Cancelable } \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/DefaultContentUrlResolver.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/DefaultContentUrlResolver.kt index c9b71fdb..cb8896e7 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/DefaultContentUrlResolver.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/content/DefaultContentUrlResolver.kt @@ -21,7 +21,7 @@ import im.vector.matrix.android.api.session.content.ContentUrlResolver 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 { diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt index b30ba3ee..46830ff5 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomFactory.kt @@ -17,7 +17,6 @@ package im.vector.matrix.android.internal.session.room 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.internal.session.room.members.LoadRoomMembersTask 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, private val monarchy: Monarchy, - private val credentials: Credentials, private val paginationTask: PaginationTask, private val contextOfEventTask: GetContextOfEventTask, private val setReadMarkersTask: SetReadMarkersTask, diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt index c506cb0d..2bde72b8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/RoomModule.kt @@ -17,6 +17,7 @@ package im.vector.matrix.android.internal.session.room 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.LoadRoomMembersTask import im.vector.matrix.android.internal.session.room.read.DefaultSetReadMarkersTask @@ -61,7 +62,11 @@ class RoomModule { } 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()) } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/MediaAttachment.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/MediaAttachment.kt new file mode 100644 index 00000000..7205a370 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/MediaAttachment.kt @@ -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 \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/MediaUploader.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/MediaUploader.kt new file mode 100644 index 00000000..f71d08ea --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/MediaUploader.kt @@ -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 { + 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()) + } + } + } +} \ No newline at end of file diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/UploadMediaWorker.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/UploadMediaWorker.kt new file mode 100644 index 00000000..94912863 --- /dev/null +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/media/UploadMediaWorker.kt @@ -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() + + @JsonClass(generateAdapter = true) + internal data class Params( + val attachment: MediaAttachment + ) + + override fun doWork(): Result { + val params = WorkerParamsFactory.fromData(inputData) + ?: return Result.failure() + + return mediaUploader + .uploadFile(params.attachment) + .fold({ Result.retry() }, { Result.success() }) + } + + +} diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt index ef8569e6..58a0c7e8 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/DefaultSendService.kt @@ -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.model.ChunkEntity 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.util.CancelableWork 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 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, private val eventFactory: EventFactory, private val monarchy: Monarchy) : SendService { - private val sendConstraints = Constraints.Builder() - .setRequiredNetworkType(NetworkType.CONNECTED) - .build() override fun sendTextMessage(text: String, callback: MatrixCallback): Cancelable { val event = eventFactory.createTextEvent(roomId, text) - - monarchy.tryTransactionAsync { realm -> - 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() - .setConstraints(sendConstraints) - .setInputData(workData) - .setBackoffCriteria(BackoffPolicy.LINEAR, 10_000, TimeUnit.MILLISECONDS) - .build() - + saveLiveEvent(event) + val sendWork = createSendEventWork(event) WorkManager.getInstance() .beginUniqueWork(SEND_WORK, ExistingWorkPolicy.APPEND, sendWork) .enqueue() return CancelableWork(sendWork.id) + } + override fun sendMedia(attachment: MediaAttachment, callback: MatrixCallback): 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() + .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() + .setConstraints(WORK_CONSTRAINTS) + .setInputData(uploadWorkData) + .setBackoffCriteria(BackoffPolicy.LINEAR, BACKOFF_DELAY, TimeUnit.MILLISECONDS) + .build() } } diff --git a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/EventFactory.kt b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/EventFactory.kt index 47605ea0..b0295f6c 100644 --- a/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/EventFactory.kt +++ b/matrix-sdk-android/src/main/java/im/vector/matrix/android/internal/session/room/send/EventFactory.kt @@ -17,27 +17,45 @@ package im.vector.matrix.android.internal.session.room.send 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.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.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) { - private val moshi = MoshiProvider.providesMoshi() - fun createTextEvent(roomId: String, text: String): Event { 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() ?: return event + val updatedContent = imageContent.copy(url = url) + return event.copy(content = updatedContent.toContent()) + } + + fun createEvent(roomId: String, content: Any? = null): Event { return Event( roomId = roomId, originServerTs = dummyOriginServerTs(), sender = credentials.userId, eventId = dummyEventId(roomId), 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 { return roomId + "-" + dummyOriginServerTs() } - - @Suppress("UNCHECKED_CAST") - private inline fun toContent(data: T?): Content? { - val moshiAdapter = moshi.adapter(T::class.java) - val jsonValue = moshiAdapter.toJsonValue(data) - return jsonValue as? Content? - } - - } diff --git a/vector/build.gradle b/vector/build.gradle index 6856d591..d7ef8c9c 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -149,7 +149,7 @@ dependencies { def coroutines_version = "1.0.1" def markwon_version = '3.0.0-SNAPSHOT' 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-rx") @@ -209,6 +209,9 @@ dependencies { implementation "com.github.bumptech.glide:glide:$glide_version" kapt "com.github.bumptech.glide:compiler:$glide_version" + implementation 'com.github.jaiselrahman:FilePicker:1.2.0' + + // DI implementation "org.koin:koin-android:$koin_version" implementation "org.koin:koin-android-scope:$koin_version" diff --git a/vector/src/main/java/im/vector/riotredesign/core/utils/ExternalApplicationsUtil.kt b/vector/src/main/java/im/vector/riotredesign/core/utils/ExternalApplicationsUtil.kt new file mode 100644 index 00000000..318549b2 --- /dev/null +++ b/vector/src/main/java/im/vector/riotredesign/core/utils/ExternalApplicationsUtil.kt @@ -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) + } +} diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt index f7a59f2d..38c436b5 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailActions.kt @@ -16,12 +16,14 @@ 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.TimelineEvent sealed class RoomDetailActions { data class SendMessage(val text: String) : RoomDetailActions() + data class SendMedia(val mediaFiles: List) : RoomDetailActions() object IsDisplayed : RoomDetailActions() data class EventDisplayed(val event: TimelineEvent) : RoomDetailActions() data class LoadMore(val direction: Timeline.Direction) : RoomDetailActions() diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt index b87628fa..d24ca52f 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailFragment.kt @@ -16,6 +16,8 @@ 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.Parcelable import android.view.LayoutInflater @@ -26,6 +28,9 @@ import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import com.airbnb.epoxy.EpoxyVisibilityTracker 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.riotredesign.R 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.platform.RiotFragment import im.vector.riotredesign.core.platform.ToolbarConfigurable -import im.vector.riotredesign.core.utils.PERMISSIONS_FOR_TAKING_PHOTO -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.core.utils.* import im.vector.riotredesign.features.home.AvatarRenderer import im.vector.riotredesign.features.home.HomeModule 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.getOrCreateScope import org.koin.core.parameter.parametersOf +import timber.log.Timber @Parcelize @@ -60,6 +62,10 @@ data class RoomDetailArgs( ) : 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 { companion object { @@ -91,6 +97,16 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { 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() { super.onResume() roomDetailViewModel.process(RoomDetailActions.IsDisplayed) @@ -150,7 +166,7 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { */ // Send sticker - items.add(DialogListItem.SendSticker) + //items.add(DialogListItem.SendSticker) // Camera //if (PreferencesManager.useNativeCamera(this)) { @@ -161,18 +177,24 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { // } val adapter = DialogSendItemAdapter(requireContext(), items) AlertDialog.Builder(requireContext()) - .setAdapter(adapter, { dialog, which -> - onSendChoiceClicked(items[which]) - }) + .setAdapter(adapter) { _, position -> + onSendChoiceClicked(items[position]) + } .setNegativeButton(R.string.cancel, null) .show() } } private fun onSendChoiceClicked(dialogListItem: DialogListItem) { + Timber.v("On send choice clicked: $dialogListItem") when (dialogListItem) { 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 -> { //launchAudioRecorderIntent() @@ -184,7 +206,7 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { // launchCamera() } 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)) { // launchNativeVideoRecorder() @@ -192,6 +214,11 @@ class RoomDetailFragment : RiotFragment(), TimelineEventController.Callback { } } + private fun handleMediaIntent(data: Intent) { + val files: ArrayList = data.getParcelableArrayListExtra(FilePickerActivity.MEDIA_FILES) + roomDetailViewModel.process(RoomDetailActions.SendMedia(files)) + } + private fun renderState(state: RoomDetailViewState) { renderRoomSummary(state) timelineEventController.setTimeline(state.timeline) diff --git a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt index 4ebf4984..58cb2c8a 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/home/room/detail/RoomDetailViewModel.kt @@ -22,6 +22,7 @@ import com.jakewharton.rxrelay2.BehaviorRelay import im.vector.matrix.android.api.MatrixCallback import im.vector.matrix.android.api.session.Session 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.riotredesign.core.platform.RiotViewModel import im.vector.riotredesign.features.home.room.VisibleRoomStore @@ -64,6 +65,7 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, fun process(action: RoomDetailActions) { when (action) { is RoomDetailActions.SendMessage -> handleSendMessage(action) + is RoomDetailActions.SendMedia -> handleSendMedia(action) is RoomDetailActions.IsDisplayed -> handleIsDisplayed() is RoomDetailActions.EventDisplayed -> handleEventDisplayed(action) is RoomDetailActions.LoadMore -> handleLoadMore(action) @@ -76,6 +78,25 @@ class RoomDetailViewModel(initialState: RoomDetailViewState, room.sendTextMessage(action.text, callback = object : MatrixCallback {}) } + 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 {}) + } + private fun handleEventDisplayed(action: RoomDetailActions.EventDisplayed) { displayedEventsObservable.accept(action) } diff --git a/vector/src/main/java/im/vector/riotredesign/features/media/MediaContentRenderer.kt b/vector/src/main/java/im/vector/riotredesign/features/media/MediaContentRenderer.kt index 6af33070..2468bc80 100644 --- a/vector/src/main/java/im/vector/riotredesign/features/media/MediaContentRenderer.kt +++ b/vector/src/main/java/im/vector/riotredesign/features/media/MediaContentRenderer.kt @@ -53,7 +53,9 @@ object MediaContentRenderer { val resolvedUrl = when (mode) { Mode.FULL_SIZE -> contentUrlResolver.resolveFullSize(data.url) Mode.THUMBNAIL -> contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) - } ?: return + } + //Fallback to base url + ?: data.url GlideApp .with(imageView) @@ -68,8 +70,8 @@ object MediaContentRenderer { val fullSize = contentUrlResolver.resolveFullSize(data.url) val thumbnail = contentUrlResolver.resolveThumbnail(data.url, width, height, ContentUrlResolver.ThumbnailMethod.SCALE) imageView.showImage( - Uri.parse(thumbnail), - Uri.parse(fullSize) + Uri.parse(thumbnail ?: data.url), + Uri.parse(fullSize ?: data.url) ) }