diff --git a/changelog.d/6763.feature b/changelog.d/6763.feature new file mode 100644 index 00000000000..826c8f516c1 --- /dev/null +++ b/changelog.d/6763.feature @@ -0,0 +1 @@ +Add an in-app camera diff --git a/dependencies.gradle b/dependencies.gradle index 7a9ed3f9315..63a7141aec5 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -31,6 +31,7 @@ def jjwt = "0.11.5" def vanniktechEmoji = "0.15.0" def fragment = "1.5.2" +def cameraxVersion = "1.1.0" // Testing def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819 @@ -53,6 +54,14 @@ ext.libs = [ 'activity' : "androidx.activity:activity:1.5.1", 'appCompat' : "androidx.appcompat:appcompat:1.4.2", 'biometric' : "androidx.biometric:biometric:1.1.0", + 'camera': [ + 'core' : "androidx.camera:camera-core:$cameraxVersion", + 'camera2' : "androidx.camera:camera-camera2:$cameraxVersion", + 'lifecycle' : "androidx.camera:camera-lifecycle:$cameraxVersion", + 'video' : "androidx.camera:camera-video:$cameraxVersion", + 'view' : "androidx.camera:camera-view:$cameraxVersion", + 'extensions' : "androidx.camera:camera-extensions:$cameraxVersion", + ], 'core' : "androidx.core:core-ktx:1.8.0", 'recyclerview' : "androidx.recyclerview:recyclerview:1.2.1", 'exifinterface' : "androidx.exifinterface:exifinterface:1.3.3", diff --git a/dependencies_groups.gradle b/dependencies_groups.gradle index bcd737acc97..c5d22c24c95 100644 --- a/dependencies_groups.gradle +++ b/dependencies_groups.gradle @@ -40,8 +40,10 @@ ext.groups = [ ], mavenCentral: [ regex: [ + 'com\\.google\\.auto\\..*', ], group: [ + 'com.google.auto', 'ch.qos.logback', 'com.adevinta.android', 'com.airbnb.android', diff --git a/library/multipicker/src/main/java/im/vector/lib/multipicker/BuiltInCameraPicker.kt b/library/multipicker/src/main/java/im/vector/lib/multipicker/BuiltInCameraPicker.kt new file mode 100644 index 00000000000..5b77781c570 --- /dev/null +++ b/library/multipicker/src/main/java/im/vector/lib/multipicker/BuiltInCameraPicker.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2022 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.net.Uri +import androidx.activity.result.ActivityResultLauncher +import im.vector.lib.multipicker.entity.MultiPickerImageType +import im.vector.lib.multipicker.entity.MultiPickerVideoType +import im.vector.lib.multipicker.utils.toMultiPickerImageType +import im.vector.lib.multipicker.utils.toMultiPickerVideoType + +class BuiltInCameraPicker { + + /** + * Start camera by using a ActivityResultLauncher. + * @return Uri of taken photo or null if the operation is cancelled. + */ + fun start(context: Context, activityResultLauncher: ActivityResultLauncher, targetClass: Class<*>) { + val intent = Intent(context, targetClass) + activityResultLauncher.launch(intent) + } + + /** + * 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 getTakenPhoto(context: Context, photoUri: Uri): MultiPickerImageType? { + return photoUri.toMultiPickerImageType(context) + } + + /** + * Call this function from onActivityResult(int, int, Intent). + * @return Taken video 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? { + return videoUri.toMultiPickerVideoType(context) + } +} diff --git a/library/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt b/library/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt index 93773458869..6a5cc7775af 100644 --- a/library/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt +++ b/library/multipicker/src/main/java/im/vector/lib/multipicker/MultiPicker.kt @@ -27,6 +27,7 @@ class MultiPicker private constructor() { val CONTACT by lazy { MultiPicker() } val CAMERA by lazy { MultiPicker() } val CAMERA_VIDEO by lazy { MultiPicker() } + val BUILTIN_CAMERA by lazy { MultiPicker() } @Suppress("UNCHECKED_CAST") fun get(type: MultiPicker): T { @@ -39,6 +40,7 @@ class MultiPicker private constructor() { CONTACT -> ContactPicker() as T CAMERA -> CameraPicker() as T CAMERA_VIDEO -> CameraVideoPicker() as T + BUILTIN_CAMERA -> BuiltInCameraPicker() as T else -> throw IllegalArgumentException("Unsupported type $type") } } diff --git a/library/ui-styles/src/main/res/values/styles_text_view.xml b/library/ui-styles/src/main/res/values/styles_text_view.xml index 0dcaf30f483..0d51e218bda 100644 --- a/library/ui-styles/src/main/res/values/styles_text_view.xml +++ b/library/ui-styles/src/main/res/values/styles_text_view.xml @@ -53,4 +53,14 @@ 16sp + + + + diff --git a/vector/build.gradle b/vector/build.gradle index 83d322946ba..f8246255efc 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -394,6 +394,15 @@ dependencies { implementation "com.gabrielittner.threetenbp:lazythreetenbp:0.11.0" implementation libs.squareup.moshi + + // Camera + implementation libs.androidx.camera.core + implementation libs.androidx.camera.camera2 + implementation libs.androidx.camera.lifecycle + implementation libs.androidx.camera.video + implementation libs.androidx.camera.view + implementation libs.androidx.camera.extensions + kapt libs.squareup.moshiKotlin // Lifecycle diff --git a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt index 94340060605..8321a56abf1 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt @@ -97,6 +97,7 @@ class UiAllScreensSanityTest { } } + testCamera() testThreadScreens() val spaceName = UUID.randomUUID().toString() @@ -174,4 +175,18 @@ class UiAllScreensSanityTest { } elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES) } + + private fun testCamera() { + elementRobot.toggleLabFeature(LabFeature.BUILTIN_CAMERA) + elementRobot.newRoom { + createNewRoom { + createRoom { + cameraPhotoBack() + cameraPhotoFront() + cameraVideoFront() + } + } + } + elementRobot.toggleLabFeature(LabFeature.BUILTIN_CAMERA) + } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt index b6fbfc23ab6..8243cf503fe 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/ElementRobot.kt @@ -140,6 +140,15 @@ class ElementRobot { // I hereby cheat and write: Thread.sleep(30_000) } + LabFeature.BUILTIN_CAMERA -> { + settings { + labs { + onView(withText(R.string.labs_enable_builtin_camera)) + .check(ViewAssertions.matches(isDisplayed())) + .perform(ViewActions.closeSoftKeyboard(), click()) + } + } + } else -> { } } diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt index 64360805993..68b858ac02f 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/RoomDetailRobot.kt @@ -38,6 +38,7 @@ import im.vector.app.features.home.room.detail.timeline.action.MessageActionsBot import im.vector.app.features.home.room.detail.timeline.reactions.ViewReactionsBottomSheet import im.vector.app.features.reactions.data.EmojiDataSource import im.vector.app.interactWithSheet +import im.vector.app.ui.robot.interaction.ClickInteractions.veryLongClickOn import im.vector.app.withRetry import java.lang.Thread.sleep @@ -64,6 +65,40 @@ class RoomDetailRobot { pressBack() } + fun cameraPhotoBack() { + clickOn(R.id.attachmentButton) + clickOn(R.id.attachmentCameraButton) + clickOn(R.id.attachmentsCameraFlash) + clickOn(R.id.attachmentsCameraFlash) + clickOn(R.id.attachmentsCameraCaptureAction) + waitAttachmentPreviewer() + clickOn(R.id.attachmentPreviewerSendButton) + } + + fun cameraPhotoFront() { + clickOn(R.id.attachmentButton) + clickOn(R.id.attachmentCameraButton) + clickOn(R.id.attachmentsCameraFlip) + clickOn(R.id.attachmentsCameraCaptureAction) + waitAttachmentPreviewer() + clickOn(R.id.attachmentPreviewerSendButton) + } + + fun cameraVideoFront() { + clickOn(R.id.attachmentButton) + clickOn(R.id.attachmentCameraButton) + clickOn(R.id.attachmentsCameraFlip) + veryLongClickOn(R.id.attachmentsCameraCaptureAction) + waitAttachmentPreviewer() + clickOn(R.id.attachmentPreviewerSendButton) + } + + private fun waitAttachmentPreviewer() { + // haven't find a way to use waitUntilViewVisible + // for the attachmentPreviewer View + sleep(4_000) + } + fun replyToThread(message: String) { openMessageMenu(message) { replyInThread() diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/ClickInteractions.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/ClickInteractions.kt new file mode 100644 index 00000000000..bdb53521b4d --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/ClickInteractions.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 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.ui.robot.interaction + +import com.adevinta.android.barista.internal.performAction +import com.adevinta.android.barista.internal.util.resourceMatcher +import im.vector.app.ui.robot.interaction.ViewActions.veryLongClick + +object ClickInteractions { + @JvmStatic + fun veryLongClickOn(resId: Int) { + resId.resourceMatcher().performAction(veryLongClick()) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/VeryLongClick.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/VeryLongClick.kt new file mode 100644 index 00000000000..3b34a56a752 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/VeryLongClick.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 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.ui.robot.interaction + +import android.view.ViewConfiguration +import androidx.test.espresso.UiController +import androidx.test.espresso.action.MotionEvents +import androidx.test.espresso.action.Tapper + +object VeryLongClick : Tapper { + override fun sendTap(uiController: UiController?, + coordinates: FloatArray?, + precision: FloatArray?, + inputDevice: Int, + buttonState: Int): Tapper.Status { + checkNotNull(uiController) + checkNotNull(coordinates) + checkNotNull(precision) + + val downEvent = MotionEvents.sendDown(uiController, coordinates, precision, inputDevice, buttonState).down + try { + // Duration before a press turns into a long press. + // Factor 1.5 is needed, otherwise a long press is not safely detected. + // See android.test.TouchUtils longClickView + // Factor 10 is needed to simulate user still pressing after the longClick is detected + val longPressTimeout = (ViewConfiguration.getLongPressTimeout() * 10f).toLong() + uiController.loopMainThreadForAtLeast(longPressTimeout) + if (!MotionEvents.sendUp(uiController, downEvent)) { + MotionEvents.sendCancel(uiController, downEvent) + return Tapper.Status.FAILURE + } + } finally { + downEvent!!.recycle() + } + return Tapper.Status.SUCCESS + } + + override fun sendTap(uiController: UiController?, coordinates: FloatArray?, precision: FloatArray?): Tapper.Status { + return sendTap(uiController, coordinates, precision, 0, 0) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/ViewActions.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/ViewActions.kt new file mode 100644 index 00000000000..e393da61fd0 --- /dev/null +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/interaction/ViewActions.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 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.ui.robot.interaction + +import android.view.InputDevice +import android.view.MotionEvent +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.GeneralLocation +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.ViewActions + +object ViewActions { + fun veryLongClick(): ViewAction { + return ViewActions.actionWithAssertions( + GeneralClickAction( + VeryLongClick, + GeneralLocation.CENTER, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.ACTION_DOWN + ) + ) + } +} diff --git a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt index 656201d8128..97e266ef194 100644 --- a/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt +++ b/vector/src/androidTest/java/im/vector/app/ui/robot/settings/labs/LabFeature.kt @@ -22,5 +22,6 @@ enum class LabFeature { LATEX_MATHEMATICS, THREAD_MESSAGES, AUTO_REPORT_ERRORS, + BUILTIN_CAMERA, RENDER_USER_LOCATION } diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index ea62aa1b58c..d1d29be8d48 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -290,6 +290,8 @@ + + @Binds + @IntoMap + @MavericksViewModelKey(AttachmentsCameraModel::class) + fun vectorAttachmentsCameraModelFactory(factory: AttachmentsCameraModel.Factory): MavericksAssistedViewModelFactory<*, *> + @Binds @IntoMap @MavericksViewModelKey(LiveLocationMapViewModel::class) 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 f8aa22f4181..feb15cc6d53 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 @@ -24,6 +24,9 @@ import androidx.activity.result.ActivityResultLauncher import im.vector.app.core.dialogs.PhotoOrVideoDialog import im.vector.app.core.platform.Restorable import im.vector.app.core.resources.BuildMeta +import im.vector.app.features.attachments.camera.AttachmentsCameraActivity +import im.vector.app.features.attachments.camera.AttachmentsCameraOutput +import im.vector.app.features.attachments.camera.MediaType import im.vector.app.features.settings.VectorPreferences import im.vector.lib.multipicker.MultiPicker import org.matrix.android.sdk.api.session.content.ContentAttachmentData @@ -111,6 +114,14 @@ class AttachmentsHelper( }) } + fun openBuiltInCamera(attachmentsCameraActivityResultLauncher: ActivityResultLauncher) { + MultiPicker.get(MultiPicker.BUILTIN_CAMERA).start( + context, + attachmentsCameraActivityResultLauncher, + AttachmentsCameraActivity::class.java + ) + } + /** * Starts the process for handling contact picking. */ @@ -170,6 +181,18 @@ class AttachmentsHelper( } } + fun onAttachmentsCameraResult(cameraOutput: AttachmentsCameraOutput) { + val multiPicker = MultiPicker.get(MultiPicker.BUILTIN_CAMERA) + val media = if (cameraOutput.type == MediaType.IMAGE) { + multiPicker.getTakenPhoto(context, cameraOutput.uri) + } else { + multiPicker.getTakenVideo(context, cameraOutput.uri) + } ?: return + callback.onContentAttachmentsReady( + listOf(media).map { it.toContentAttachmentData() } + ) + } + fun onCameraVideoResult() { captureUri?.let { captureUri -> MultiPicker.get(MultiPicker.CAMERA_VIDEO) diff --git a/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraAction.kt b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraAction.kt new file mode 100644 index 00000000000..008c0c4a8a5 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraAction.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 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.features.attachments.camera + +import android.content.Context +import androidx.camera.core.ImageCapture +import androidx.camera.video.Recorder +import androidx.camera.video.VideoCapture +import im.vector.app.core.platform.VectorViewModelAction + +sealed class AttachmentsCameraAction : VectorViewModelAction { + object ChangeLensFacing : AttachmentsCameraAction() + object RotateFlashMode : AttachmentsCameraAction() + data class SetRotation(val rotation: Int) : AttachmentsCameraAction() + data class Capture( + val context: Context, + val imageCapture: ImageCapture? = null, + val videoCapture: VideoCapture? = null + ) : AttachmentsCameraAction() +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraActivity.kt b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraActivity.kt new file mode 100644 index 00000000000..3266d309552 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraActivity.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 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.features.attachments.camera + +import android.content.Intent +import android.provider.MediaStore +import androidx.core.view.isVisible +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.core.extensions.addFragment +import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.databinding.ActivitySimpleBinding +import im.vector.app.features.themes.ActivityOtherThemes + +@AndroidEntryPoint +class AttachmentsCameraActivity : VectorBaseActivity() { + + override fun getOtherThemes() = ActivityOtherThemes.AttachmentsPreview + + override fun getBinding() = ActivitySimpleBinding.inflate(layoutInflater) + + override fun getCoordinatorLayout() = views.coordinatorLayout + + override fun initUiAndData() { + if (isFirstCreation()) { + addFragment(views.simpleFragmentContainer, AttachmentsCameraFragment::class.java) + } + } + + fun setResultAndFinish(data: AttachmentsCameraOutput) { + val resultIntent = Intent().apply { + putExtra(MediaStore.EXTRA_OUTPUT, data) + } + setResult(RESULT_OK, resultIntent) + finish() + } + + fun setErrorAndFinish() { + val resultIntent = Intent() + setResult(RESULT_CANCELED, resultIntent) + finish() + } + + fun showWaitingView() { + views.simpleActivityWaitingView.isVisible = true + } +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraFragment.kt b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraFragment.kt new file mode 100644 index 00000000000..47ce9589a47 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraFragment.kt @@ -0,0 +1,337 @@ +/* + * Copyright (c) 2022 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.features.attachments.camera + +import android.Manifest +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.os.SystemClock +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.MotionEvent +import android.view.OrientationEventListener +import android.view.ScaleGestureDetector +import android.view.Surface +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.VideoCapture +import androidx.core.content.ContextCompat +import androidx.core.view.isVisible +import com.airbnb.mvrx.activityViewModel +import com.airbnb.mvrx.withState +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.platform.VectorBaseFragment +import im.vector.app.core.platform.VectorMenuProvider +import im.vector.app.core.time.Clock +import im.vector.app.databinding.FragmentAttachmentsCameraBinding +import timber.log.Timber +import javax.inject.Inject + +@AndroidEntryPoint +class AttachmentsCameraFragment : + VectorBaseFragment(), + VectorMenuProvider { + + @Inject lateinit var clock: Clock + private val viewModel: AttachmentsCameraModel by activityViewModel() + + private var imageCapture: ImageCapture? = null + private var videoCapture: VideoCapture? = null + private var preview: Preview? = null + private var currentLens: Int? = null + + private lateinit var camera: Camera + + private val gestureListener = object : ScaleGestureDetector.SimpleOnScaleGestureListener() { + override fun onScale(detector: ScaleGestureDetector): Boolean { + val scale = camera.cameraInfo.zoomState.value!!.zoomRatio * detector.scaleFactor + camera.cameraControl.setZoomRatio(scale) + return true + } + } + + private val requestPermissionLauncher = registerForActivityResult( + ActivityResultContracts.RequestMultiplePermissions() + ) { + if (allPermissionsGranted()) { + startCamera() + } else { + Toast.makeText(context, + "Permissions not granted by the user.", + Toast.LENGTH_SHORT).show() + activity?.finish() + } + } + + private val orientationEventListener by lazy { + object : OrientationEventListener(context) { + override fun onOrientationChanged(orientation: Int) { + withState(viewModel) { state -> + val rotation = when (orientation) { + in 45 until 135 -> Surface.ROTATION_270 + in 135 until 225 -> Surface.ROTATION_180 + in 225 until 315 -> Surface.ROTATION_90 + else -> Surface.ROTATION_0 + } + if (rotation != state.rotation) { + viewModel.handle(AttachmentsCameraAction.SetRotation(rotation)) + } + } + } + } + } + + @SuppressLint("ClickableViewAccessibility") + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + viewModel.observeViewEvents { + when (it) { + AttachmentsCameraViewEvents.SetErrorAndFinish -> { + views.attachmentsCameraChronometer.stop() + (activity as AttachmentsCameraActivity).setErrorAndFinish() + } + is AttachmentsCameraViewEvents.SetResultAndFinish -> { + views.attachmentsCameraChronometer.stop() + (activity as AttachmentsCameraActivity).setResultAndFinish(it.attachmentsCameraOutput) + } + } + } + + // Request camera permissions + if (allPermissionsGranted()) { + startCamera() + } else { + requestPermissionLauncher.launch(REQUIRED_PERMISSIONS) + } + + views.attachmentsCameraCaptureAction.setOnTouchListener { _, motionEvent -> + views.attachmentCameraActionDescription.visibility = View.INVISIBLE + if (motionEvent.action == MotionEvent.ACTION_UP) { + context?.let { + viewModel.handle(AttachmentsCameraAction.Capture(it, imageCapture)) + } + } + false + } + + views.attachmentsCameraCaptureAction.setOnLongClickListener { + context?.let { + viewModel.handle(AttachmentsCameraAction.Capture(it, videoCapture = videoCapture)) + } + true + } + + views.attachmentsCameraFlip.debouncedClicks { + viewModel.handle(AttachmentsCameraAction.ChangeLensFacing) + } + + views.attachmentsCameraFlash.debouncedClicks { + viewModel.handle(AttachmentsCameraAction.RotateFlashMode) + } + + val scaleGestureDetector = ScaleGestureDetector(context, gestureListener) + + views.root.setOnTouchListener { _, event -> + scaleGestureDetector.onTouchEvent(event) + return@setOnTouchListener true + } + } + + override fun invalidate() { + // Request camera permissions + if (allPermissionsGranted()) { + startCamera() + } + setFlashButton() + setRotation() + showCapturing() + } + + override fun onStart() { + super.onStart() + orientationEventListener.enable() + } + + override fun onStop() { + super.onStop() + orientationEventListener.disable() + } + + private fun allPermissionsGranted() = REQUIRED_PERMISSIONS.all { permission -> + context?.let { context -> + ContextCompat.checkSelfPermission(context, permission) + } == PackageManager.PERMISSION_GRANTED + } + + private fun showCapturing() = withState(viewModel) { state -> + if (state.done) { + waitForPhoto() + } else if (state.recording) { + recording() + } + } + + private fun waitForPhoto() { + (activity as AttachmentsCameraActivity?)?.showWaitingView() + context?.let { context -> + ProcessCameraProvider.getInstance(context).get().unbind(preview) + } + } + + @SuppressLint("UseCompatLoadingForDrawables") + private fun recording() { + views.attachmentsCameraCaptureAction.setImageDrawable( + context?.getDrawable(R.drawable.ic_material_stop) + ) + views.attachmentsCameraFlip.isEnabled = false + views.attachmentsCameraFlash.isEnabled = false + views.attachmentsCameraChronometer.isVisible = true + views.attachmentsCameraChronometer.base = SystemClock.elapsedRealtime() + views.attachmentsCameraChronometer.start() + } + + @SuppressLint("RestrictedApi") + private fun setRotation() { + withState(viewModel) { state -> + arrayOf( + views.attachmentsCameraFlip, + views.attachmentsCameraFlash, + views.attachmentsCameraCaptureAction, + ).forEach { + it.rotation = when (state.rotation) { + Surface.ROTATION_270 -> 270F + Surface.ROTATION_180 -> 180F + Surface.ROTATION_90 -> 90F + else -> 0F + } + } + } + } + + @SuppressLint("UseCompatLoadingForDrawables") + private fun setFlashButton() { + withState(viewModel) { state -> + if (state.cameraSelector != CameraSelector.DEFAULT_BACK_CAMERA) { + views.attachmentsCameraFlash.isVisible = false + } else { + views.attachmentsCameraFlash.apply { + isVisible = true + setImageDrawable( + context?.getDrawable( + when (state.flashMode) { + ImageCapture.FLASH_MODE_AUTO -> R.drawable.ic_flash_auto + ImageCapture.FLASH_MODE_OFF -> R.drawable.ic_flash_off + ImageCapture.FLASH_MODE_ON -> R.drawable.ic_flash_on + else -> R.drawable.ic_flash_auto + } + ) + ) + contentDescription = context?.getString( + when (state.flashMode) { + ImageCapture.FLASH_MODE_AUTO -> R.string.attachment_camera_disable_flash + ImageCapture.FLASH_MODE_OFF -> R.string.attachment_camera_enable_flash + ImageCapture.FLASH_MODE_ON -> R.string.attachment_camera_auto_flash + else -> R.string.attachment_camera_disable_flash + } + ) + } + } + } + } + + @SuppressLint("RestrictedApi") + private fun startCamera() { + Timber.d("Starting Camera") + val context = this.context ?: return + withState(viewModel) { state -> + if (currentLens == state.cameraSelector.lensFacing) return@withState + currentLens = state.cameraSelector.lensFacing + + val cameraProviderFuture = ProcessCameraProvider.getInstance(context) + + cameraProviderFuture.addListener({ + // Used to bind the lifecycle of cameras to the lifecycle owner + val cameraProvider: ProcessCameraProvider = cameraProviderFuture.get() + + // Preview + preview = Preview.Builder() + .build() + .also { + it.setSurfaceProvider(views.viewFinder.surfaceProvider) + } + + imageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .build() + + val recorder = Recorder.Builder() + .setQualitySelector(QualitySelector.from(Quality.HIGHEST)) + .build() + videoCapture = VideoCapture.withOutput(recorder) + + try { + // Unbind use cases before rebinding + cameraProvider.unbindAll() + + // Bind use cases to camera + camera = cameraProvider.bindToLifecycle( + this, state.cameraSelector, preview, imageCapture, videoCapture + ) + + Timber.d("Lensfacing: ${camera.cameraInfo.cameraSelector}") + } catch (exc: Exception) { + Timber.e("Use case binding failed", exc) + } + }, ContextCompat.getMainExecutor(context)) + } + } + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentAttachmentsCameraBinding { + return FragmentAttachmentsCameraBinding.inflate(inflater, container, false) + } + + override fun getMenuRes() = R.menu.vector_attachments_camera + + override fun handleMenuItemSelected(item: MenuItem): Boolean { + return true + } + + companion object { + private val REQUIRED_PERMISSIONS = + mutableListOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + ).apply { + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) { + add(Manifest.permission.WRITE_EXTERNAL_STORAGE) + } + }.toTypedArray() + } +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraModel.kt b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraModel.kt new file mode 100644 index 00000000000..f71bff82f5e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraModel.kt @@ -0,0 +1,274 @@ +/* + * Copyright (c) 2022 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.features.attachments.camera + +import android.Manifest +import android.annotation.SuppressLint +import android.content.Context +import android.graphics.Bitmap +import android.graphics.Bitmap.CompressFormat +import android.graphics.BitmapFactory +import android.graphics.Matrix +import android.net.Uri +import android.view.Surface +import android.widget.Toast +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.ImageProxy +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import androidx.core.content.PermissionChecker +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.time.Clock +import timber.log.Timber +import java.io.ByteArrayOutputStream +import java.io.File +import java.io.FileOutputStream +import java.nio.ByteBuffer + +class AttachmentsCameraModel @AssistedInject constructor( + @Assisted val initialState: AttachmentsCameraState, + val clock: Clock +) : VectorViewModel(initialState) { + + private var recording: Recording? = null + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: AttachmentsCameraState): AttachmentsCameraModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + override fun handle(action: AttachmentsCameraAction) { + when (action) { + AttachmentsCameraAction.ChangeLensFacing -> changeLensFacing() + AttachmentsCameraAction.RotateFlashMode -> rotateFlashMode() + is AttachmentsCameraAction.SetRotation -> setRotation(action.rotation) + is AttachmentsCameraAction.Capture -> capture(action) + } + } + + private fun changeLensFacing() { + setState { + copy( + cameraSelector = if (cameraSelector == CameraSelector.DEFAULT_BACK_CAMERA) { + CameraSelector.DEFAULT_FRONT_CAMERA + } else { + CameraSelector.DEFAULT_BACK_CAMERA + } + ) + } + } + + private fun rotateFlashMode() { + setState { + copy( + flashMode = when (flashMode) { + ImageCapture.FLASH_MODE_AUTO -> ImageCapture.FLASH_MODE_OFF + ImageCapture.FLASH_MODE_OFF -> ImageCapture.FLASH_MODE_ON + ImageCapture.FLASH_MODE_ON -> ImageCapture.FLASH_MODE_AUTO + else -> ImageCapture.FLASH_MODE_OFF + } + ) + } + } + + private fun setRotation(newRotation: Int) { + setState { + copy( + rotation = newRotation + ) + } + } + + private fun capture(action: AttachmentsCameraAction.Capture) { + recording?.let { + recording?.stop() + recording = null + return + } + action.videoCapture?.let { + capture(action.context, action.videoCapture) + return + } + action.imageCapture?.let { + capture(action.context, action.imageCapture) + return + } + _viewEvents.post(AttachmentsCameraViewEvents.SetErrorAndFinish) + } + + private fun capture(context: Context, imageCapture: ImageCapture) { + withState { state -> + imageCapture.flashMode = state.flashMode + + val file = createTempFile(context, MediaType.IMAGE) + val outputUri = getUri(context, file) + + imageCapture.takePicture( + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageCapturedCallback() { + @SuppressLint("RestrictedApi") + override fun onCaptureSuccess(image: ImageProxy) { + setState { copy( done = true ) } + val orientation = ( + (imageCapture.camera?.cameraInfo?.sensorRotationDegrees?.toFloat() ?: 0F) - when (state.rotation) { + Surface.ROTATION_270 -> 270F + Surface.ROTATION_180 -> 180F + Surface.ROTATION_90 -> 90F + else -> 0F + }) + saveImageProxyToFile(image, file, orientation)?.let { + _viewEvents.post( + AttachmentsCameraViewEvents.SetResultAndFinish( + AttachmentsCameraOutput( + type = MediaType.IMAGE, + uri = outputUri + ) + ) + ) + } ?: _viewEvents.post(AttachmentsCameraViewEvents.SetErrorAndFinish) + } + + override fun onError(exception: ImageCaptureException) { + Timber.e("Photo capture failed: ${exception.message}", exception) + Toast.makeText(context, "An error occurred", Toast.LENGTH_SHORT).show() + _viewEvents.post(AttachmentsCameraViewEvents.SetErrorAndFinish) + } + } + ) + } + } + + private fun saveImageProxyToFile(image: ImageProxy, file: File, orientation: Float): Boolean? { + val buffer: ByteBuffer = image.planes[0].buffer + val bytes = ByteArray(buffer.capacity()) + buffer.get(bytes) + return BitmapFactory.decodeByteArray(bytes, 0, bytes.size, null)?.let { bitmap -> + val bos = ByteArrayOutputStream() + val matrix = Matrix().apply { + postRotate(orientation) + } + val rotatedBitmap = Bitmap.createBitmap(bitmap, 0,0 , bitmap.width, bitmap.height, matrix, true) + rotatedBitmap.compress(CompressFormat.JPEG, 90, bos) + val fd = FileOutputStream(file) + fd.write(bos.toByteArray()) + fd.flush() + fd.close() + true + } + } + + @SuppressLint("UseCompatLoadingForDrawables", "RestrictedApi") + private fun capture(context: Context, videoCapture: VideoCapture) = withState { state -> + Timber.d("Capturing Video") + + videoCapture.camera?.cameraControl?.enableTorch(state.flashMode == ImageCapture.FLASH_MODE_ON) + videoCapture.targetRotation = state.rotation + + val file = createTempFile(context, MediaType.VIDEO) + val outputUri = getUri(context, file) + + val options = FileOutputOptions + .Builder(file) + .build() + + recording = videoCapture.output + .prepareRecording(context, options) + .apply { + if (PermissionChecker.checkSelfPermission( + context, + Manifest.permission.RECORD_AUDIO + ) == PermissionChecker.PERMISSION_GRANTED) { + withAudioEnabled() + } + } + .start(ContextCompat.getMainExecutor(context)) { recordEvent -> + when (recordEvent) { + is VideoRecordEvent.Start -> { + setState { copy( recording = true ) } + } + is VideoRecordEvent.Finalize -> { + setState { copy( done = true ) } + if (!recordEvent.hasError()) { + Timber.d( + "Video capture succeeded: " + + "${recordEvent.outputResults.outputUri}" + ) + _viewEvents.post( + AttachmentsCameraViewEvents.SetResultAndFinish( + AttachmentsCameraOutput( + type = MediaType.VIDEO, + uri = outputUri + ) + ) + ) + } else { + recording?.close() + recording = null + Timber.e( + "Video capture ends with error: " + + "${recordEvent.error}" + ) + _viewEvents.post(AttachmentsCameraViewEvents.SetErrorAndFinish) + } + } + } + } + } + + private fun createTempFile(context: Context, type: MediaType): File { + var prefix = "" + var suffix = "" + when (type) { + MediaType.IMAGE -> { + prefix = "IMG_" + suffix = ".jpg" + } + MediaType.VIDEO -> { + prefix = "VID_" + suffix = ".mp4" + } + } + return File.createTempFile( + "$prefix${clock.epochMillis()}", + suffix, + context.cacheDir.also { it?.mkdirs() }!! + ) + } + + private fun getUri(context: Context, file: File): Uri { + return FileProvider.getUriForFile( + context, + context.packageName + ".fileProvider", + file + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraOutput.kt b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraOutput.kt new file mode 100644 index 00000000000..eacacab108c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraOutput.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 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.features.attachments.camera + +import android.net.Uri +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +enum class MediaType { + IMAGE, + VIDEO +} + +@Parcelize +data class AttachmentsCameraOutput( + val type: MediaType, + val uri: Uri, +) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraState.kt b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraState.kt new file mode 100644 index 00000000000..7e159fcd078 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraState.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 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.features.attachments.camera + +import android.view.Surface +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import com.airbnb.mvrx.MavericksState + +data class AttachmentsCameraState( + val cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA, + val flashMode: Int = ImageCapture.FLASH_MODE_AUTO, + val rotation: Int = Surface.ROTATION_0, + val recording: Boolean = false, + val done: Boolean = false, +) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraViewEvents.kt b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraViewEvents.kt new file mode 100644 index 00000000000..7c0866ae085 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/attachments/camera/AttachmentsCameraViewEvents.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 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.features.attachments.camera + +import im.vector.app.core.platform.VectorViewEvents + +sealed class AttachmentsCameraViewEvents : VectorViewEvents { + object SetErrorAndFinish : AttachmentsCameraViewEvents() + data class SetResultAndFinish(val attachmentsCameraOutput: AttachmentsCameraOutput) : AttachmentsCameraViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index b5eb0608d49..e0017ea04a8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -23,6 +23,7 @@ import android.content.res.Configuration import android.net.Uri import android.os.Build import android.os.Bundle +import android.provider.MediaStore import android.text.Spannable import android.text.format.DateUtils import android.text.method.LinkMovementMethod @@ -135,6 +136,7 @@ import im.vector.app.features.attachments.AttachmentTypeSelectorView import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.ContactAttachment import im.vector.app.features.attachments.ShareIntentHandler +import im.vector.app.features.attachments.camera.AttachmentsCameraOutput import im.vector.app.features.attachments.preview.AttachmentsPreviewActivity import im.vector.app.features.attachments.preview.AttachmentsPreviewArgs import im.vector.app.features.attachments.toGroupedContentAttachmentData @@ -1381,6 +1383,14 @@ class TimelineFragment : } } + private val attachmentBuiltInCameraActivityResultLauncher = registerStartForActivityResult { + if (it.resultCode == Activity.RESULT_OK) { + it.data?.getParcelableExtra(MediaStore.EXTRA_OUTPUT)?.let { cameraOutput -> + attachmentsHelper.onAttachmentsCameraResult(cameraOutput) + } + } + } + private val contentAttachmentActivityResultLauncher = registerStartForActivityResult { activityResult -> val data = activityResult.data ?: return@registerStartForActivityResult if (activityResult.resultCode == Activity.RESULT_OK) { @@ -2616,12 +2626,18 @@ class TimelineFragment : private fun launchAttachmentProcess(type: AttachmentTypeSelectorView.Type) { when (type) { - AttachmentTypeSelectorView.Type.CAMERA -> attachmentsHelper.openCamera( - activity = requireActivity(), - vectorPreferences = vectorPreferences, - cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, - cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher - ) + AttachmentTypeSelectorView.Type.CAMERA -> { + if (vectorPreferences.builtinCameraIsEnabled()) { + attachmentsHelper.openBuiltInCamera(attachmentBuiltInCameraActivityResultLauncher) + } else { + attachmentsHelper.openCamera( + activity = requireActivity(), + vectorPreferences = vectorPreferences, + cameraActivityResultLauncher = attachmentCameraActivityResultLauncher, + cameraVideoActivityResultLauncher = attachmentCameraVideoActivityResultLauncher + ) + } + } AttachmentTypeSelectorView.Type.FILE -> attachmentsHelper.selectFile(attachmentFileActivityResultLauncher) AttachmentTypeSelectorView.Type.GALLERY -> attachmentsHelper.selectGallery(attachmentMediaActivityResultLauncher) AttachmentTypeSelectorView.Type.CONTACT -> attachmentsHelper.selectContact(attachmentContactActivityResultLauncher) 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 cefbe64d9df..032cfa546a5 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 @@ -163,6 +163,7 @@ class VectorPreferences @Inject constructor( const val SETTINGS_LABS_ALLOW_EXTENDED_LOGS = "SETTINGS_LABS_ALLOW_EXTENDED_LOGS" const val SETTINGS_LABS_AUTO_REPORT_UISI = "SETTINGS_LABS_AUTO_REPORT_UISI" + const val SETTINGS_LABS_ENABLE_BUILTIN_CAMERA = "SETTINGS_LABS_ENABLE_BUILTIN_CAMERA" const val SETTINGS_PREF_SPACE_SHOW_ALL_ROOM_IN_HOME = "SETTINGS_PREF_SPACE_SHOW_ALL_ROOM_IN_HOME" private const val SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY = "SETTINGS_DEVELOPER_MODE_PREFERENCE_KEY" @@ -368,6 +369,10 @@ class VectorPreferences @Inject constructor( return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_LATEX_MATHS, false) } + fun builtinCameraIsEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_ENABLE_BUILTIN_CAMERA, false) + } + fun failFast(): Boolean { return buildMeta.isDebug || (developerMode() && defaultPrefs.getBoolean(SETTINGS_DEVELOPER_MODE_FAIL_FAST_PREFERENCE_KEY, false)) } 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 0bd5316b8f5..c28c278a01a 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 @@ -162,15 +162,19 @@ class VectorSettingsPreferencesFragment : } } - // Take photo or video - updateTakePhotoOrVideoPreferenceSummary() - takePhotoOrVideoPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { - PhotoOrVideoDialog(requireActivity(), vectorPreferences).showForSettings(object : PhotoOrVideoDialog.PhotoOrVideoDialogSettingsListener { - override fun onUpdated() { - updateTakePhotoOrVideoPreferenceSummary() - } - }) - true + if (vectorPreferences.builtinCameraIsEnabled()) { + takePhotoOrVideoPreference.isVisible = false + } else { + // Take photo or video + updateTakePhotoOrVideoPreferenceSummary() + takePhotoOrVideoPreference.onPreferenceClickListener = Preference.OnPreferenceClickListener { + PhotoOrVideoDialog(requireActivity(), vectorPreferences).showForSettings(object : PhotoOrVideoDialog.PhotoOrVideoDialogSettingsListener { + override fun onUpdated() { + updateTakePhotoOrVideoPreferenceSummary() + } + }) + true + } } } diff --git a/vector/src/main/res/drawable/ic_flash_auto.xml b/vector/src/main/res/drawable/ic_flash_auto.xml new file mode 100644 index 00000000000..4a74797d0d3 --- /dev/null +++ b/vector/src/main/res/drawable/ic_flash_auto.xml @@ -0,0 +1,5 @@ + + + diff --git a/vector/src/main/res/drawable/ic_flash_off.xml b/vector/src/main/res/drawable/ic_flash_off.xml new file mode 100644 index 00000000000..b940fd93834 --- /dev/null +++ b/vector/src/main/res/drawable/ic_flash_off.xml @@ -0,0 +1,5 @@ + + + diff --git a/vector/src/main/res/drawable/ic_flash_on.xml b/vector/src/main/res/drawable/ic_flash_on.xml new file mode 100644 index 00000000000..a6c0c44b308 --- /dev/null +++ b/vector/src/main/res/drawable/ic_flash_on.xml @@ -0,0 +1,5 @@ + + + diff --git a/vector/src/main/res/drawable/ic_flip_camera.xml b/vector/src/main/res/drawable/ic_flip_camera.xml new file mode 100644 index 00000000000..9e8ac18eabb --- /dev/null +++ b/vector/src/main/res/drawable/ic_flip_camera.xml @@ -0,0 +1,7 @@ + + + + + diff --git a/vector/src/main/res/drawable/ic_material_record.xml b/vector/src/main/res/drawable/ic_material_record.xml new file mode 100644 index 00000000000..57fc55c15d9 --- /dev/null +++ b/vector/src/main/res/drawable/ic_material_record.xml @@ -0,0 +1,5 @@ + + + diff --git a/vector/src/main/res/drawable/ic_material_stop.xml b/vector/src/main/res/drawable/ic_material_stop.xml new file mode 100644 index 00000000000..19bcbee7993 --- /dev/null +++ b/vector/src/main/res/drawable/ic_material_stop.xml @@ -0,0 +1,5 @@ + + + diff --git a/vector/src/main/res/layout/fragment_attachments_camera.xml b/vector/src/main/res/layout/fragment_attachments_camera.xml new file mode 100644 index 00000000000..68a1d28463f --- /dev/null +++ b/vector/src/main/res/layout/fragment_attachments_camera.xml @@ -0,0 +1,79 @@ + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/menu/vector_attachments_camera.xml b/vector/src/main/res/menu/vector_attachments_camera.xml new file mode 100644 index 00000000000..b1313affa1c --- /dev/null +++ b/vector/src/main/res/menu/vector_attachments_camera.xml @@ -0,0 +1,4 @@ + + + diff --git a/vector/src/main/res/values/strings.xml b/vector/src/main/res/values/strings.xml index 4d39dcae878..aaf6a379373 100644 --- a/vector/src/main/res/values/strings.xml +++ b/vector/src/main/res/values/strings.xml @@ -511,12 +511,15 @@ Send files Send sticker - Take photo or video + + Take photo or video Take photo Take video - Always ask + + Always ask - Use as default and do not ask again + + Use as default and do not ask again You don’t currently have any stickerpacks enabled.\n\nAdd some now? @@ -3196,6 +3199,14 @@ Live location sharing Please note: this is a labs feature using a temporary implementation. This means you will not be able to delete your location history, and advanced users will be able to see your location history even after you stop sharing your live location with this room. Enable location sharing + Capture + Change camera + Enable Built-in Camera + Disable flash + Enable flash + Set flash automatically + Chronometer + Tap for photo, hold for video %d message removed diff --git a/vector/src/main/res/xml/vector_settings_labs.xml b/vector/src/main/res/xml/vector_settings_labs.xml index 80b71a1f753..3f081487466 100644 --- a/vector/src/main/res/xml/vector_settings_labs.xml +++ b/vector/src/main/res/xml/vector_settings_labs.xml @@ -59,6 +59,11 @@ android:title="@string/labs_enable_latex_maths" /> + +