diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3b56fec4acb..e62dbb2f404 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -41,6 +41,7 @@ val androidxTestVersion = "1.5.0" val media3Version = "1.11.0" val coroutinesVersion = "1.11.0" val mockitoKotlinVersion = "6.3.0" +val cameraXVersion = "1.6.1" android { compileSdk = 37 @@ -244,6 +245,12 @@ dependencies { implementation("androidx.lifecycle:lifecycle-common:$lifecycleVersion") implementation("androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion") + implementation("androidx.camera:camera-core:$cameraXVersion") + implementation("androidx.camera:camera-camera2:$cameraXVersion") + implementation("androidx.camera:camera-lifecycle:$cameraXVersion") + implementation("androidx.camera:camera-video:$cameraXVersion") + implementation("androidx.camera:camera-view:$cameraXVersion") + implementation("androidx.biometric:biometric:1.1.0") implementation("io.reactivex.rxjava2:rxandroid:2.1.1") diff --git a/app/src/main/java/com/nextcloud/talk/chat/CameraLens.kt b/app/src/main/java/com/nextcloud/talk/chat/CameraLens.kt new file mode 100644 index 00000000000..b9176f47009 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/CameraLens.kt @@ -0,0 +1,60 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import android.content.Context +import android.util.Log +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.UseCase +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import java.util.concurrent.ExecutionException + +private const val TAG = "CameraLens" + +internal fun cameraSelectorFor(lens: Int): CameraSelector = CameraSelector.Builder().requireLensFacing(lens).build() + +internal fun oppositeLens(lens: Int): Int = + if (lens == CameraSelector.LENS_FACING_FRONT) CameraSelector.LENS_FACING_BACK else CameraSelector.LENS_FACING_FRONT + +/** + * The lens to bind: [preferred] when the device has it, otherwise the other one. + */ +internal fun resolveLens(provider: ProcessCameraProvider, preferred: Int): Int = + if (provider.hasCamera(cameraSelectorFor(preferred))) preferred else oppositeLens(preferred) + +/** + * Calls [onResult] on the main thread with the camera provider, or with null when it is not available. + */ +internal fun whenCameraProviderReady(context: Context, onResult: (ProcessCameraProvider?) -> Unit) { + val providerFuture = ProcessCameraProvider.getInstance(context) + providerFuture.addListener({ + val provider = try { + providerFuture.get() + } catch (e: ExecutionException) { + Log.w(TAG, "camera provider is not available", e) + null + } + onResult(provider) + }, ContextCompat.getMainExecutor(context)) +} + +/** + * Binds [useCases] to the camera with [lens]. Returns null when the camera cannot be bound. + */ +internal fun ProcessCameraProvider.bindSafely(owner: LifecycleOwner, lens: Int, vararg useCases: UseCase): Camera? = + try { + bindToLifecycle(owner, cameraSelectorFor(lens), *useCases) + } catch (e: IllegalArgumentException) { + Log.w(TAG, "cannot bind camera use cases", e) + null + } catch (e: IllegalStateException) { + Log.w(TAG, "cannot bind camera use cases", e) + null + } diff --git a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index 54749edcb4b..49a01515380 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -24,6 +24,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.res.AssetFileDescriptor import android.database.Cursor +import android.graphics.Outline import android.location.LocationManager import android.media.MediaMetadataRetriever import android.net.Uri @@ -36,10 +37,14 @@ import android.provider.MediaStore import android.provider.Settings import android.text.TextUtils import android.util.Log +import android.view.Gravity +import android.view.Surface import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup +import android.view.ViewOutlineProvider import android.view.WindowManager +import android.widget.FrameLayout import android.widget.PopupWindow import android.widget.TextView import android.widget.Toast @@ -50,6 +55,7 @@ import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia import androidx.activity.viewModels +import androidx.annotation.StringRes import androidx.appcompat.app.AlertDialog import androidx.compose.foundation.gestures.scrollBy import androidx.compose.foundation.lazy.LazyListState @@ -269,6 +275,8 @@ class ChatActivity : private lateinit var binding: ActivityChatBinding + private val orientationTracker by lazy { DeviceOrientationTracker(this) } + @Inject lateinit var ncApi: NcApi @@ -668,6 +676,7 @@ class ChatActivity : messageInputViewModel.setData(chatViewModel.getChatRepository()) initObservers() + resumeRecordingAfterRecreation() pendingTargetMessageId?.let { messageId -> lifecycleScope.launch { @@ -1422,6 +1431,7 @@ class ChatActivity : override fun onStart() { super.onStart() active = true + orientationTracker.enable() this.lifecycle.addObserver(AudioUtils) this.lifecycle.addObserver(chatViewModel) @@ -1462,8 +1472,14 @@ class ChatActivity : } override fun onStop() { + val changingConfigurations = isChangingConfigurations super.onStop() active = false + orientationTracker.disable() + if (!changingConfigurations) { + // the user leaves the chat: a video recording must not go on with camera and microphone + chatViewModel.activeVideoMessageRecorder?.cancel() + } this.lifecycle.removeObserver(AudioUtils) this.lifecycle.removeObserver(chatViewModel) @@ -1615,6 +1631,11 @@ class ChatActivity : joinRoomWithPassword() + // a video result which waited for the conversation and the capabilities of this activity + chatViewModel.activeVideoMessageRecorder?.takeIf { it.hasPendingResult }?.let { + attachVideoRecorder(it) + } + if (sharedFilePaths.isNotEmpty()) { onChooseFileResult(sharedFilePaths.map { it.toUri() }) sharedFilePaths = emptyList() @@ -1891,8 +1912,13 @@ class ChatActivity : } } + // The observer also gets the current value when the activity is recreated: no feedback for that + var lastRecordingInProgress = chatViewModel.getVoiceRecordingInProgress.value chatViewModel.getVoiceRecordingInProgress.observe(this) { voiceRecordingInProgress -> - VibrationUtils.vibrateShort(context) + if (shouldVibrateOnRecordingChange(lastRecordingInProgress, voiceRecordingInProgress)) { + VibrationUtils.vibrateShort(context) + } + lastRecordingInProgress = voiceRecordingInProgress if (voiceRecordingInProgress) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) } else { @@ -2459,6 +2485,229 @@ class ChatActivity : ) } + fun isVideoRecordPermissionGranted(): Boolean = + permissionUtil.isCameraPermissionGranted() && permissionUtil.isMicrophonePermissionGranted() + + fun requestVideoRecordPermissions() { + requestPermissions( + arrayOf( + Manifest.permission.CAMERA, + Manifest.permission.RECORD_AUDIO + ), + REQUEST_VIDEO_RECORD_PERMISSIONS + ) + } + + /** + * Starts recording a video message with the front camera. The recording shares the in-progress and locked state + * of the voice recording, so the gestures of the record button work the same for both. + * + * @return true if the recording was started + */ + fun startVideoRecording(): Boolean { + val recorder = chatViewModel.videoMessageRecorder(this) + val file = if (recorder.isActive) null else createAttachmentFile(R.string.nc_video_filename, VIDEO_SUFFIX) + if (file == null || !chatViewModel.onVideoRecordingStarted()) { + return false + } + showVideoRecordingPreview(true) + recorder.attach( + this, + binding.videoRecordingPreview, + ::onVideoRecordingFinished, + ::updateVideoRecordingPreviewLayout + ) + recorder.start( + file, + VideoMessageRecorder.videoTargetRotation( + orientationTracker.degrees, + binding.videoRecordingPreview.display?.rotation ?: Surface.ROTATION_0 + ) + ) + return true + } + + fun stopAndSendRecording() { + if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { + chatViewModel.activeVideoMessageRecorder?.stopAndSend() + } else { + chatViewModel.stopAndSendAudioRecording( + roomToken = roomToken, + replyToMessageId = getReplyToMessageId(), + displayName = currentConversation!!.displayName + ) + } + } + + fun stopAndDiscardRecording() { + if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { + chatViewModel.activeVideoMessageRecorder?.cancel() + } else { + chatViewModel.stopAndDiscardAudioRecording() + } + } + + private fun onVideoRecordingFinished(outcome: VideoMessageRecorder.Outcome, file: File?) { + showVideoRecordingPreview(false) + if (chatViewModel.getVoiceRecordingInProgress.value == true) { + chatViewModel.onVideoRecordingEnded() + } + if (chatViewModel.getVoiceRecordingLocked.value == true) { + chatViewModel.setVoiceRecordingLocked(false) + } + when (outcome) { + VideoMessageRecorder.Outcome.SEND -> file?.let { + uploadFiles(mutableListOf(FileProvider.getUriForFile(context, context.packageName, it).toString())) + } + + VideoMessageRecorder.Outcome.TOO_SHORT -> + Snackbar.make( + binding.root, + R.string.nc_video_message_hold_to_record_info_switch_to_voice, + Snackbar.LENGTH_SHORT + ).show() + + VideoMessageRecorder.Outcome.FAILED -> + Snackbar.make(binding.root, R.string.nc_video_message_recording_failed, Snackbar.LENGTH_LONG).show() + + VideoMessageRecorder.Outcome.INTERRUPTED -> file?.let { + onChooseFileResult(listOf(FileProvider.getUriForFile(context, context.packageName, it))) + } + + VideoMessageRecorder.Outcome.CANCELLED -> Unit + } + } + + /** + * After a recreation of the activity: picks up the recording which went on in the view model of the chat. The + * finger of the record button is gone with the old activity, so a recording which was held is locked, and the + * locked recording UI (timer, stop, send, cancel) is shown by the observer of the locked state. + */ + private fun resumeRecordingAfterRecreation() { + val recorder = chatViewModel.activeVideoMessageRecorder + val action = resolveRecordingResume( + recorderActive = recorder?.isActive == true, + hasPendingResult = recorder?.hasPendingResult == true, + recordingInProgress = chatViewModel.getVoiceRecordingInProgress.value == true, + recordingLocked = chatViewModel.getVoiceRecordingLocked.value == true, + // the result is uploaded or previewed with the conversation and the capabilities, which load later + chatReady = currentConversation != null && ::spreedCapabilities.isInitialized + ) + if (recorder != null && recorder.isActive) { + showVideoRecordingPreview(true) + } + val attach = action == RecordingResume.ATTACH || + action == RecordingResume.ATTACH_AND_LOCK || + action == RecordingResume.DELIVER_RESULT + if (recorder != null && attach) attachVideoRecorder(recorder) + if (action == RecordingResume.ATTACH_AND_LOCK) { + chatViewModel.setVoiceRecordingLocked(true) + } + } + + private fun attachVideoRecorder(recorder: VideoMessageRecorder) { + recorder.attach( + this, + binding.videoRecordingPreview, + ::onVideoRecordingFinished, + ::updateVideoRecordingPreviewLayout + ) + } + + private val accessibilityBeforeVideoRecording = HashMap() + + // the update only touches the layout of the preview when its size changed, so it does not loop + private val videoScrimLayoutListener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + updateVideoRecordingPreviewLayout() + } + + /** + * Places the preview in the middle of the dimming layer, which covers the area of the chat above the recording + * panel: in the aspect of the recorded frame, fitted into the area. Called whenever the area changes its size + * (rotation, panels of a wide screen) and when the recorder knows the aspect of the frame. + */ + private fun updateVideoRecordingPreviewLayout() { + val scrim = binding.videoRecordingScrim + if (scrim.width <= 0 || scrim.height <= 0) return + val resources = scrim.resources + val displayRotation = ContextCompat.getDisplayOrDefault(scrim.context)?.rotation ?: Surface.ROTATION_0 + val placement = videoPreviewPlacement( + areaWidth = scrim.width, + areaHeight = scrim.height, + aspect = chatViewModel.activeVideoMessageRecorder + ?.let { screenFrameAspect(it.frameAspect, it.videoRotation, displayRotation) } + ?: fallbackVideoFrameAspect(displayRotation), + limits = VideoPreviewLimits( + margin = resources.getDimensionPixelSize(R.dimen.standard_margin), + maxWidthFraction = VIDEO_PREVIEW_MAX_WIDTH_FRACTION, + maxSide = resources.getDimensionPixelSize(R.dimen.video_recording_preview_max_side), + shortAreaHeight = resources.getDimensionPixelSize(R.dimen.video_recording_preview_short_area), + shortMargin = resources.getDimensionPixelSize(R.dimen.standard_half_margin) + ) + ) + val container = binding.videoRecordingContainer + val params = container.layoutParams as FrameLayout.LayoutParams + if (params.width != placement.width || params.height != placement.height) { + params.width = placement.width + params.height = placement.height + params.gravity = Gravity.CENTER + container.layoutParams = params + container.invalidateOutline() + } + } + + /** + * TalkBack reaches the views under the dimming layer, which only swallows touches. While the video is recorded + * they are hidden from it; the values they had are put back afterwards. A new activity after a rotation starts + * with nothing saved and saves the defaults of its own fresh views. + */ + private fun hideChatFromAccessibility(hide: Boolean) { + val parent = binding.videoRecordingScrim.parent as? ViewGroup ?: return + if (hide) { + if (accessibilityBeforeVideoRecording.isNotEmpty()) return + for (i in 0 until parent.childCount) { + val child = parent.getChildAt(i) + if (child === binding.videoRecordingScrim) continue + accessibilityBeforeVideoRecording[child] = child.importantForAccessibility + child.importantForAccessibility = View.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS + } + } else { + accessibilityBeforeVideoRecording.forEach { (view, value) -> view.importantForAccessibility = value } + accessibilityBeforeVideoRecording.clear() + } + } + + private fun showVideoRecordingPreview(show: Boolean) { + if (show) { + binding.videoRecordingScrim.removeOnLayoutChangeListener(videoScrimLayoutListener) + binding.videoRecordingScrim.addOnLayoutChangeListener(videoScrimLayoutListener) + binding.videoRecordingContainer.clipToOutline = true + binding.videoRecordingContainer.outlineProvider = object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + val radius = view.resources.getDimension(R.dimen.standard_margin) + outline.setRoundRect(0, 0, view.width, view.height, radius) + } + } + binding.videoRecordingSwitchCamera.setOnClickListener { + chatViewModel.activeVideoMessageRecorder?.switchCamera() + } + } + hideChatFromAccessibility(show) + binding.videoRecordingScrim.visibility = if (show) View.VISIBLE else View.GONE + } + + private fun createAttachmentFile(@StringRes nameRes: Int, suffix: String): File? = + try { + val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) + ?: throw IOException("Could not create shared attachments directory") + val date = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT).format(Date()) + File(outputDir, "${context.resources.getString(nameRes, date)}$suffix") + } catch (e: IOException) { + logger.e(TAG, "error while creating attachment file", e) + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + null + } + private fun requestCameraPermissions() { requestPermissions( arrayOf( @@ -2812,6 +3061,14 @@ class ChatActivity : Snackbar.LENGTH_LONG ).show() } + } else if (requestCode == REQUEST_VIDEO_RECORD_PERMISSIONS) { + if (grantResults.isEmpty() || grantResults.any { it != PackageManager.PERMISSION_GRANTED }) { + Snackbar.make( + binding.root, + context.getString(R.string.nc_video_message_missing_permissions), + Snackbar.LENGTH_LONG + ).show() + } } else if (requestCode == REQUEST_CAMERA_PERMISSION) { if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { Snackbar @@ -4106,21 +4363,7 @@ class ChatActivity : } else { Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { takePictureIntent -> takePictureIntent.resolveActivity(packageManager)?.also { - val photoFile: File? = try { - val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) - ?: throw IOException("Could not create shared attachments directory") - val dateFormat = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT) - val date = dateFormat.format(Date()) - val photoName = String.format( - context.resources.getString(R.string.nc_picture_filename), - date - ) - File(outputDir, "$photoName$PICTURE_SUFFIX") - } catch (e: IOException) { - logger.e(TAG, "error while creating photo file", e) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - null - } + val photoFile = createAttachmentFile(R.string.nc_picture_filename, PICTURE_SUFFIX) photoFile?.also { pendingCameraUri = FileProvider.getUriForFile(context, context.packageName, it) @@ -4138,21 +4381,7 @@ class ChatActivity : } else { Intent(MediaStore.ACTION_VIDEO_CAPTURE).also { takeVideoIntent -> takeVideoIntent.resolveActivity(packageManager)?.also { - val videoFile: File? = try { - val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) - ?: throw IOException("Could not create shared attachments directory") - val dateFormat = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT) - val date = dateFormat.format(Date()) - val videoName = String.format( - context.resources.getString(R.string.nc_video_filename), - date - ) - File(outputDir, "$videoName$VIDEO_SUFFIX") - } catch (e: IOException) { - logger.e(TAG, "error while creating video file", e) - Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() - null - } + val videoFile = createAttachmentFile(R.string.nc_video_filename, VIDEO_SUFFIX) videoFile?.also { pendingCameraUri = FileProvider.getUriForFile(context, context.packageName, it) @@ -4282,6 +4511,7 @@ class ChatActivity : } companion object { + private const val VIDEO_PREVIEW_MAX_WIDTH_FRACTION = 0.75f val TAG = ChatActivity::class.java.simpleName /** @@ -4313,6 +4543,7 @@ class ChatActivity : private const val REQUEST_RECORD_AUDIO_PERMISSION = 222 private const val REQUEST_READ_CONTACT_PERMISSION = 234 private const val REQUEST_CAMERA_PERMISSION = 223 + private const val REQUEST_VIDEO_RECORD_PERMISSIONS = 224 private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss" private const val VIDEO_SUFFIX = ".mp4" private const val PICTURE_SUFFIX = ".jpg" @@ -4363,3 +4594,9 @@ internal fun resolveReplyToMessageId(replyMessageId: Int?, draftQuotedJsonId: In val replyId = replyMessageId?.takeIf { it != 0 } ?: draftQuotedJsonId?.takeIf { it != 0 } return replyId ?: threadId ?: 0 } + +/** + * Feedback is for the user starting or ending a recording, not for an activity which only shows the state again. + */ +internal fun shouldVibrateOnRecordingChange(previous: Boolean?, current: Boolean): Boolean = + current != (previous ?: false) diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt index a5d3fa86c7b..823b67c7230 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -12,6 +12,8 @@ import android.content.res.Resources import android.os.Build import android.os.Bundle import android.os.CountDownTimer +import android.os.Handler +import android.os.Looper import android.os.SystemClock import android.text.Editable import android.text.InputFilter @@ -25,6 +27,7 @@ import android.view.LayoutInflater import android.view.MenuItem import android.view.MotionEvent import android.view.View +import android.view.ViewConfiguration import android.view.ViewGroup import android.view.animation.AlphaAnimation import android.view.animation.Animation @@ -38,6 +41,7 @@ import android.widget.LinearLayout import android.widget.PopupMenu import android.widget.RelativeLayout import android.widget.SeekBar +import androidx.annotation.StringRes import androidx.appcompat.view.ContextThemeWrapper import androidx.compose.material3.MaterialTheme import androidx.core.content.ContextCompat @@ -55,7 +59,6 @@ import autodagger.AutoInjector import coil.load import com.google.android.flexbox.FlexboxLayout import com.google.android.material.button.MaterialButton -import com.google.android.material.snackbar.Snackbar import com.nextcloud.android.common.ui.theme.utils.ColorRole import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication @@ -131,6 +134,12 @@ class MessageInputFragment : Fragment() { private lateinit var spreedCapabilities: SpreedCapabilityDto private var hasSharedText = false + private val recordButtonHandler = Handler(Looper.getMainLooper()) + private var pendingRecordStart: Runnable? = null + private var recordingStartedAt = 0L + private var recordHintPopup: RecordHintPopup? = null + private var recordingUiShown = false + private var lastQuotedJsonId: Int? = null private var lastEditMessageId: Int? = null private var lastIsThreadCreationInProgress: Boolean = false @@ -155,8 +164,16 @@ class MessageInputFragment : Fragment() { return binding.root } + override fun onPause() { + cancelPendingRecordStart() + super.onPause() + } + override fun onDestroyView() { restoreKeyboardOnEmojiDismiss = false + cancelPendingRecordStart() + recordHintPopup?.dismiss() + recordHintPopup = null super.onDestroyView() if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() @@ -275,6 +292,13 @@ class MessageInputFragment : Fragment() { } } + chatActivity.chatViewModel.getVoiceRecordingInProgress.observe(viewLifecycleOwner) { inProgress -> + if (inProgress != true && recordingUiShown) { + showRecordAudioUi(false) + resetSlider() + } + } + chatActivity.chatViewModel.scheduledMessagesCount.observe(viewLifecycleOwner) { count -> if (chatActivity.conversationThreadId != null && chatActivity.conversationThreadId!! > 0) { val threadId = chatActivity.conversationThreadId @@ -583,86 +607,78 @@ class MessageInputFragment : Fragment() { @Suppress("ClickableViewAccessibility", "CyclomaticComplexMethod", "LongMethod") private fun initVoiceRecordButton() { handleButtonsVisibility() + updateRecordButtonMode() var prevDx = 0f - var voiceRecordStartTime = 0L - var voiceRecordEndTime: Long + val gesture = RecordButtonGesture(VOICE_RECORD_CANCEL_SLIDER_X.toFloat()) binding.fragmentMessageInputView.recordAudioButton.setOnTouchListener { v, event -> v?.performClick() when (event?.action) { MotionEvent.ACTION_DOWN -> { - if (!chatActivity.isRecordAudioPermissionGranted()) { - chatActivity.requestRecordAudioPermissions() - return@setOnTouchListener true - } - if (!chatActivity.permissionUtil.isFilesPermissionGranted()) { - UploadAndShareFilesWorker.requestStoragePermission(chatActivity) - return@setOnTouchListener true - } - - val base = SystemClock.elapsedRealtime() - voiceRecordStartTime = System.currentTimeMillis() - binding.fragmentMessageInputView.audioRecordDuration.base = base - messageInputViewModel.setRecordingTime(base) - binding.fragmentMessageInputView.audioRecordDuration.start() - chatActivity.chatViewModel.startAudioRecording(requireContext(), chatActivity.currentConversation!!) - showRecordAudioUi(true) + // A tap only switches the mode, the recording starts only after the hold threshold. + recordHintPopup?.dismiss() prevDx = event.x + gesture.down() + scheduleRecordStart(gesture) } MotionEvent.ACTION_CANCEL -> { Log.d(TAG, "ACTION_CANCEL") - if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || - !chatActivity.isRecordAudioPermissionGranted() + gesture.cancel() + if (cancelPendingRecordStart() || + chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true || + !isActiveRecordingPermissionGranted() ) { return@setOnTouchListener true } showRecordAudioUi(false) if (chatActivity.chatViewModel.getVoiceRecordingLocked.value != true) { // can also be null - chatActivity.chatViewModel.stopAndDiscardAudioRecording() + chatActivity.stopAndDiscardRecording() } } MotionEvent.ACTION_UP -> { Log.d(TAG, "ACTION_UP") - if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || + val release = gesture.up() + if (cancelPendingRecordStart() && release == RecordButtonGesture.Release.TOGGLE_MODE) { + toggleRecordInputMode() + return@setOnTouchListener true + } + if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true || chatActivity.chatViewModel.getVoiceRecordingLocked.value == true || - !chatActivity.isRecordAudioPermissionGranted() + !isActiveRecordingPermissionGranted() ) { return@setOnTouchListener false } showRecordAudioUi(false) - voiceRecordEndTime = System.currentTimeMillis() - val voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime - if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) { - Snackbar.make( - binding.root, - requireContext().getString(R.string.nc_voice_message_hold_to_record_info), - Snackbar.LENGTH_SHORT - ).show() - chatActivity.chatViewModel.stopAndDiscardAudioRecording() + val isVideo = chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO + if (isVideo) { + chatActivity.stopAndSendRecording() + } else if (isVoiceRecordTooShort()) { + showRecordHint(R.string.nc_voice_message_hold_to_record_info) + chatActivity.stopAndDiscardRecording() return@setOnTouchListener false } else { - chatActivity.chatViewModel.stopAndSendAudioRecording( - roomToken = chatActivity.roomToken, - replyToMessageId = chatActivity.getReplyToMessageId(), - displayName = chatActivity.currentConversation!!.displayName - ) + chatActivity.stopAndSendRecording() } resetSlider() } MotionEvent.ACTION_MOVE -> { - if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || - !chatActivity.isRecordAudioPermissionGranted() + if (gesture.isPending) { + if (gesture.move(event.x)) cancelPendingRecordStart() + return@setOnTouchListener true + } + if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true || + !isActiveRecordingPermissionGranted() ) { return@setOnTouchListener false } if (event.x < VOICE_RECORD_CANCEL_SLIDER_X) { - chatActivity.chatViewModel.stopAndDiscardAudioRecording() + chatActivity.stopAndDiscardRecording() showRecordAudioUi(false) resetSlider() return@setOnTouchListener true @@ -689,6 +705,96 @@ class MessageInputFragment : Fragment() { } } + private fun isVoiceRecordTooShort(): Boolean = + RecordButtonGesture.isTooShort(recordingStartedAt, SystemClock.elapsedRealtime(), MINIMUM_VOICE_RECORD_DURATION) + + private fun beginRecording(isVideo: Boolean) { + val base = SystemClock.elapsedRealtime() + if (isVideo) { + if (!chatActivity.startVideoRecording()) return + } else { + chatActivity.chatViewModel.startAudioRecording(requireContext(), chatActivity.currentConversation!!) + } + // the minimum duration counts from the start of the recording, not from the touch which preceded the hold + recordingStartedAt = SystemClock.elapsedRealtime() + binding.fragmentMessageInputView.audioRecordDuration.base = base + messageInputViewModel.setRecordingTime(base) + binding.fragmentMessageInputView.audioRecordDuration.start() + showRecordAudioUi(true) + } + + /** + * Neither the microphone nor the camera is opened before the hold threshold has passed, so that a tap on the + * record button can switch the mode without flashing the recording UI. After the threshold the permissions are + * checked: without them they are requested and nothing is recorded. + */ + private fun scheduleRecordStart(gesture: RecordButtonGesture) { + cancelPendingRecordStart() + val runnable = Runnable { + pendingRecordStart = null + if (!gesture.holdElapsed()) return@Runnable + val isVideo = chatActivity.chatViewModel.recordInputMode == RecordInputMode.VIDEO + if (isVideo && !chatActivity.isVideoRecordPermissionGranted()) { + chatActivity.requestVideoRecordPermissions() + } else if (!isVideo && !chatActivity.isRecordAudioPermissionGranted()) { + chatActivity.requestRecordAudioPermissions() + } else if (!chatActivity.permissionUtil.isFilesPermissionGranted()) { + UploadAndShareFilesWorker.requestStoragePermission(chatActivity) + } else { + beginRecording(isVideo) + } + } + pendingRecordStart = runnable + recordButtonHandler.postDelayed( + runnable, + RecordButtonGesture.holdThresholdMs(ViewConfiguration.getLongPressTimeout()) + ) + } + + private fun cancelPendingRecordStart(): Boolean { + val runnable = pendingRecordStart ?: return false + recordButtonHandler.removeCallbacks(runnable) + pendingRecordStart = null + return true + } + + private fun isActiveRecordingPermissionGranted(): Boolean = + if (chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { + chatActivity.isVideoRecordPermissionGranted() + } else { + chatActivity.isRecordAudioPermissionGranted() + } + + private fun toggleRecordInputMode() { + val newMode = chatActivity.chatViewModel.toggleRecordInputMode() + updateRecordButtonMode() + showRecordHint( + if (newMode == RecordInputMode.VIDEO) { + R.string.nc_video_message_hold_to_record_info_switch_to_voice + } else { + R.string.nc_voice_message_hold_to_record_info_switch_to_video + } + ) + } + + private fun showRecordHint(@StringRes messageRes: Int) { + val popup = recordHintPopup ?: RecordHintPopup(binding.fragmentMessageInputView.recordAudioButton) + .also { recordHintPopup = it } + popup.show(messageRes) + } + + private fun updateRecordButtonMode() { + val isVideo = chatActivity.chatViewModel.recordInputMode == RecordInputMode.VIDEO + binding.fragmentMessageInputView.recordAudioButton.apply { + setImageResource( + if (isVideo) R.drawable.ic_baseline_videocam_24 else R.drawable.ic_baseline_mic_24 + ) + contentDescription = context.getString( + if (isVideo) R.string.nc_description_record_video else R.string.nc_description_record_voice + ) + } + } + private fun initThreadHandling() { binding.fragmentMessageInputView.submitThreadButton.setOnClickListener { submitMessage(false) @@ -820,6 +926,7 @@ class MessageInputFragment : Fragment() { } private fun showRecordAudioUi(show: Boolean) { + recordingUiShown = show if (show) { restoreKeyboardOnEmojiDismiss = false binding.emojiPicker.isVisible = false diff --git a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt index 6832cfcf70e..7523a60f061 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -12,6 +12,8 @@ import android.os.SystemClock import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewGroup.LayoutParams.WRAP_CONTENT +import android.widget.LinearLayout import android.widget.SeekBar import android.widget.SeekBar.OnSeekBarChangeListener import androidx.core.content.ContextCompat @@ -38,6 +40,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { companion object { val TAG: String = MessageInputVoiceRecordingFragment::class.java.simpleName private const val SEEK_LIMIT = 98 + private const val PROGRESS_MAX = 1000 @JvmStatic fun newInstance() = MessageInputVoiceRecordingFragment() @@ -75,6 +78,9 @@ class MessageInputVoiceRecordingFragment : Fragment() { } private fun initObservers() { + if (isVideoRecording()) { + return + } messageInputViewModel.startMicInput(requireContext()) messageInputViewModel.micInputAudioObserver.observe(viewLifecycleOwner) { binding.micInputCloud.setRotationSpeed(it.first, it.second) @@ -114,21 +120,21 @@ class MessageInputVoiceRecordingFragment : Fragment() { private fun initVoiceRecordingView() { binding.deleteVoiceRecording.setOnClickListener { - chatActivity.chatViewModel.stopAndDiscardAudioRecording() + chatActivity.stopAndDiscardRecording() clear() } binding.sendVoiceRecording.setOnClickListener { - chatActivity.chatViewModel.stopAndSendAudioRecording( - roomToken = chatActivity.roomToken, - replyToMessageId = chatActivity.getReplyToMessageId(), - displayName = chatActivity.currentConversation!!.displayName - ) + chatActivity.stopAndSendRecording() clear() } - binding.micInputCloud.setOnClickListener { - togglePreviewVisibility() + if (isVideoRecording()) { + showCompactVideoRow() + } else { + binding.micInputCloud.setOnClickListener { + togglePreviewVisibility() + } } binding.playPauseBtn.setOnClickListener { @@ -155,10 +161,54 @@ class MessageInputVoiceRecordingFragment : Fragment() { }) } + /** + * Video is recorded with a one-line panel (delete, red dot in the progress ring, timer, send) so that the preview + * gets the height. The views are the ones of the voice panel, only arranged differently; the voice panel stays + * as it is. The fragment is recreated after a rotation and arranges the row again. The buttons get new layout + * params here, so their vertical margins from the layout do not apply in this row (48dp + 2 x 8dp padding). + */ + private fun showCompactVideoRow() { + val row = binding.recordingControlsRow + val timer = binding.audioRecordDuration + val margin = resources.getDimensionPixelSize(R.dimen.standard_half_margin) + (timer.parent as ViewGroup).removeView(timer) + row.addView(timer, row.indexOfChild(binding.micInputCloud), LinearLayout.LayoutParams(0, WRAP_CONTENT, 1f)) + timer.setPaddingRelative(margin, 0, 0, 0) + row.setPadding(margin, margin, margin, margin) + row.weightSum = 0f + for (button in listOf(binding.deleteVoiceRecording, binding.sendVoiceRecording)) { + button.layoutParams = LinearLayout.LayoutParams(button.layoutParams.width, button.layoutParams.height) + } + binding.deleteVoiceRecording.contentDescription = getString(R.string.nc_video_message_delete_recording) + binding.sendVoiceRecording.contentDescription = getString(R.string.nc_video_message_send_recording) + binding.micInputCloud.visibility = View.GONE + binding.videoRecordingIndicator.visibility = View.VISIBLE + binding.videoRecordingProgress.max = PROGRESS_MAX + timer.setOnChronometerTickListener { + val elapsed = SystemClock.elapsedRealtime() - it.base + binding.videoRecordingProgress.progress = + (elapsed * PROGRESS_MAX / VideoMessageRecorder.MAX_DURATION_MS).toInt().coerceIn(0, PROGRESS_MAX) + } + } + + private fun isVideoRecording() = chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO + private fun clear() { - chatActivity.chatViewModel.setVoiceRecordingLocked(false) + val isVideo = isVideoRecording() + val recorderActive = chatActivity.chatViewModel.activeVideoMessageRecorder?.isActive == true messageInputViewModel.stopMicInput() - chatActivity.chatViewModel.stopAudioRecording() + // A video recording is finished by CameraX later: ChatActivity.onVideoRecordingFinished releases the lock + // and the in-progress state then, also when the recording failed or was cancelled. + if (!clearsRecordingStateOnFinalize(isVideo, recorderActive)) { + chatActivity.chatViewModel.setVoiceRecordingLocked(false) + if (isVideo) { + if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == true) { + chatActivity.chatViewModel.onVideoRecordingEnded() + } + } else { + chatActivity.chatViewModel.stopAudioRecording() + } + } messageInputViewModel.stopMediaPlayer() binding.audioRecordDuration.stop() binding.audioRecordDuration.clearAnimation() diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt new file mode 100644 index 00000000000..6a0a101ac08 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt @@ -0,0 +1,76 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +/** + * Classifies one touch on the record button: a tap only switches the record mode, a hold starts the recording. + * + * The recording starts only once [holdElapsed] reports that the hold threshold has passed. A release or a cancel + * before that is never a recording. Has no Android dependencies, the caller owns the timer and the touch events. + */ +class RecordButtonGesture(private val cancelX: Float) { + + enum class State { IDLE, PENDING, HELD } + + enum class Release { TOGGLE_MODE, NONE } + + var state: State = State.IDLE + private set + + val isPending: Boolean get() = state == State.PENDING + + fun down() { + state = State.PENDING + } + + /** The hold threshold passed. Returns true if the recording may start now. */ + fun holdElapsed(): Boolean { + if (state != State.PENDING) return false + state = State.HELD + return true + } + + /** Finger moved to [x] (button coordinates). Returns true if the swipe cancelled a not yet started recording. */ + fun move(x: Float): Boolean { + if (state != State.PENDING || x >= cancelX) return false + state = State.IDLE + return true + } + + /** Finger released. A release before the hold threshold is a tap. */ + fun up(): Release { + val wasPending = state == State.PENDING + state = State.IDLE + return if (wasPending) Release.TOGGLE_MODE else Release.NONE + } + + /** The system cancelled the touch. Never switches the mode. */ + fun cancel() { + state = State.IDLE + } + + companion object { + const val MIN_HOLD_MS = 400L + const val MAX_HOLD_MS = 600L + + /** + * Hold threshold from the system long-press timeout (400 ms by default, longer when the user raised it for + * accessibility). A touch shorter than the system long press is a click for every other control, so the same + * touch must not start a recording. The upper bound keeps the start delay bearable. + */ + fun holdThresholdMs(systemLongPressMs: Int): Long = + systemLongPressMs.toLong().coerceIn(MIN_HOLD_MS, MAX_HOLD_MS) + + /** + * Whether a recording which started at [startedAtMs] and ends at [endedAtMs] is shorter than [minMs]. Both + * times are taken when the recording actually starts and stops, not at the touch of the button, which + * precedes the start by the hold threshold. + */ + fun isTooShort(startedAtMs: Long, endedAtMs: Long, minMs: Int): Boolean = endedAtMs - startedAtMs < minMs + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordHintPopup.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordHintPopup.kt new file mode 100644 index 00000000000..c2163dd9e8b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/RecordHintPopup.kt @@ -0,0 +1,165 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +import android.content.res.ColorStateList +import android.graphics.drawable.GradientDrawable +import android.os.Handler +import android.os.Looper +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.PopupWindow +import android.widget.TextView +import androidx.annotation.StringRes +import androidx.core.view.doOnLayout +import com.google.android.material.color.MaterialColors +import com.nextcloud.talk.R + +/** + * Hint bubble above the record button, with an arrow pointing at the button. It does not take touches, so the + * button stays usable while the hint is visible. + */ +class RecordHintPopup(private val anchor: View) { + + private val handler = Handler(Looper.getMainLooper()) + private val dismissRunnable = Runnable { dismiss() } + private var popup: PopupWindow? = null + private var shown = 0 + private var layoutListener: View.OnLayoutChangeListener? = null + + /** + * Shows the hint above the anchor. The position is taken after the layout of the anchor, because the caller may + * have changed the layout just before (the recording UI is hidden and the input shown again, which moves the + * button), and it follows the anchor while the hint is shown. + */ + fun show(@StringRes messageRes: Int) { + dismiss() + if (!anchor.isAttachedToWindow) return + val token = ++shown + anchor.doOnLayout { + if (token == shown && it.isAttachedToWindow) present(messageRes) + } + } + + private fun present(@StringRes messageRes: Int) { + val context = anchor.context + val content = LayoutInflater.from(context).inflate(R.layout.view_record_hint, null) + val bubbleColor = MaterialColors.getColor(anchor, com.google.android.material.R.attr.colorSurfaceInverse) + val textColor = MaterialColors.getColor(anchor, com.google.android.material.R.attr.colorOnSurfaceInverse) + val text = content.findViewById(R.id.recordHintText) + text.setText(messageRes) + text.setTextColor(textColor) + (text.background.mutate() as GradientDrawable).setColor(bubbleColor) + val arrow = content.findViewById(R.id.recordHintArrow) + arrow.imageTintList = ColorStateList.valueOf(bubbleColor) + + val metrics = context.resources.displayMetrics + val margin = (SCREEN_MARGIN_DP * metrics.density).toInt() + val gap = (GAP_DP * metrics.density).toInt() + val arrowWidth = (ARROW_WIDTH_DP * metrics.density).toInt() + content.measure( + View.MeasureSpec.makeMeasureSpec(anchor.rootView.width - 2 * margin, View.MeasureSpec.AT_MOST), + View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED) + ) + val hintWidth = content.measuredWidth + + // showAtLocation positions relative to the window of the anchor, so the anchor is measured in its window + fun placement(): Placement { + val location = IntArray(2) + anchor.getLocationInWindow(location) + return hintPlacement( + anchorLeft = location[0], + anchorTop = location[1], + anchorWidth = anchor.width, + windowWidth = anchor.rootView.width, + windowHeight = anchor.rootView.height, + hintWidth = hintWidth, + margin = margin, + gap = gap, + arrowWidth = arrowWidth + ) + } + + fun applyArrow(placement: Placement) { + val arrowParams = arrow.layoutParams as LinearLayout.LayoutParams + arrowParams.leftMargin = placement.arrowLeftMargin + arrow.layoutParams = arrowParams + } + + val first = placement() + applyArrow(first) + val wrap = ViewGroup.LayoutParams.WRAP_CONTENT + val window = PopupWindow(content, wrap, wrap, false) + .apply { + isTouchable = false + isFocusable = false + isOutsideTouchable = false + // anchored by its bottom edge, so that the hint grows upwards, away from the button + showAtLocation(anchor, Gravity.BOTTOM or Gravity.LEFT, first.x, first.bottomOffset) + } + popup = window + + val listener = View.OnLayoutChangeListener { _, _, _, _, _, _, _, _, _ -> + val moved = placement() + applyArrow(moved) + window.update(moved.x, moved.bottomOffset, -1, -1) + } + layoutListener = listener + anchor.addOnLayoutChangeListener(listener) + handler.postDelayed(dismissRunnable, SHOW_DURATION_MS) + } + + fun dismiss() { + shown++ + handler.removeCallbacks(dismissRunnable) + layoutListener?.let { anchor.removeOnLayoutChangeListener(it) } + layoutListener = null + popup?.dismiss() + popup = null + } + + /** + * Where the hint goes, in the coordinates of the window of the anchor: [x] from the left edge of the window, + * [bottomOffset] from the bottom edge of the window up to the bottom edge of the hint. + */ + data class Placement(val x: Int, val bottomOffset: Int, val arrowLeftMargin: Int) + + companion object { + const val SHOW_DURATION_MS = 1500L + private const val SCREEN_MARGIN_DP = 8 + private const val GAP_DP = 4 + private const val ARROW_WIDTH_DP = 16 + + /** + * The hint sits above the anchor with [gap] between the bottom edge of the hint and the top edge of the + * anchor, centred on it and kept [margin] away from the side edges of the window; the arrow points at the + * centre of the anchor. The height of the hint does not enter: it is anchored by its bottom edge. + */ + @Suppress("LongParameterList") + fun hintPlacement( + anchorLeft: Int, + anchorTop: Int, + anchorWidth: Int, + windowWidth: Int, + windowHeight: Int, + hintWidth: Int, + margin: Int, + gap: Int, + arrowWidth: Int + ): Placement { + val anchorCenterX = anchorLeft + anchorWidth / 2 + val x = (anchorCenterX - hintWidth / 2).coerceIn(margin, maxOf(margin, windowWidth - margin - hintWidth)) + val arrow = (anchorCenterX - x - arrowWidth / 2).coerceIn(0, maxOf(0, hintWidth - arrowWidth)) + return Placement(x, windowHeight - anchorTop + gap, arrow) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordInputMode.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordInputMode.kt new file mode 100644 index 00000000000..0db7cdb4e24 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/RecordInputMode.kt @@ -0,0 +1,22 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +/** + * What the record button of the message input records while it is held. + */ +enum class RecordInputMode { + VOICE, + VIDEO; + + fun toggled(): RecordInputMode = if (this == VOICE) VIDEO else VOICE + + companion object { + fun fromVideoFlag(isVideo: Boolean): RecordInputMode = if (isVideo) VIDEO else VOICE + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordingStopCoordinator.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordingStopCoordinator.kt new file mode 100644 index 00000000000..ac584bf3369 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/RecordingStopCoordinator.kt @@ -0,0 +1,104 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +import android.os.Handler +import android.os.Looper + +/** + * Runs a delayed [Runnable] on the main thread; replaced in tests. + */ +internal interface DelayScheduler { + fun postDelayed(runnable: Runnable, delayMs: Long) + fun cancel(runnable: Runnable) +} + +internal class MainThreadScheduler : DelayScheduler { + private val handler by lazy { Handler(Looper.getMainLooper()) } + + override fun postDelayed(runnable: Runnable, delayMs: Long) { + handler.postDelayed(runnable, delayMs) + } + + override fun cancel(runnable: Runnable) { + handler.removeCallbacks(runnable) + } +} + +/** + * How a running recording is asked to end. + */ +internal enum class StopAction { SEND, DISCARD } + +/** + * Decides when a running recording may be stopped. + * + * After a camera switch the CameraX recorder sets up a new video surface for [SWITCH_SETTLE_MS]. If it is stopped + * meanwhile, the setup ends in an AssertionError of CameraX (onConfigured() in a STOPPING state). So no stop is + * issued inside that window: it is held back and issued when the window ends. + * + * The first request ends the recording and is final: a send after a cancel must not send the cancelled video, nor a + * cancel after a send discard the video which is being sent. + */ +internal class RecordingStopCoordinator(private val scheduler: DelayScheduler, private val stopRecording: () -> Unit) { + var action: StopAction? = null + private set + + var settling = false + private set + + private var stopPending = false + private val windowEnded = Runnable { + settling = false + if (stopPending) { + stopPending = false + stopRecording() + } + } + + /** + * Opens the window after a camera switch. Refused while another window is open or once a stop was requested. + */ + fun beginSwitch(): Boolean { + if (action != null || settling) return false + settling = true + scheduler.postDelayed(windowEnded, SWITCH_SETTLE_MS) + return true + } + + /** + * @return false if the request is ignored because an earlier one decided already + */ + fun requestStop(requested: StopAction): Boolean { + if (action != null) return false + action = requested + if (settling) { + stopPending = true + } else { + stopRecording() + } + return true + } + + /** + * Forgets everything, the recording is over. + */ + fun reset() { + scheduler.cancel(windowEnded) + settling = false + stopPending = false + action = null + } + + companion object { + /** + * How long after a camera switch the CameraX recorder may still be setting up its new video surface. + */ + const val SWITCH_SETTLE_MS = 1_500L + } +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt new file mode 100644 index 00000000000..be41260214b --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -0,0 +1,521 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +import android.annotation.SuppressLint +import android.content.Context +import android.util.Log +import android.view.OrientationEventListener +import android.view.Surface +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.video.ExperimentalPersistentRecording +import androidx.camera.video.FallbackStrategy +import androidx.camera.video.FileOutputOptions +import androidx.camera.video.Quality +import androidx.camera.video.QualitySelector +import androidx.camera.video.Recorder +import androidx.camera.video.Recording +import androidx.camera.video.VideoCapture +import androidx.camera.video.VideoRecordEvent +import androidx.camera.view.PreviewView +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import java.io.File + +/** + * Records a plain mp4 video with CameraX and shows a live preview while doing so. + * + * The recorder does not belong to an activity: it lives in the view model of the chat and survives the recreation of + * the activity (rotation). The camera is bound once per recording to a lifecycle of the recorder itself + * ([RecordingLifecycleOwner]), not to the one of the activity: the activity stops on every rotation and would stop + * the camera, and binding the use cases again to the next activity makes the CameraX recorder configure itself in a + * state where that is not allowed. The activity hands only its preview view and its callback in with [attach] and + * the recorder lets go of both in [detach], which also happens when the activity lifecycle is destroyed, so no + * activity or view is leaked. While nothing is attached the recording goes on; a result that arrives then is kept + * and delivered by the next [attach]. + * + * All methods must be called on the main thread. The callback is called exactly once per [start] on the main thread: + * with [Outcome.SEND] and the file to send, with [Outcome.INTERRUPTED] and the file that was recorded up to the + * interruption, or with another outcome and null (the file is deleted then). + * The recording survives [switchCamera]; the preview and the camera are released as soon as it ends. + */ +class VideoMessageRecorder(context: Context) { + + /** + * [INTERRUPTED]: the recording ended without being asked to, but what was recorded is usable. It is not sent + * on its own but shown as a preview. + */ + enum class Outcome { SEND, CANCELLED, TOO_SHORT, FAILED, INTERRUPTED } + + private enum class State { IDLE, STARTING, RECORDING } + + private class Finished(val outcome: Outcome, val file: File?) + + private val context: Context = context.applicationContext + private var activityLifecycle: Lifecycle? = null + private var cameraOwner: RecordingLifecycleOwner? = null + private var previewView: PreviewView? = null + private var onFinished: ((Outcome, File?) -> Unit)? = null + private var onFrameAspectChanged: (() -> Unit)? = null + private var pendingResult: Finished? = null + private var released = false + + private val ownerObserver = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_DESTROY) detach() + } + + private var state = State.IDLE + private var cameraProvider: ProcessCameraProvider? = null + private var preview: Preview? = null + private var videoCapture: VideoCapture? = null + private var recording: Recording? = null + private var outputFile: File? = null + private var lensFacing = CameraSelector.LENS_FACING_FRONT + private var session = 0 + + /** The [Surface] rotation the video is recorded in. */ + var videoRotation = Surface.ROTATION_0 + private set + private val stopCoordinator = RecordingStopCoordinator(MainThreadScheduler()) { recording?.stop() } + + val isActive: Boolean + get() = state != State.IDLE + + /** + * Width divided by height of the frame that is recorded, as the viewer sees it: portrait for a recording of a + * phone held upright, landscape for one held on its side. It comes from the resolution the camera was bound + * with; until that is known, from the rotation the video is recorded in. + */ + val frameAspect: Float + get() { + val info = videoCapture?.resolutionInfo + val crop = info?.cropRect + val resolution = info?.resolution + val aspect = when { + info == null -> null + crop != null && !crop.isEmpty -> videoFrameAspect(crop.width(), crop.height(), info.rotationDegrees) + resolution != null -> videoFrameAspect(resolution.width, resolution.height, info.rotationDegrees) + else -> null + } + return aspect ?: fallbackVideoFrameAspect(videoRotation) + } + + /** + * A recording ended while no activity was attached, its result waits for the next [attach]. + */ + val hasPendingResult: Boolean + get() = pendingResult != null + + /** + * Shows the preview in [view] and reports the end of the recording to [callback]. A running recording keeps its + * camera, only the preview picture moves to the new view; a result which is waiting is delivered at once. The + * previously attached view is dropped. [owner] is only watched: its destruction detaches the recorder. + * [onFrameAspect] is called when the aspect ratio of the recorded frame became known, see [frameAspect]. + */ + fun attach( + owner: LifecycleOwner, + view: PreviewView, + callback: (Outcome, File?) -> Unit, + onFrameAspect: () -> Unit = {} + ) { + if (released) return + detach() + activityLifecycle = owner.lifecycle + previewView = view + onFinished = callback + onFrameAspectChanged = onFrameAspect + owner.lifecycle.addObserver(ownerObserver) + + preview?.let { previewUseCase -> + // only the preview is reconfigured by this, the video use case and the recording are not touched + previewUseCase.targetRotation = displayRotation(view, videoRotation) + previewUseCase.surfaceProvider = view.surfaceProvider + } + pendingResult?.let { + pendingResult = null + callback(it.outcome, it.file) + } + } + + /** + * Lets go of the activity. A running recording continues with its camera and without a picture on the screen + * until the next [attach]. + */ + fun detach() { + activityLifecycle?.removeObserver(ownerObserver) + preview?.surfaceProvider = null + activityLifecycle = null + previewView = null + onFinished = null + onFrameAspectChanged = null + } + + /** + * Ends the recorder for good, when the chat is left: a running recording is discarded, nothing is delivered. + */ + fun release() { + released = true + detach() + pendingResult?.file?.delete() + pendingResult = null + cancel() + // Inside the window after a camera switch the stop is held back and the camera is not unbound: that would + // let CameraX set up the new surface while stopping. The finalization (or the end of the window) closes it. + if (!stopCoordinator.settling) releaseCamera() + } + + /** + * @param targetRotation the [Surface] rotation the video is recorded in, see [videoTargetRotation] + */ + fun start(file: File, targetRotation: Int) { + check(state == State.IDLE) { "Video recording already active" } + state = State.STARTING + session++ + val startedSession = session + stopCoordinator.reset() + outputFile = file + videoRotation = targetRotation + lensFacing = CameraSelector.LENS_FACING_FRONT + + whenCameraProviderReady(context) { provider -> + if (startedSession != session) return@whenCameraProviderReady + if (provider != null) { + onCameraProviderReady(provider) + } else { + finish(Outcome.FAILED, null) + } + } + } + + fun stopAndSend() { + stop(StopAction.SEND) + } + + /** + * Discards the recording unless it was already stopped to be sent: that one is finished and sent regardless. + */ + fun cancel() { + stop(StopAction.DISCARD) + } + + /** + * Switches between the front and the back camera. A running recording continues, the picture is frozen + * until the new camera delivers frames. The use cases are bound again, which makes the CameraX recorder set up a + * new video surface, and that must not meet a stop: see [RecordingStopCoordinator]. + */ + fun switchCamera() { + val provider = cameraProvider + val newLens = oppositeLens(lensFacing) + if (provider == null || state != State.RECORDING || !provider.hasCamera(cameraSelectorFor(newLens))) return + if (!stopCoordinator.beginSwitch()) return + + val previousLens = lensFacing + lensFacing = newLens + provider.unbind(preview, videoCapture) + if (!bindUseCases(provider)) { + lensFacing = previousLens + bindUseCases(provider) + } + // the new camera may deliver another resolution + onFrameAspectChanged?.invoke() + } + + private fun stop(action: StopAction) { + when (state) { + State.IDLE -> Unit + + State.STARTING -> { + session++ + finish(if (action == StopAction.SEND) Outcome.TOO_SHORT else Outcome.CANCELLED, null) + } + + State.RECORDING -> stopCoordinator.requestStop(action) + } + } + + private fun onCameraProviderReady(provider: ProcessCameraProvider) { + if (state != State.STARTING) { + return + } + cameraProvider = provider + val owner = RecordingLifecycleOwner().also { cameraOwner = it } + owner.start() + + val recorder = Recorder.Builder() + .setQualitySelector( + QualitySelector.from(Quality.HD, FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)) + ) + .setTargetVideoEncodingBitRate(TARGET_VIDEO_BIT_RATE) + .build() + videoCapture = VideoCapture.Builder(recorder).setTargetRotation(videoRotation).build() + preview = Preview.Builder() + .setTargetRotation(previewView?.let { displayRotation(it, videoRotation) } ?: videoRotation) + .build() + .also { it.surfaceProvider = previewView?.surfaceProvider } + + if (!bindUseCases(provider)) { + finish(Outcome.FAILED, null) + return + } + startRecording() + onFrameAspectChanged?.invoke() + } + + private fun bindUseCases(provider: ProcessCameraProvider): Boolean { + lensFacing = resolveLens(provider, lensFacing) + val owner = cameraOwner + val previewUseCase = preview + val videoUseCase = videoCapture + if (owner == null || previewUseCase == null || videoUseCase == null) return false + return provider.bindSafely(owner, lensFacing, previewUseCase, videoUseCase) != null + } + + @SuppressLint("MissingPermission") + @androidx.annotation.OptIn(ExperimentalPersistentRecording::class) + private fun startRecording() { + val file = outputFile ?: return + val videoUseCase = videoCapture ?: return + val options = FileOutputOptions.Builder(file) + .setDurationLimitMillis(MAX_DURATION_MS) + .build() + recording = videoUseCase.output + .prepareRecording(context, options) + .withAudioEnabled() + .asPersistentRecording() + .start(ContextCompat.getMainExecutor(context), ::onRecordEvent) + state = State.RECORDING + } + + private fun onRecordEvent(event: VideoRecordEvent) { + if (event is VideoRecordEvent.Finalize) { + val duration = event.recordingStats.recordedDurationNanos + var outcome = resolveOutcome( + discardRequested = stopCoordinator.action == StopAction.DISCARD, + hasError = event.hasError(), + error = event.error, + recordedDurationNanos = duration + ) + val cutOff = outcome == Outcome.FAILED && + isSalvageable(event.error, duration, outputFile?.length() ?: 0L) + if (cutOff) { + outcome = Outcome.INTERRUPTED + } + if (event.hasError()) { + Log.w(TAG, "recording finalized with error ${event.error}, outcome: $outcome") + } + finish(outcome, if (outcome == Outcome.SEND || outcome == Outcome.INTERRUPTED) outputFile else null) + } + } + + private fun finish(outcome: Outcome, fileToSend: File?) { + val file = outputFile + releaseCamera() + recording = null + outputFile = null + state = State.IDLE + if (fileToSend == null || released) { + file?.delete() + } + if (released) return + + val callback = onFinished + if (callback != null) { + callback(outcome, fileToSend) + } else { + pendingResult = Finished(outcome, fileToSend) + } + } + + /** + * Ends the lifecycle the camera is bound to, which closes the camera, and lets go of the use cases. + */ + private fun releaseCamera() { + stopCoordinator.reset() + preview?.surfaceProvider = null + cameraProvider?.unbind(preview, videoCapture) + cameraOwner?.destroy() + cameraOwner = null + preview = null + videoCapture = null + } + + companion object { + private val TAG = VideoMessageRecorder::class.java.simpleName + const val MAX_DURATION_MS = 120_000L + const val TARGET_VIDEO_BIT_RATE = 2_500_000 + + const val MIN_DURATION_NANOS = 1_000_000_000L + + /** + * A recording that ended because of the duration or size limit is complete and valid; any other error leaves + * a file that must not be sent. + */ + fun isUsableFinalize(hasError: Boolean, error: Int): Boolean = + !hasError || + error == VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED || + error == VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED + + /** + * Decides what happens with a finalized recording. "No valid data" and a recording shorter than + * [MIN_DURATION_NANOS] mean the button was released too early, every other error is a failure. + */ + fun resolveOutcome( + discardRequested: Boolean, + hasError: Boolean, + error: Int, + recordedDurationNanos: Long + ): Outcome = + when { + discardRequested -> Outcome.CANCELLED + hasError && error == VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA -> Outcome.TOO_SHORT + !isUsableFinalize(hasError, error) -> Outcome.FAILED + recordedDurationNanos < MIN_DURATION_NANOS -> Outcome.TOO_SHORT + else -> Outcome.SEND + } + + /** + * A recording which the camera cut off (source inactive) leaves a valid file with everything recorded + * before that. It is worth keeping when it is long enough and not empty. + */ + fun isSalvageable(error: Int, recordedDurationNanos: Long, fileLength: Long): Boolean = + error == VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE && + recordedDurationNanos >= MIN_DURATION_NANOS && + fileLength > 0 + + /** + * The rotation to record the video in: the one of the sensor when it is known (the video then matches how + * the phone is held, whatever the activity shows), otherwise the one of the display. + * + * @param sensorDegrees what [OrientationEventListener] reported, or ORIENTATION_UNKNOWN + */ + fun videoTargetRotation(sensorDegrees: Int, displayRotation: Int): Int = + rotationForDeviceOrientation(sensorDegrees) ?: displayRotation + } +} + +/** + * The rotation of the display the view is shown on. It is not read from the view itself: the activity attaches its + * preview in onCreate, before the view is in a window, and View.getDisplay() is null then. + */ +private fun displayRotation(view: PreviewView, fallback: Int): Int = + ContextCompat.getDisplayOrDefault(view.context)?.rotation ?: fallback + +/** + * The lifecycle the camera of a recording is bound to. It is not the one of an activity, so that rotating the screen + * (stop and destroy of the activity) neither stops the camera nor unbinds its use cases. It is started when the + * recording starts and destroyed when it ends, which closes the camera. + */ +internal class RecordingLifecycleOwner : LifecycleOwner { + private val registry = LifecycleRegistry.createUnsafe(this) + + override val lifecycle: Lifecycle + get() = registry + + fun start() { + if (registry.currentState != Lifecycle.State.DESTROYED) registry.currentState = Lifecycle.State.STARTED + } + + fun destroy() { + // a lifecycle cannot go from INITIALIZED to DESTROYED directly + if (registry.currentState == Lifecycle.State.INITIALIZED) registry.currentState = Lifecycle.State.CREATED + registry.currentState = Lifecycle.State.DESTROYED + } +} + +/** + * What a new activity does about a recording it finds in the view model of the chat. + */ +enum class RecordingResume { + /** Nothing was recording. */ + NONE, + + /** Attach to the running recording, the lock is already set. */ + ATTACH, + + /** Attach and lock: the finger which held the record button is gone with the old activity. */ + ATTACH_AND_LOCK, + + /** The recording ended while there was no activity: deliver its result. */ + DELIVER_RESULT, + + /** Same, but the chat of the new activity is not loaded yet: attach later, once it is. */ + DEFER_RESULT +} + +fun resolveRecordingResume( + recorderActive: Boolean, + hasPendingResult: Boolean, + recordingInProgress: Boolean, + recordingLocked: Boolean, + chatReady: Boolean = true +): RecordingResume = + when { + hasPendingResult && !chatReady -> RecordingResume.DEFER_RESULT + hasPendingResult -> RecordingResume.DELIVER_RESULT + recorderActive && recordingLocked -> RecordingResume.ATTACH + recorderActive -> RecordingResume.ATTACH_AND_LOCK + recordingInProgress && !recordingLocked -> RecordingResume.ATTACH_AND_LOCK + else -> RecordingResume.NONE + } + +/** + * Whether "send" or "delete" of a locked recording leaves the recording state to [VideoMessageRecorder]: a video + * recording is not over before CameraX has finalized the file, and its callback releases the lock and the in-progress + * state. Cleared earlier, the UI would accept a new start which the still busy recorder drops. An audio recording, + * or a video recorder which is already idle (no finalization to wait for), is cleared at once. + */ +fun clearsRecordingStateOnFinalize(isVideo: Boolean, recorderActive: Boolean): Boolean = isVideo && recorderActive + +/** + * Follows the orientation of the device with the sensor, which keeps working while the activity is not rotated. + */ +class DeviceOrientationTracker(context: Context) { + var degrees: Int = OrientationEventListener.ORIENTATION_UNKNOWN + private set + + private val listener = object : OrientationEventListener(context) { + override fun onOrientationChanged(orientation: Int) { + degrees = orientation + } + } + + fun enable() { + if (listener.canDetectOrientation()) listener.enable() + } + + fun disable() { + listener.disable() + degrees = OrientationEventListener.ORIENTATION_UNKNOWN + } +} + +private const val ORIENTATION_QUARTER = 90 +private const val ORIENTATION_EIGHTH = 45 +private const val FULL_CIRCLE = 360 + +// Device turned clockwise by 0, 90, 180, 270 degrees: the display rotates the other way. +private val rotationByQuadrant = intArrayOf( + Surface.ROTATION_0, + Surface.ROTATION_270, + Surface.ROTATION_180, + Surface.ROTATION_90 +) + +/** + * Maps the device orientation reported by OrientationEventListener (degrees, clockwise from upright) to the + * [Surface] rotation, or null when the orientation is unknown. + */ +internal fun rotationForDeviceOrientation(degrees: Int): Int? { + if (degrees < 0) return null + return rotationByQuadrant[((degrees + ORIENTATION_EIGHTH) % FULL_CIRCLE) / ORIENTATION_QUARTER] +} diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt new file mode 100644 index 00000000000..6461b9e67d6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Krainov Gleb + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +package com.nextcloud.talk.chat + +import kotlin.math.max +import kotlin.math.min +import kotlin.math.roundToInt + +/** + * Size of the video recording preview inside the area of the chat, in the unit of the area (pixels). The preview is + * centered by its parent (gravity), so no offsets are given: they would have to be resolved for right-to-left. + */ +data class VideoPreviewPlacement(val width: Int, val height: Int) + +/** + * Bounds of the preview inside the area. + * + * @param margin the gap kept free at every edge of the area (shrinks on a very small area) + * @param maxWidthFraction the part of the area width the preview may take at most + * @param maxSide the longest side the preview may have, so that it does not grow without limit on a tablet + * @param shortAreaHeight an area lower than this keeps [shortMargin] instead of [margin]: on a short landscape area two + * full margins would take a noticeable part of the height. Zero turns this off. + */ +data class VideoPreviewLimits( + val margin: Int, + val maxWidthFraction: Float, + val maxSide: Int, + val shortAreaHeight: Int = 0, + val shortMargin: Int = margin +) + +/** + * Fits a frame of the given aspect ratio into the area inside the [limits]. A portrait frame is limited + * by the width of the area and by its height, a landscape frame is fitted by the height when the area is short: + * the frame is never distorted, only scaled down. + * + * @param aspect width divided by height of the recorded frame as the viewer sees it (9/16 portrait, 16/9 landscape) + */ +fun videoPreviewPlacement( + areaWidth: Int, + areaHeight: Int, + aspect: Float, + limits: VideoPreviewLimits +): VideoPreviewPlacement { + val safeAspect = if (aspect.isFinite() && aspect > 0f) aspect else DEFAULT_FRAME_ASPECT + val width = max(areaWidth, 0) + val height = max(areaHeight, 0) + // a margin may not eat the area: at most an eighth of the shorter side on each edge + val wanted = if (height < limits.shortAreaHeight) limits.shortMargin else limits.margin + val margin = max(min(wanted, min(width, height) / MARGIN_DIVISOR), 0) + val boxWidth = min((width - 2 * margin).toFloat(), width * limits.maxWidthFraction) + val boxHeight = (height - 2 * margin).toFloat() + + var w = max(boxWidth, 0f) + var h = w / safeAspect + if (h > boxHeight) { + h = max(boxHeight, 0f) + w = h * safeAspect + } + val longest = max(w, h) + if (longest > limits.maxSide && longest > 0f) { + val scale = limits.maxSide / longest + w *= scale + h *= scale + } + + return VideoPreviewPlacement(width = w.roundToInt(), height = h.roundToInt()) +} + +/** + * Aspect ratio (width / height) of the frame the viewer sees, from the size of the camera surface and the rotation + * needed to show it upright. A quarter turn swaps the sides. Null when the size is not known (yet). + */ +fun videoFrameAspect(surfaceWidth: Int, surfaceHeight: Int, rotationDegrees: Int): Float? = + if (surfaceWidth <= 0 || surfaceHeight <= 0) { + null + } else if (rotationDegrees % HALF_TURN == 0) { + surfaceWidth.toFloat() / surfaceHeight + } else { + surfaceHeight.toFloat() / surfaceWidth + } + +/** + * The aspect ratio of the recorded frame in the coordinates of the screen. The sensor image always runs along the + * same side of the phone; the rotation the video is recorded in only sets the flag in the file. The preview is + * drawn upright for the display, so when the recording rotation and the display rotation differ by a quarter turn + * (auto-rotate off with the phone held sideways, or the phone turned during the recording) the frame on the screen + * is the other way round than the recorded one. + * + * @param frameAspect width / height of the frame as the recording shows it + * @param videoRotation [android.view.Surface] rotation the video is recorded in + * @param displayRotation [android.view.Surface] rotation of the display + */ +fun screenFrameAspect(frameAspect: Float, videoRotation: Int, displayRotation: Int): Float = + if ((videoRotation - displayRotation + QUARTER_TURNS) % 2 == 1) 1f / frameAspect else frameAspect + +/** + * Aspect ratio of a recording before the camera has told its resolution: the recording is 16:9, upright in the + * rotation the video is recorded in ([android.view.Surface] ROTATION_0 and ROTATION_180 are portrait). + */ +fun fallbackVideoFrameAspect(surfaceRotation: Int): Float = + if (surfaceRotation == android.view.Surface.ROTATION_0 || surfaceRotation == android.view.Surface.ROTATION_180) { + PORTRAIT_ASPECT + } else { + LANDSCAPE_ASPECT + } + +private const val QUARTER_TURNS = 4 +private const val MARGIN_DIVISOR = 8 +private const val HALF_TURN = 180 +private const val DEFAULT_FRAME_ASPECT = 9f / 16f +private const val PORTRAIT_ASPECT = 9f / 16f +private const val LANDSCAPE_ASPECT = 16f / 9f diff --git a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 35d03c8dd4f..59facff7a78 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt @@ -7,6 +7,7 @@ */ package com.nextcloud.talk.chat.viewmodels +import android.app.Activity import android.content.Context import android.net.Uri import android.os.Bundle @@ -22,6 +23,8 @@ import com.google.gson.Gson import com.nextcloud.talk.R import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.arbitrarystorage.ArbitraryStorageManager +import com.nextcloud.talk.chat.RecordInputMode +import com.nextcloud.talk.chat.VideoMessageRecorder import com.nextcloud.talk.chat.data.ChatMessageRepository import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager @@ -478,7 +481,10 @@ class ChatViewModel @AssistedInject constructor( override fun onStop(owner: LifecycleOwner) { super.onStop(owner) currentLifeCycleFlag = LifeCycleFlag.STOPPED - mediaRecorderManager.handleOnStop() + // a rotation stops the activity but not the recording: the new activity goes on with it + if ((owner as? Activity)?.isChangingConfigurations != true) { + mediaRecorderManager.handleOnStop() + } chatRepository.handleOnStop() mediaPlayerManager.handleOnStop() } @@ -2264,6 +2270,7 @@ class ChatViewModel @AssistedInject constructor( private fun reactionLockFor(messageId: Int): Mutex = reactionLocks.getOrPut(messageId) { Mutex() } fun startAudioRecording(context: Context, currentConversation: ConversationModel) { + activeRecordingMode = RecordInputMode.VOICE audioFocusRequestManager.audioFocusRequest(true) { Log.d(TAG, "Recording Started") mediaRecorderManager.start(context, currentConversation) @@ -2296,6 +2303,64 @@ class ChatViewModel @AssistedInject constructor( } } + private var storedRecordInputMode: RecordInputMode? = null + + /** + * What the record button records while held. Persisted, so the choice survives leaving the chat. + */ + val recordInputMode: RecordInputMode + get() = storedRecordInputMode + ?: RecordInputMode.fromVideoFlag(appPreferences.videoRecordMode).also { storedRecordInputMode = it } + + /** + * What the recording that is currently in progress (or was last started) records. + */ + var activeRecordingMode: RecordInputMode = RecordInputMode.VOICE + private set + + fun toggleRecordInputMode(): RecordInputMode { + val newMode = recordInputMode.toggled() + storedRecordInputMode = newMode + appPreferences.videoRecordMode = newMode == RecordInputMode.VIDEO + return newMode + } + + /** + * @return true if the audio focus was granted and the recording state was set + */ + fun onVideoRecordingStarted(): Boolean { + activeRecordingMode = RecordInputMode.VIDEO + var granted = false + audioFocusRequestManager.audioFocusRequest(true) { + granted = true + _getVoiceRecordingInProgress.value = true + } + return granted + } + + private var videoRecorder: VideoMessageRecorder? = null + + /** + * The recorder of the video messages of this room. It lives here, not in the activity, so that it survives the + * recreation of the activity like the voice recorder does. + */ + fun videoMessageRecorder(context: Context): VideoMessageRecorder = + videoRecorder ?: VideoMessageRecorder(context).also { videoRecorder = it } + + val activeVideoMessageRecorder: VideoMessageRecorder? + get() = videoRecorder + + override fun onCleared() { + super.onCleared() + videoRecorder?.release() + videoRecorder = null + } + + fun onVideoRecordingEnded() { + audioFocusRequestManager.audioFocusRequest(false) {} + _getVoiceRecordingInProgress.value = false + } + fun stopAndDiscardAudioRecording() { stopAudioRecording() Log.d(TAG, "File discarded") diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java index 440a83f37ef..b9e6324e7bf 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferences.java @@ -211,5 +211,9 @@ public interface AppPreferences { long getConversationListLastUserId(); + boolean getVideoRecordMode(); + + void setVideoRecordMode(boolean value); + void clear(); } diff --git a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt index ccda4e3e032..3880a57752f 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/preferences/AppPreferencesImpl.kt @@ -626,6 +626,18 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { } } + override fun getVideoRecordMode(): Boolean = + runBlocking { + async { readBoolean(VIDEO_RECORD_MODE).first() } + }.getCompleted() + + override fun setVideoRecordMode(value: Boolean) = + runBlocking { + async { + writeBoolean(VIDEO_RECORD_MODE, value) + } + } + override fun clear() {} private suspend fun writeString(key: String, value: String) = @@ -708,6 +720,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { const val DB_ROOM_MIGRATED = "db_room_migrated" const val PHONE_BOOK_INTEGRATION_LAST_RUN = "phone_book_integration_last_run" const val TYPING_STATUS = "typing_status" + const val VIDEO_RECORD_MODE = "video_record_mode" const val MESSAGE_QUEUE = "@message_queue" const val PLAY_BACK = "_playback" const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds" diff --git a/app/src/main/res/drawable/bg_record_hint.xml b/app/src/main/res/drawable/bg_record_hint.xml new file mode 100644 index 00000000000..f3958f677d9 --- /dev/null +++ b/app/src/main/res/drawable/bg_record_hint.xml @@ -0,0 +1,12 @@ + + + + + + diff --git a/app/src/main/res/drawable/ic_record_hint_arrow.xml b/app/src/main/res/drawable/ic_record_hint_arrow.xml new file mode 100644 index 00000000000..cbcbb257a7d --- /dev/null +++ b/app/src/main/res/drawable/ic_record_hint_arrow.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/app/src/main/res/drawable/video_recording_dot.xml b/app/src/main/res/drawable/video_recording_dot.xml new file mode 100644 index 00000000000..5215a6ff422 --- /dev/null +++ b/app/src/main/res/drawable/video_recording_dot.xml @@ -0,0 +1,10 @@ + + + + + diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 16a10d70a5b..72522c2f22d 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -118,6 +118,47 @@ app:fabCustomSize="@dimen/min_size_clickable_area" app:srcCompat="@drawable/ic_lock_open_grey600_24dp" /> + + + + + + + + + + + + + + + + + + + + + android:src="@drawable/ic_baseline_mic_24" + app:tint="?attr/colorControlNormal" /> + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 3c80902ce09..4c5949b2314 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -101,4 +101,5 @@ #EF3B02 #DBE2E9 + #99000000 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index ba77a21a5e5..3ae7d9f45b9 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -18,6 +18,12 @@ 48dp 48dp + 480dp + + 2dp + 24dp + + 200dp 8dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ab569cb88a..e8020d3197a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -743,6 +743,14 @@ How to translate with transifex: Talk recording from %1$s (%2$s) Hold to record, release to send. Record voice message + Record video message + Hold to record, release to send. Tap to switch to video mode. + Hold to record video, release to send. Tap to switch to voice mode. + Video recording failed + Delete video recording + Send video recording + Permissions for camera and audio recording are required + Switch camera « Slide to cancel Play/pause voice message Play/pause audio file diff --git a/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt new file mode 100644 index 00000000000..feeb5cff55a --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt @@ -0,0 +1,100 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordButtonGestureTest { + + private val gesture = RecordButtonGesture(cancelX = -300f) + + @Test + fun releaseBeforeThresholdIsTapThatTogglesMode() { + gesture.down() + assertEquals(RecordButtonGesture.Release.TOGGLE_MODE, gesture.up()) + assertEquals(RecordButtonGesture.State.IDLE, gesture.state) + } + + @Test + fun releaseAfterThresholdDoesNotToggleMode() { + gesture.down() + assertTrue(gesture.holdElapsed()) + assertEquals(RecordButtonGesture.Release.NONE, gesture.up()) + } + + @Test + fun holdStartsRecordingOnlyOnce() { + gesture.down() + assertTrue(gesture.holdElapsed()) + assertFalse(gesture.holdElapsed()) + } + + @Test + fun holdTimerAfterReleaseDoesNotStartRecording() { + gesture.down() + gesture.up() + assertFalse(gesture.holdElapsed()) + } + + @Test + fun swipeLeftBeforeStartCancelsWithoutToggle() { + gesture.down() + assertFalse(gesture.move(-100f)) + assertTrue(gesture.move(-301f)) + assertFalse(gesture.holdElapsed()) + assertEquals(RecordButtonGesture.Release.NONE, gesture.up()) + } + + @Test + fun swipeAfterStartIsLeftToRecordingHandler() { + gesture.down() + gesture.holdElapsed() + assertFalse(gesture.move(-500f)) + assertEquals(RecordButtonGesture.State.HELD, gesture.state) + } + + @Test + fun systemCancelNeverToggles() { + gesture.down() + gesture.cancel() + assertEquals(RecordButtonGesture.Release.NONE, gesture.up()) + assertFalse(gesture.holdElapsed()) + } + + @Test + fun releaseWithoutDownDoesNothing() { + assertEquals(RecordButtonGesture.Release.NONE, gesture.up()) + } + + @Test + fun nextTouchStartsFresh() { + gesture.down() + gesture.holdElapsed() + gesture.up() + gesture.down() + assertEquals(RecordButtonGesture.Release.TOGGLE_MODE, gesture.up()) + } + + @Test + fun thresholdFollowsSystemLongPressWithinBounds() { + assertEquals(400L, RecordButtonGesture.holdThresholdMs(400)) + assertEquals(500L, RecordButtonGesture.holdThresholdMs(500)) + assertEquals(400L, RecordButtonGesture.holdThresholdMs(250)) + assertEquals(600L, RecordButtonGesture.holdThresholdMs(1500)) + } + + @Test + fun minimumDurationIsMeasuredFromTheRecordingStart() { + // touch at 0 ms, recording starts at 500 ms (hold threshold), released at 1300 ms: 800 ms recorded + assertTrue(RecordButtonGesture.isTooShort(startedAtMs = 500, endedAtMs = 1300, minMs = 1000)) + assertFalse(RecordButtonGesture.isTooShort(startedAtMs = 500, endedAtMs = 1500, minMs = 1000)) + assertFalse(RecordButtonGesture.isTooShort(startedAtMs = 500, endedAtMs = 2000, minMs = 1000)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/RecordHintPopupPlacementTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordHintPopupPlacementTest.kt new file mode 100644 index 00000000000..e6bc2584e0c --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/RecordHintPopupPlacementTest.kt @@ -0,0 +1,72 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RecordHintPopupPlacementTest { + + private fun place(anchorLeft: Int, anchorTop: Int = 1000, windowWidth: Int = 1000) = + RecordHintPopup.hintPlacement( + anchorLeft = anchorLeft, + anchorTop = anchorTop, + anchorWidth = 100, + windowWidth = windowWidth, + windowHeight = 2000, + hintWidth = 300, + margin = 20, + gap = 10, + arrowWidth = 40 + ) + + @Test + fun hintBottomIsTheGapAboveTheAnchorTop() { + val placement = place(anchorLeft = 450, anchorTop = 1000) + // measured from the bottom of the window: the bottom of the hint is 10 px above the top of the anchor + assertEquals(2000 - 1000 + 10, placement.bottomOffset) + } + + @Test + fun hintFollowsAnAnchorWhichMovedUp() { + val before = place(anchorLeft = 450, anchorTop = 1000) + val after = place(anchorLeft = 450, anchorTop = 900) + // a button moved up by 100 px takes the hint up by 100 px, however high the hint is + assertEquals(before.bottomOffset + 100, after.bottomOffset) + } + + @Test + fun hintIsCentredOnTheAnchor() { + val placement = place(anchorLeft = 450) + assertEquals(500 - 150, placement.x) + // the arrow is in the middle of the hint + assertEquals(150 - 20, placement.arrowLeftMargin) + } + + @Test + fun hintIsKeptInsideTheWindowAtTheRightEdge() { + val placement = place(anchorLeft = 900) + assertEquals(1000 - 20 - 300, placement.x) + // the arrow still points at the anchor centre (950) + assertEquals(950, placement.x + placement.arrowLeftMargin + 20) + } + + @Test + fun hintIsKeptInsideTheWindowAtTheLeftEdge() { + val placement = place(anchorLeft = 0) + assertEquals(20, placement.x) + assertEquals(50 - 20 - 20, placement.arrowLeftMargin) + } + + @Test + fun anchorInANarrowWindowIsStillPointedAt() { + // the chat in a pane of 600 px: only the window width matters, not the width of the screen + val placement = place(anchorLeft = 500, windowWidth = 600) + assertEquals(600 - 20 - 300, placement.x) + assertEquals(550, placement.x + placement.arrowLeftMargin + 20) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/RecordingStopCoordinatorTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordingStopCoordinatorTest.kt new file mode 100644 index 00000000000..a4c8d6f0fdd --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/RecordingStopCoordinatorTest.kt @@ -0,0 +1,141 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class RecordingStopCoordinatorTest { + + private class FakeScheduler : DelayScheduler { + var pending: Runnable? = null + var delay = -1L + + override fun postDelayed(runnable: Runnable, delayMs: Long) { + pending = runnable + delay = delayMs + } + + override fun cancel(runnable: Runnable) { + if (pending === runnable) pending = null + } + + /** The window ends. */ + fun fire() { + pending?.also { pending = null }?.run() + } + } + + private val scheduler = FakeScheduler() + private var stops = 0 + private val coordinator = RecordingStopCoordinator(scheduler) { stops++ } + + @Test + fun stopOutsideTheWindowIsIssuedAtOnce() { + assertTrue(coordinator.requestStop(StopAction.SEND)) + assertEquals(1, stops) + } + + @Test + fun stopInsideTheWindowWaitsAndIsIssuedWhenItEnds() { + assertTrue(coordinator.beginSwitch()) + assertEquals(RecordingStopCoordinator.SWITCH_SETTLE_MS, scheduler.delay) + + assertTrue(coordinator.requestStop(StopAction.SEND)) + assertEquals(0, stops) + + scheduler.fire() + + assertEquals(1, stops) + assertFalse(coordinator.settling) + } + + @Test + fun releaseInsideTheWindowDoesNotStopBeforeItEnds() { + coordinator.beginSwitch() + + // what release() does: cancel the recording, and the camera stays bound while the window is open + coordinator.requestStop(StopAction.DISCARD) + assertEquals(0, stops) + assertTrue(coordinator.settling) + + scheduler.fire() + + assertEquals(1, stops) + assertEquals(StopAction.DISCARD, coordinator.action) + } + + @Test + fun noStopIsIssuedWhileTheWindowIsOpenWhateverIsRequested() { + coordinator.beginSwitch() + coordinator.requestStop(StopAction.DISCARD) + coordinator.requestStop(StopAction.SEND) + coordinator.requestStop(StopAction.DISCARD) + assertEquals(0, stops) + } + + @Test + fun secondSwitchInsideTheWindowIsRefused() { + assertTrue(coordinator.beginSwitch()) + assertFalse(coordinator.beginSwitch()) + + scheduler.fire() + + assertTrue(coordinator.beginSwitch()) + } + + @Test + fun switchAfterAStopRequestIsRefused() { + coordinator.requestStop(StopAction.SEND) + assertFalse(coordinator.beginSwitch()) + } + + @Test + fun sendAfterCancelDoesNotSendTheCancelledVideo() { + assertTrue(coordinator.requestStop(StopAction.DISCARD)) + assertFalse(coordinator.requestStop(StopAction.SEND)) + + assertEquals(StopAction.DISCARD, coordinator.action) + assertEquals(1, stops) + } + + @Test + fun sendAfterCancelInsideTheWindowStaysCancelled() { + coordinator.beginSwitch() + coordinator.requestStop(StopAction.DISCARD) + coordinator.requestStop(StopAction.SEND) + + scheduler.fire() + + assertEquals(StopAction.DISCARD, coordinator.action) + assertEquals(1, stops) + } + + @Test + fun cancelAfterSendDoesNotDiscardTheVideoBeingSent() { + coordinator.requestStop(StopAction.SEND) + assertFalse(coordinator.requestStop(StopAction.DISCARD)) + assertEquals(StopAction.SEND, coordinator.action) + } + + @Test + fun resetClosesTheWindowAndForgetsTheRequest() { + coordinator.beginSwitch() + coordinator.requestStop(StopAction.SEND) + + coordinator.reset() + scheduler.fire() + + assertEquals(0, stops) + assertNull(coordinator.action) + assertFalse(coordinator.settling) + assertTrue(coordinator.requestStop(StopAction.SEND)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/ShouldVibrateOnRecordingChangeTest.kt b/app/src/test/java/com/nextcloud/talk/chat/ShouldVibrateOnRecordingChangeTest.kt new file mode 100644 index 00000000000..55f40b2f96d --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/ShouldVibrateOnRecordingChangeTest.kt @@ -0,0 +1,39 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import org.junit.Assert +import org.junit.Test + +class ShouldVibrateOnRecordingChangeTest { + + @Test + fun startingARecordingVibrates() { + Assert.assertTrue(shouldVibrateOnRecordingChange(false, true)) + } + + @Test + fun startingTheFirstRecordingVibrates() { + Assert.assertTrue(shouldVibrateOnRecordingChange(null, true)) + } + + @Test + fun endingARecordingVibrates() { + Assert.assertTrue(shouldVibrateOnRecordingChange(true, false)) + } + + @Test + fun runningRecordingShownAgainAfterRotationDoesNotVibrate() { + Assert.assertFalse(shouldVibrateOnRecordingChange(true, true)) + } + + @Test + fun idleStateShownAgainAfterRotationDoesNotVibrate() { + Assert.assertFalse(shouldVibrateOnRecordingChange(false, false)) + Assert.assertFalse(shouldVibrateOnRecordingChange(null, false)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt new file mode 100644 index 00000000000..17c91c18b08 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt @@ -0,0 +1,71 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Krainov Gleb + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import android.view.Surface +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class VideoFrameAspectTest { + + private val portrait = 9f / 16f + private val landscape = 16f / 9f + + @Test + fun frameAspectFromTheCameraSurface() { + // a sensor delivers 1280x720; turned a quarter it is a portrait frame + assertEquals(720f / 1280f, videoFrameAspect(1280, 720, 90)!!, 0.0001f) + assertEquals(720f / 1280f, videoFrameAspect(1280, 720, 270)!!, 0.0001f) + assertEquals(1280f / 720f, videoFrameAspect(1280, 720, 0)!!, 0.0001f) + assertEquals(1280f / 720f, videoFrameAspect(1280, 720, 180)!!, 0.0001f) + } + + @Test + fun frameAspectIsUnknownWithoutASize() { + assertNull(videoFrameAspect(0, 720, 90)) + assertNull(videoFrameAspect(1280, 0, 0)) + } + + @Test + fun fallbackAspectFollowsTheRecordingRotation() { + assertEquals(portrait, fallbackVideoFrameAspect(Surface.ROTATION_0), 0.0001f) + assertEquals(portrait, fallbackVideoFrameAspect(Surface.ROTATION_180), 0.0001f) + assertEquals(landscape, fallbackVideoFrameAspect(Surface.ROTATION_90), 0.0001f) + assertEquals(landscape, fallbackVideoFrameAspect(Surface.ROTATION_270), 0.0001f) + } + + @Test + fun screenAspectKeepsTheFrameWhenRotationsAgreeOrAreOpposite() { + val r = listOf(Surface.ROTATION_0, Surface.ROTATION_90, Surface.ROTATION_180, Surface.ROTATION_270) + r.forEach { rotation -> + assertEquals(portrait, screenFrameAspect(portrait, rotation, rotation), 0.0001f) + assertEquals(landscape, screenFrameAspect(landscape, rotation, rotation), 0.0001f) + val opposite = (rotation + 2) % 4 + assertEquals(portrait, screenFrameAspect(portrait, rotation, opposite), 0.0001f) + } + } + + @Test + fun screenAspectTurnsTheFrameOnAQuarterTurnDifference() { + // (video rotation, display rotation) pairs which differ by one or three quarters + val pairs = listOf( + Surface.ROTATION_90 to Surface.ROTATION_0, // auto-rotate off, phone held sideways + Surface.ROTATION_270 to Surface.ROTATION_0, + Surface.ROTATION_0 to Surface.ROTATION_90, // phone turned during the recording + Surface.ROTATION_0 to Surface.ROTATION_270, + Surface.ROTATION_180 to Surface.ROTATION_90, + Surface.ROTATION_90 to Surface.ROTATION_180, + Surface.ROTATION_270 to Surface.ROTATION_180, + Surface.ROTATION_180 to Surface.ROTATION_270 + ) + pairs.forEach { (video, display) -> + assertEquals("$video/$display", landscape, screenFrameAspect(portrait, video, display), 0.0001f) + assertEquals("$video/$display", portrait, screenFrameAspect(landscape, video, display), 0.0001f) + } + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoMessageRecordingTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoMessageRecordingTest.kt new file mode 100644 index 00000000000..ab4cdaa1651 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoMessageRecordingTest.kt @@ -0,0 +1,98 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import androidx.camera.video.VideoRecordEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class VideoMessageRecordingTest { + + @Test + fun toggleSwitchesBetweenVoiceAndVideo() { + assertEquals(RecordInputMode.VIDEO, RecordInputMode.VOICE.toggled()) + assertEquals(RecordInputMode.VOICE, RecordInputMode.VIDEO.toggled()) + assertEquals(RecordInputMode.VOICE, RecordInputMode.VOICE.toggled().toggled()) + } + + @Test + fun storedFlagMapsToMode() { + assertEquals(RecordInputMode.VIDEO, RecordInputMode.fromVideoFlag(true)) + assertEquals(RecordInputMode.VOICE, RecordInputMode.fromVideoFlag(false)) + } + + @Test + fun recordingWithoutErrorIsUsable() { + assertTrue(VideoMessageRecorder.isUsableFinalize(false, VideoRecordEvent.Finalize.ERROR_NONE)) + } + + @Test + fun recordingStoppedByLimitIsUsable() { + assertTrue( + VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED) + ) + assertTrue( + VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED) + ) + } + + @Test + fun recordingWithOtherErrorIsNotUsable() { + assertFalse(VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA)) + assertFalse(VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE)) + assertFalse(VideoMessageRecorder.isUsableFinalize(true, VideoRecordEvent.Finalize.ERROR_UNKNOWN)) + } + + private fun outcome(discard: Boolean, hasError: Boolean, error: Int, durationNanos: Long) = + VideoMessageRecorder.resolveOutcome(discard, hasError, error, durationNanos) + + @Test + fun discardedRecordingIsCancelledEvenWhenLongEnough() { + assertEquals( + VideoMessageRecorder.Outcome.CANCELLED, + outcome(true, false, VideoRecordEvent.Finalize.ERROR_NONE, 5_000_000_000L) + ) + } + + @Test + fun releasedBeforeAnyDataIsTooShort() { + assertEquals( + VideoMessageRecorder.Outcome.TOO_SHORT, + outcome(false, true, VideoRecordEvent.Finalize.ERROR_NO_VALID_DATA, 0L) + ) + } + + @Test + fun recordingShorterThanMinimumIsTooShort() { + assertEquals( + VideoMessageRecorder.Outcome.TOO_SHORT, + outcome(false, false, VideoRecordEvent.Finalize.ERROR_NONE, VideoMessageRecorder.MIN_DURATION_NANOS - 1) + ) + } + + @Test + fun cameraErrorIsFailure() { + assertEquals( + VideoMessageRecorder.Outcome.FAILED, + outcome(false, true, VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE, 5_000_000_000L) + ) + } + + @Test + fun longEnoughRecordingAndLimitHitAreSent() { + assertEquals( + VideoMessageRecorder.Outcome.SEND, + outcome(false, false, VideoRecordEvent.Finalize.ERROR_NONE, VideoMessageRecorder.MIN_DURATION_NANOS) + ) + assertEquals( + VideoMessageRecorder.Outcome.SEND, + outcome(false, true, VideoRecordEvent.Finalize.ERROR_DURATION_LIMIT_REACHED, 120_000_000_000L) + ) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementCompactPanelTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementCompactPanelTest.kt new file mode 100644 index 00000000000..584ae09f212 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementCompactPanelTest.kt @@ -0,0 +1,59 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Krainov Gleb + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import org.junit.Assert.assertEquals +import org.junit.Test + +/** Preview size for the one-line video recording panel on the screens of the owner. The values are pixels. */ +class VideoPreviewPlacementCompactPanelTest { + + private val portrait = 9f / 16f + private val landscape = 16f / 9f + private val limits = VideoPreviewLimits(margin = 16, maxWidthFraction = 0.75f, maxSide = 480) + + private fun place(areaWidth: Int, areaHeight: Int, aspect: Float, limits: VideoPreviewLimits = this.limits) = + videoPreviewPlacement(areaWidth, areaHeight, aspect, limits) + + // Owner screenshots, density 3.1, so the values below are pixels. Limits as ChatActivity builds them: margin 16dp, + // short area below 200dp keeps 8dp, longest side 480dp. The scrim begins at y = 321 px in both orientations. + private val density = 3.1f + private val phoneLimits = VideoPreviewLimits( + margin = (16 * density).toInt(), + maxWidthFraction = 0.75f, + maxSide = (480 * density).toInt(), + shortAreaHeight = (200 * density).toInt(), + shortMargin = (8 * density).toInt() + ) + + @Test + fun foldedHuaweiLandscapePreviewGrowsWithTheCompactPanel() { + // 1080 px high screen: scrim 321 px from the top, old panel 411 px, compact panel 64 dp = 198 px + val old = place(2444, 1080 - 321 - 411, landscape, phoneLimits.copy(shortAreaHeight = 0)) + assertEquals(VideoPreviewPlacement(width = 466, height = 262), old) // 150 x 84 dp + val compact = place(2444, 1080 - 321 - 198, landscape, phoneLimits) + assertEquals(VideoPreviewPlacement(width = 912, height = 513), compact) // 294 x 165 dp + assertEquals(landscape.toDouble(), compact.width.toDouble() / compact.height, 0.02) + } + + @Test + fun portraitOnOwnerPhoneIsBoundByTheWidthSoTheCompactPanelDoesNotChangeIt() { + // 348 dp = 1080 px wide, 2444 px high screen: the area grows from 1712 to 1925 px, the width stays the limit + val old = place(1080, 2444 - 321 - 411, portrait, phoneLimits) + val compact = place(1080, 2444 - 321 - 198, portrait, phoneLimits) + assertEquals(VideoPreviewPlacement(width = 810, height = 1440), old) // 261 x 464 dp + assertEquals(old, compact) + } + + @Test + fun shortAreaKeepsTheSmallerMarginAndATallAreaKeepsTheFullOne() { + val tall = place(1000, 400, portrait, limits.copy(shortAreaHeight = 200, shortMargin = 8)) + assertEquals(368, tall.height) // 400 is not below 200: full margin 16 + val low = place(1000, 190, portrait, limits.copy(shortAreaHeight = 200, shortMargin = 8)) + assertEquals(174, low.height) // 190 - 2 * 8 + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt new file mode 100644 index 00000000000..c88301fb805 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt @@ -0,0 +1,133 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Krainov Gleb + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The values are dp: the function does not care for the unit. */ +class VideoPreviewPlacementTest { + + private val portrait = 9f / 16f + private val landscape = 16f / 9f + private val limits = VideoPreviewLimits(margin = 16, maxWidthFraction = 0.75f, maxSide = 480) + + private fun place(areaWidth: Int, areaHeight: Int, aspect: Float, limits: VideoPreviewLimits = this.limits) = + videoPreviewPlacement(areaWidth, areaHeight, aspect, limits) + + /** Centered by the parent: the preview fits the area with the margin kept on every side. */ + private fun assertInside(p: VideoPreviewPlacement, areaWidth: Int, areaHeight: Int) { + assertTrue("width ${p.width}", p.width in 0..areaWidth) + assertTrue("height ${p.height}", p.height in 0..areaHeight) + } + + private fun assertAspect(p: VideoPreviewPlacement, aspect: Float) { + assertEquals(aspect.toDouble(), p.width.toDouble() / p.height, 0.02) + } + + @Test + fun portraitOnOwnerPhoneTakesThreeQuartersOfTheWidthAndKeepsTheAspect() { + val p = place(348, 560, portrait) + assertEquals(261, p.width) + assertEquals(464, p.height) + assertAspect(p, portrait) + assertInside(p, 348, 560) + } + + @Test + fun portraitOnAShortAreaIsFittedByTheHeightWithMargins() { + val p = place(348, 400, portrait) + // the height minus the margins: 400 - 2 * 16 + assertEquals(368, p.height) + assertEquals(207, p.width) + assertAspect(p, portrait) + assertTrue(p.height <= 400 - 32) + } + + @Test + fun landscapeInALandscapeAreaIsFittedByTheHeightAndNotSquashed() { + val p = place(700, 200, landscape) + assertEquals(168, p.height) + assertEquals(299, p.width) + assertAspect(p, landscape) + assertTrue(p.height <= 200 - 32) + assertTrue(p.width <= 700 - 32) + assertInside(p, 700, 200) + } + + @Test + fun landscapeFrameInAPortraitAreaIsFittedByTheWidth() { + val p = place(348, 560, landscape) + assertEquals(261, p.width) + assertEquals(147, p.height) + assertAspect(p, landscape) + } + + @Test + fun narrowPanelOfTwoPanesKeepsTheMarginAndTheAspect() { + val p = place(240, 520, portrait) + // 75 % of 240; 240 - 2 * 16 = 208 would be wider + assertEquals(180, p.width) + assertEquals(320, p.height) + assertAspect(p, portrait) + assertTrue(p.width <= 240 - 32) + assertInside(p, 240, 520) + } + + @Test + fun narrowPanelWithALandscapeFrameIsLimitedByTheMarginedWidth() { + val p = place(200, 520, landscape, limits.copy(maxWidthFraction = 0.95f)) + // 200 - 2 * 16 = 168 is less than 95 % of 200 + assertEquals(168, p.width) + assertAspect(p, landscape) + assertTrue(p.width <= 200 - 32) + } + + @Test + fun veryLowAreaStillGivesAPositivePreviewInsideTheArea() { + val p = place(348, 40, portrait) + assertTrue(p.height in 1..40) + assertTrue(p.width > 0) + assertAspect(p, portrait) + assertInside(p, 348, 40) + } + + @Test + fun veryLowAreaWithALandscapeFrameStaysInside() { + val p = place(700, 24, landscape) + assertTrue(p.height > 0) + assertAspect(p, landscape) + assertInside(p, 700, 24) + } + + @Test + fun emptyAreaGivesAnEmptyPreview() { + val p = place(0, 0, portrait) + assertEquals(VideoPreviewPlacement(0, 0), p) + assertEquals(0, place(-5, 100, portrait).width) + } + + @Test + fun hugeAreaIsCappedByTheLongestSide() { + val p = place(1200, 1000, portrait) + assertEquals(480, p.height) + assertEquals(270, p.width) + assertAspect(p, portrait) + assertInside(p, 1200, 1000) + val l = place(2000, 1000, landscape) + assertEquals(480, l.width) + assertEquals(270, l.height) + } + + @Test + fun invalidAspectFallsBackToPortrait() { + assertEquals(place(348, 560, portrait), place(348, 560, Float.NaN)) + assertEquals(place(348, 560, portrait), place(348, 560, 0f)) + assertEquals(place(348, 560, portrait), place(348, 560, -1f)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoRecorderLifecycleTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoRecorderLifecycleTest.kt new file mode 100644 index 00000000000..c4ec2ec9df6 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoRecorderLifecycleTest.kt @@ -0,0 +1,45 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import androidx.lifecycle.Lifecycle +import org.junit.Assert.assertEquals +import org.junit.Test + +class VideoRecorderLifecycleTest { + + @Test + fun cameraLifecycleStartsWithTheRecordingAndEndsWithIt() { + val owner = RecordingLifecycleOwner() + assertEquals(Lifecycle.State.INITIALIZED, owner.lifecycle.currentState) + + owner.start() + assertEquals(Lifecycle.State.STARTED, owner.lifecycle.currentState) + + owner.destroy() + assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState) + } + + @Test + fun destroyedCameraLifecycleCannotBeStartedAgain() { + val owner = RecordingLifecycleOwner() + owner.start() + owner.destroy() + + owner.start() + + assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState) + } + + @Test + fun destroyingTwiceIsHarmless() { + val owner = RecordingLifecycleOwner() + owner.destroy() + owner.destroy() + assertEquals(Lifecycle.State.DESTROYED, owner.lifecycle.currentState) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt new file mode 100644 index 00000000000..6f4e9650aad --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt @@ -0,0 +1,112 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.chat + +import android.view.OrientationEventListener +import android.view.Surface +import androidx.camera.video.VideoRecordEvent +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class VideoRecordingRecreationTest { + + private fun resume(active: Boolean, pending: Boolean, inProgress: Boolean, locked: Boolean) = + resolveRecordingResume(active, pending, inProgress, locked) + + @Test + fun nothingRecordingMeansNothingToResume() { + assertEquals(RecordingResume.NONE, resume(false, false, false, false)) + } + + @Test + fun lockedVideoRecordingIsAttachedAgain() { + assertEquals(RecordingResume.ATTACH, resume(true, false, true, true)) + } + + @Test + fun heldVideoRecordingIsLockedBecauseTheFingerIsGone() { + assertEquals(RecordingResume.ATTACH_AND_LOCK, resume(true, false, true, false)) + } + + @Test + fun heldVoiceRecordingIsLockedToo() { + assertEquals(RecordingResume.ATTACH_AND_LOCK, resume(false, false, true, false)) + } + + @Test + fun lockedVoiceRecordingNeedsNoRecorder() { + assertEquals(RecordingResume.NONE, resume(false, false, true, true)) + } + + @Test + fun resultWhichArrivedWithoutActivityIsDelivered() { + assertEquals(RecordingResume.DELIVER_RESULT, resume(false, true, true, true)) + } + + @Test + fun resultIsDeferredUntilTheChatIsLoaded() { + assertEquals( + RecordingResume.DEFER_RESULT, + resolveRecordingResume(false, true, true, true, chatReady = false) + ) + assertEquals( + RecordingResume.DELIVER_RESULT, + resolveRecordingResume(false, true, true, true, chatReady = true) + ) + } + + @Test + fun activeRecordingIsAttachedEvenIfTheChatIsNotLoaded() { + assertEquals(RecordingResume.ATTACH, resolveRecordingResume(true, false, true, true, chatReady = false)) + } + + @Test + fun videoRotationFollowsTheSensor() { + // phone turned clockwise by 90 degrees: the display rotates the other way + assertEquals(Surface.ROTATION_270, VideoMessageRecorder.videoTargetRotation(90, Surface.ROTATION_0)) + assertEquals(Surface.ROTATION_0, VideoMessageRecorder.videoTargetRotation(10, Surface.ROTATION_90)) + assertEquals(Surface.ROTATION_180, VideoMessageRecorder.videoTargetRotation(180, Surface.ROTATION_0)) + assertEquals(Surface.ROTATION_90, VideoMessageRecorder.videoTargetRotation(270, Surface.ROTATION_0)) + } + + @Test + fun videoRotationFallsBackToTheDisplayWithoutSensorValue() { + assertEquals( + Surface.ROTATION_90, + VideoMessageRecorder.videoTargetRotation(OrientationEventListener.ORIENTATION_UNKNOWN, Surface.ROTATION_90) + ) + } + + @Test + fun cutOffRecordingIsKeptWhenLongEnough() { + val inactive = VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE + assertTrue(VideoMessageRecorder.isSalvageable(inactive, VideoMessageRecorder.MIN_DURATION_NANOS, 1024)) + assertFalse(VideoMessageRecorder.isSalvageable(inactive, VideoMessageRecorder.MIN_DURATION_NANOS - 1, 1024)) + assertFalse(VideoMessageRecorder.isSalvageable(inactive, VideoMessageRecorder.MIN_DURATION_NANOS, 0)) + assertFalse( + VideoMessageRecorder.isSalvageable( + VideoRecordEvent.Finalize.ERROR_ENCODING_FAILED, + VideoMessageRecorder.MIN_DURATION_NANOS, + 1024 + ) + ) + } + + @Test + fun activeVideoRecorderClearsStateOnlyOnFinalize() { + assertTrue(clearsRecordingStateOnFinalize(isVideo = true, recorderActive = true)) + } + + @Test + fun idleVideoRecorderAndAudioAreClearedAtOnce() { + assertFalse(clearsRecordingStateOnFinalize(isVideo = true, recorderActive = false)) + assertFalse(clearsRecordingStateOnFinalize(isVideo = false, recorderActive = false)) + assertFalse(clearsRecordingStateOnFinalize(isVideo = false, recorderActive = true)) + } +}