diff --git a/CHANGES.md b/CHANGES.md index 01f5aa31a1a..7a596066aa6 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -10,6 +10,7 @@ Improvements πŸ™Œ: - Compress video before sending (#442) - Improve file too big error detection (#3245) - User can now select video when selecting Gallery to send attachments to a room + - Add option to record a video from the camera Bugfix πŸ›: - Message states cosmetic changes (#3007) diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/CameraVideoPicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/CameraVideoPicker.kt new file mode 100644 index 00000000000..e7faf9e83c4 --- /dev/null +++ b/multipicker/src/main/java/im/vector/lib/multipicker/CameraVideoPicker.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2021 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.lib.multipicker + +import android.content.Context +import android.content.Intent +import android.media.MediaMetadataRetriever +import android.net.Uri +import android.provider.MediaStore +import androidx.activity.result.ActivityResultLauncher +import androidx.core.content.FileProvider +import im.vector.lib.multipicker.entity.MultiPickerVideoType +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +/** + * Implementation of taking a video with Camera + */ +class CameraVideoPicker { + + /** + * Start camera by using a ActivityResultLauncher + * @return Uri of taken photo or null if the operation is cancelled. + */ + fun startWithExpectingFile(context: Context, activityResultLauncher: ActivityResultLauncher): Uri? { + val videoUri = createVideoUri(context) + val intent = createIntent().apply { + putExtra(MediaStore.EXTRA_OUTPUT, videoUri) + } + activityResultLauncher.launch(intent) + return videoUri + } + + /** + * Call this function from onActivityResult(int, int, Intent). + * @return Taken photo or null if request code is wrong + * or result code is not Activity.RESULT_OK + * or user cancelled the operation. + */ + fun getTakenVideo(context: Context, videoUri: Uri): MultiPickerVideoType? { + val projection = arrayOf( + MediaStore.Images.Media.DISPLAY_NAME, + MediaStore.Images.Media.SIZE, + MediaStore.Images.Media.MIME_TYPE + ) + + context.contentResolver.query( + videoUri, + projection, + null, + null, + null + )?.use { cursor -> + val nameColumn = cursor.getColumnIndex(MediaStore.Images.Media.DISPLAY_NAME) + val sizeColumn = cursor.getColumnIndex(MediaStore.Images.Media.SIZE) + + if (cursor.moveToNext()) { + val name = cursor.getString(nameColumn) + val size = cursor.getLong(sizeColumn) + var duration = 0L + var width = 0 + var height = 0 + var orientation = 0 + + context.contentResolver.openFileDescriptor(videoUri, "r")?.use { pfd -> + val mediaMetadataRetriever = MediaMetadataRetriever() + mediaMetadataRetriever.setDataSource(pfd.fileDescriptor) + duration = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION)?.toLong() ?: 0L + width = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_WIDTH)?.toInt() ?: 0 + height = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_HEIGHT)?.toInt() ?: 0 + orientation = mediaMetadataRetriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_VIDEO_ROTATION)?.toInt() ?: 0 + } + + return MultiPickerVideoType( + name, + size, + context.contentResolver.getType(videoUri), + videoUri, + width, + height, + orientation, + duration + ) + } + } + return null + } + + private fun createIntent(): Intent { + return Intent(MediaStore.ACTION_VIDEO_CAPTURE) + } + + companion object { + fun createVideoUri(context: Context): Uri { + val file = createVideoFile(context) + val authority = context.packageName + ".multipicker.fileprovider" + return FileProvider.getUriForFile(context, authority, file) + } + + private fun createVideoFile(context: Context): File { + val timeStamp: String = SimpleDateFormat("yyyyMMdd_HHmmss", Locale.getDefault()).format(Date()) + val storageDir: File = context.filesDir + return File.createTempFile( + "${timeStamp}_", /* prefix */ + ".mp4", /* suffix */ + storageDir /* directory */ + ) + } + } +} diff --git a/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt b/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt index f7ed4e5cd9c..6ce50f622af 100644 --- a/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt +++ b/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt @@ -26,6 +26,7 @@ class MultiPicker { val AUDIO by lazy { MultiPicker() } val CONTACT by lazy { MultiPicker() } val CAMERA by lazy { MultiPicker() } + val CAMERA_VIDEO by lazy { MultiPicker() } @Suppress("UNCHECKED_CAST") fun get(type: MultiPicker): T { @@ -37,6 +38,7 @@ class MultiPicker { AUDIO -> AudioPicker() as T CONTACT -> ContactPicker() as T CAMERA -> CameraPicker() as T + CAMERA_VIDEO -> CameraVideoPicker() as T else -> throw IllegalArgumentException("Unsupported type $type") } } diff --git a/vector/src/main/java/im/vector/app/core/dialogs/PhotoOrVideoDialog.kt b/vector/src/main/java/im/vector/app/core/dialogs/PhotoOrVideoDialog.kt new file mode 100644 index 00000000000..d9188397d81 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/dialogs/PhotoOrVideoDialog.kt @@ -0,0 +1,118 @@ +/* + * Copyright (c) 2021 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.app.core.dialogs + +import android.app.Activity +import androidx.appcompat.app.AlertDialog +import androidx.core.view.isVisible +import im.vector.app.R +import im.vector.app.databinding.DialogPhotoOrVideoBinding +import im.vector.app.features.settings.VectorPreferences + +class PhotoOrVideoDialog( + private val activity: Activity, + private val vectorPreferences: VectorPreferences +) { + + interface PhotoOrVideoDialogListener { + fun takePhoto() + fun takeVideo() + } + + interface PhotoOrVideoDialogSettingsListener { + fun onUpdated() + } + + fun show(listener: PhotoOrVideoDialogListener) { + when (vectorPreferences.getTakePhotoVideoMode()) { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> listener.takePhoto() + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> listener.takeVideo() + /* VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK */ + else -> { + val dialogLayout = activity.layoutInflater.inflate(R.layout.dialog_photo_or_video, null) + val views = DialogPhotoOrVideoBinding.bind(dialogLayout) + + // Show option to set as default in this case + views.dialogPhotoOrVideoAsDefault.isVisible = true + // Always default to photo + views.dialogPhotoOrVideoPhoto.isChecked = true + + AlertDialog.Builder(activity) + .setTitle(R.string.option_take_photo_video) + .setView(dialogLayout) + .setPositiveButton(R.string._continue) { _, _ -> + submit(views, vectorPreferences, listener) + } + .setNegativeButton(R.string.cancel, null) + .show() + } + } + } + + private fun submit(views: DialogPhotoOrVideoBinding, + vectorPreferences: VectorPreferences, + listener: PhotoOrVideoDialogListener) { + val mode = if (views.dialogPhotoOrVideoPhoto.isChecked) { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO + } else { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO + } + + if (views.dialogPhotoOrVideoAsDefault.isChecked) { + vectorPreferences.setTakePhotoVideoMode(mode) + } + + when (mode) { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> listener.takePhoto() + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> listener.takeVideo() + } + } + + fun showForSettings(listener: PhotoOrVideoDialogSettingsListener) { + val currentMode = vectorPreferences.getTakePhotoVideoMode() + + val dialogLayout = activity.layoutInflater.inflate(R.layout.dialog_photo_or_video, null) + val views = DialogPhotoOrVideoBinding.bind(dialogLayout) + + // Show option for always ask in this case + views.dialogPhotoOrVideoAlwaysAsk.isVisible = true + // Always default to photo + views.dialogPhotoOrVideoPhoto.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO + views.dialogPhotoOrVideoVideo.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO + views.dialogPhotoOrVideoAlwaysAsk.isChecked = currentMode == VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK + + AlertDialog.Builder(activity) + .setTitle(R.string.option_take_photo_video) + .setView(dialogLayout) + .setPositiveButton(R.string.save) { _, _ -> + submitSettings(views) + listener.onUpdated() + } + .setNegativeButton(R.string.cancel, null) + .show() + } + + private fun submitSettings(views: DialogPhotoOrVideoBinding) { + vectorPreferences.setTakePhotoVideoMode( + when { + views.dialogPhotoOrVideoPhoto.isChecked -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO + views.dialogPhotoOrVideoVideo.isChecked -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO + else -> VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK + } + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt index a9fcd353db8..28760bf52f3 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentsHelper.kt @@ -15,12 +15,15 @@ */ package im.vector.app.features.attachments +import android.app.Activity import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.activity.result.ActivityResultLauncher +import im.vector.app.core.dialogs.PhotoOrVideoDialog import im.vector.app.core.platform.Restorable +import im.vector.app.features.settings.VectorPreferences import im.vector.lib.multipicker.MultiPicker import org.matrix.android.sdk.BuildConfig import org.matrix.android.sdk.api.session.content.ContentAttachmentData @@ -91,10 +94,21 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab } /** - * Starts the process for handling capture image picking + * Starts the process for handling image/video capture. Can open a dialog */ - fun openCamera(context: Context, activityResultLauncher: ActivityResultLauncher) { - captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, activityResultLauncher) + fun openCamera(activity: Activity, + vectorPreferences: VectorPreferences, + cameraActivityResultLauncher: ActivityResultLauncher, + cameraVideoActivityResultLauncher: ActivityResultLauncher) { + PhotoOrVideoDialog(activity, vectorPreferences).show(object : PhotoOrVideoDialog.PhotoOrVideoDialogListener { + override fun takePhoto() { + captureUri = MultiPicker.get(MultiPicker.CAMERA).startWithExpectingFile(context, cameraActivityResultLauncher) + } + + override fun takeVideo() { + captureUri = MultiPicker.get(MultiPicker.CAMERA_VIDEO).startWithExpectingFile(context, cameraVideoActivityResultLauncher) + } + }) } /** @@ -141,7 +155,7 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab ) } - fun onPhotoResult() { + fun onCameraResult() { captureUri?.let { captureUri -> MultiPicker.get(MultiPicker.CAMERA) .getTakenPhoto(context, captureUri) @@ -153,6 +167,18 @@ class AttachmentsHelper(val context: Context, val callback: Callback) : Restorab } } + fun onCameraVideoResult() { + captureUri?.let { captureUri -> + MultiPicker.get(MultiPicker.CAMERA_VIDEO) + .getTakenVideo(context, captureUri) + ?.let { + callback.onContentAttachmentsReady( + listOf(it).map { it.toContentAttachmentData() } + ) + } + } + } + fun onVideoResult(data: Intent?) { callback.onContentAttachmentsReady( MultiPicker.get(MultiPicker.VIDEO) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt index f78dad4e166..cabd69ecf9c 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailFragment.kt @@ -994,9 +994,15 @@ class RoomDetailFragment @Inject constructor( } } - private val attachmentPhotoActivityResultLauncher = registerStartForActivityResult { + private val attachmentCameraActivityResultLauncher = registerStartForActivityResult { if (it.resultCode == Activity.RESULT_OK) { - attachmentsHelper.onPhotoResult() + attachmentsHelper.onCameraResult() + } + } + + private val attachmentCameraVideoActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + attachmentsHelper.onCameraVideoResult() } } @@ -1989,7 +1995,12 @@ class RoomDetailFragment @Inject constructor( private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { when (type) { - AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera(requireContext(), attachmentPhotoActivityResultLauncher) + AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( + activity = requireActivity(), + vectorPreferences = vectorPreferences, + cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, + cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher + ) AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) AttachmentTypeSelectorView.Type.AUDIO -> attachmentsHelper.selectAudio(attachmentAudioActivityResultLauncher) diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index 8c6edccda88..b44d44aba0c 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -193,6 +193,13 @@ class VectorPreferences @Inject constructor(private val context: Context) { private const val SETTINGS_UNKNOWN_DEVICE_DISMISSED_LIST = "SETTINGS_UNKNWON_DEVICE_DISMISSED_LIST" + private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE" + + // Possible values for TAKE_PHOTO_VIDEO_MODE + const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0 + const val TAKE_PHOTO_VIDEO_MODE_PHOTO = 1 + const val TAKE_PHOTO_VIDEO_MODE_VIDEO = 2 + // Background sync modes // some preferences keys must be kept after a logout @@ -948,4 +955,17 @@ class VectorPreferences @Inject constructor(private val context: Context) { fun labsUseExperimentalRestricted(): Boolean { return defaultPrefs.getBoolean(SETTINGS_LABS_USE_RESTRICTED_JOIN_RULE, false) } + + /* + * Photo / video picker + */ + fun getTakePhotoVideoMode(): Int { + return defaultPrefs.getInt(TAKE_PHOTO_VIDEO_MODE, TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK) + } + + fun setTakePhotoVideoMode(mode: Int) { + return defaultPrefs.edit { + putInt(TAKE_PHOTO_VIDEO_MODE, mode) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsPreferencesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsPreferencesFragment.kt index e895e54a20c..6f7e9c27ca9 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsPreferencesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsPreferencesFragment.kt @@ -23,6 +23,7 @@ import androidx.appcompat.app.AlertDialog import androidx.core.view.children import androidx.preference.Preference import im.vector.app.R +import im.vector.app.core.dialogs.PhotoOrVideoDialog import im.vector.app.core.extensions.restart import im.vector.app.core.preference.VectorListPreference import im.vector.app.core.preference.VectorPreference @@ -45,6 +46,9 @@ class VectorSettingsPreferencesFragment @Inject constructor( private val textSizePreference by lazy { findPreference(VectorPreferences.SETTINGS_INTERFACE_TEXT_SIZE_KEY)!! } + private val takePhotoOrVideoPreference by lazy { + findPreference("SETTINGS_INTERFACE_TAKE_PHOTO_VIDEO")!! + } override fun bindPref() { // user interface preferences @@ -123,6 +127,28 @@ class VectorSettingsPreferencesFragment @Inject constructor( false } } + + // Take photo or video + updateTakePhotoOrVideoPreferenceSummary() + takePhotoOrVideoPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + PhotoOrVideoDialog(requireActivity(), vectorPreferences).showForSettings(object: PhotoOrVideoDialog.PhotoOrVideoDialogSettingsListener { + override fun onUpdated() { + updateTakePhotoOrVideoPreferenceSummary() + } + }) + true + } + } + + private fun updateTakePhotoOrVideoPreferenceSummary() { + takePhotoOrVideoPreference.summary = getString( + when (vectorPreferences.getTakePhotoVideoMode()) { + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_PHOTO -> R.string.option_take_photo + VectorPreferences.TAKE_PHOTO_VIDEO_MODE_VIDEO -> R.string.option_take_video + /* VectorPreferences.TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK */ + else -> R.string.option_always_ask + } + ) } // ============================================================================================================== diff --git a/vector/src/main/res/layout/dialog_photo_or_video.xml b/vector/src/main/res/layout/dialog_photo_or_video.xml new file mode 100644 index 00000000000..bc6839738ec --- /dev/null +++ b/vector/src/main/res/layout/dialog_photo_or_video.xml @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 55a46a2d357..bfd91a6707a 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -575,6 +575,9 @@ Take photo or video Take photo Take video + Always ask + + Use as default and do not ask again You don’t currently have any stickerpacks enabled.\n\nAdd some now? diff --git a/vector/src/main/res/xml/vector_settings_preferences.xml b/vector/src/main/res/xml/vector_settings_preferences.xml index 1d39791ad82..1be192e0f5d 100644 --- a/vector/src/main/res/xml/vector_settings_preferences.xml +++ b/vector/src/main/res/xml/vector_settings_preferences.xml @@ -1,6 +1,7 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + @@ -185,10 +192,10 @@ - + \ No newline at end of file