From 79f9aca35dc9a98cc937ee9db718a75354975d2a Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:17:28 +0000 Subject: [PATCH 01/18] fix(chat): do not vibrate when the voice recording is only shown again The observer of the recording state is registered again when the chat activity is recreated, for example on a screen rotation. LiveData then delivers the current value to it and the device vibrated as if a recording had just started or ended. Vibrate only when the state differs from the one the activity already knew. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 13 ++++++- .../ShouldVibrateOnRecordingChangeTest.kt | 39 +++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 app/src/test/java/com/nextcloud/talk/chat/ShouldVibrateOnRecordingChangeTest.kt 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..ac94f733261 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1891,8 +1891,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 { @@ -4363,3 +4368,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/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)) + } +} From 95e7c859bd9d2ed6452954ba73c0dcbf6750daf1 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:19:36 +0000 Subject: [PATCH 02/18] fix(chat): keep a voice recording running over a screen rotation A rotation stops the chat activity. ChatViewModel.onStop then stopped the MediaRecorder, while the locked and in-progress state of the recording stayed. The screen still showed a running recording and a truncated file was sent. The view model now keeps the recorder running when the stopping activity is changing its configuration. It reads this from the lifecycle owner: ON_STOP reaches the observer before the body of Activity.onStop runs (API 29+), so a flag set by the activity in onStop would come too late. The recorder is still stopped when the user leaves the chat. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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..6d3215cd33c 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 @@ -478,7 +479,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() } From 1ccf96dc2e82828847ccbe67c82236472f6f8423 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:19:52 +0000 Subject: [PATCH 03/18] feat(chat): record video messages from the message input A short tap on the record button switches between voice and video mode. The mode is stored in AppPreferences and shown by the button icon and a snackbar hint. The result is a regular video attachment. In video mode holding the button records with CameraX (front camera, 720p with fallback, about 2.5 Mbit/s, at most 120 s, then it stops and is sent). Sliding left cancels, sliding up locks. The gestures, the lock state, the timer and the locked recording view are shared with the voice recording. The preview is shown above the input and the camera can be switched during the recording. An unfinished recording is cancelled and the camera released when the chat is paused. Too short recordings show a hint, camera errors a message. The finished mp4 goes through the regular file upload. Refs #6812 Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- app/build.gradle.kts | 7 + .../com/nextcloud/talk/chat/ChatActivity.kt | 153 ++++++++-- .../talk/chat/MessageInputFragment.kt | 160 +++++++++-- .../MessageInputVoiceRecordingFragment.kt | 27 +- .../nextcloud/talk/chat/RecordInputMode.kt | 22 ++ .../talk/chat/VideoMessageRecorder.kt | 271 ++++++++++++++++++ .../talk/chat/viewmodels/ChatViewModel.kt | 42 +++ .../utils/preferences/AppPreferences.java | 4 + .../utils/preferences/AppPreferencesImpl.kt | 13 + app/src/main/res/layout/activity_chat.xml | 28 ++ .../main/res/layout/view_message_input.xml | 3 +- app/src/main/res/values/dimens.xml | 2 + app/src/main/res/values/strings.xml | 6 + .../talk/chat/VideoMessageRecordingTest.kt | 98 +++++++ 14 files changed, 769 insertions(+), 67 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/RecordInputMode.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/VideoMessageRecordingTest.kt 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/ChatActivity.kt b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt index ac94f733261..3065891711e 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 @@ -39,6 +40,7 @@ import android.util.Log import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup +import android.view.ViewOutlineProvider import android.view.WindowManager import android.widget.PopupWindow import android.widget.TextView @@ -50,6 +52,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 +272,8 @@ class ChatActivity : private lateinit var binding: ActivityChatBinding + private var videoMessageRecorder: VideoMessageRecorder? = null + @Inject lateinit var ncApi: NcApi @@ -2464,6 +2469,111 @@ 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 = videoMessageRecorder + ?: VideoMessageRecorder(this, this, binding.videoRecordingPreview, ::onVideoRecordingFinished) + .also { videoMessageRecorder = it } + 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.start(file) + return true + } + + fun stopAndSendRecording() { + if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { + videoMessageRecorder?.stopAndSend() + } else { + chatViewModel.stopAndSendAudioRecording( + roomToken = roomToken, + replyToMessageId = getReplyToMessageId(), + displayName = currentConversation!!.displayName + ) + } + } + + fun stopAndDiscardRecording() { + if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { + videoMessageRecorder?.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.CANCELLED -> Unit + } + } + + private fun showVideoRecordingPreview(show: Boolean) { + if (show) { + 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 { videoMessageRecorder?.switchCamera() } + } + binding.videoRecordingContainer.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( @@ -2817,6 +2927,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 @@ -2999,6 +3117,8 @@ class ChatActivity : override fun onPause() { super.onPause() + videoMessageRecorder?.cancel() + logConversationInfos("onPause") eventBus.unregister(this) @@ -4111,21 +4231,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) @@ -4143,21 +4249,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) @@ -4318,6 +4410,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" 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..1919a101afc 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 @@ -38,6 +40,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 @@ -131,6 +134,10 @@ class MessageInputFragment : Fragment() { private lateinit var spreedCapabilities: SpreedCapabilityDto private var hasSharedText = false + private val recordButtonHandler = Handler(Looper.getMainLooper()) + private var pendingVideoRecordStart: Runnable? = null + private var recordingUiShown = false + private var lastQuotedJsonId: Int? = null private var lastEditMessageId: Int? = null private var lastIsThreadCreationInProgress: Boolean = false @@ -155,8 +162,14 @@ class MessageInputFragment : Fragment() { return binding.root } + override fun onPause() { + cancelPendingVideoRecordStart() + super.onPause() + } + override fun onDestroyView() { restoreKeyboardOnEmojiDismiss = false + cancelPendingVideoRecordStart() super.onDestroyView() if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() @@ -275,6 +288,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,6 +603,7 @@ class MessageInputFragment : Fragment() { @Suppress("ClickableViewAccessibility", "CyclomaticComplexMethod", "LongMethod") private fun initVoiceRecordButton() { handleButtonsVisibility() + updateRecordButtonMode() var prevDx = 0f var voiceRecordStartTime = 0L @@ -591,7 +612,12 @@ class MessageInputFragment : Fragment() { v?.performClick() when (event?.action) { MotionEvent.ACTION_DOWN -> { - if (!chatActivity.isRecordAudioPermissionGranted()) { + val isVideo = chatActivity.chatViewModel.recordInputMode == RecordInputMode.VIDEO + if (isVideo && !chatActivity.isVideoRecordPermissionGranted()) { + chatActivity.requestVideoRecordPermissions() + return@setOnTouchListener true + } + if (!isVideo && !chatActivity.isRecordAudioPermissionGranted()) { chatActivity.requestRecordAudioPermissions() return@setOnTouchListener true } @@ -600,69 +626,76 @@ class MessageInputFragment : Fragment() { 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) prevDx = event.x + if (isVideo) { + scheduleVideoRecordStart() + } else { + beginRecording(false) + } } MotionEvent.ACTION_CANCEL -> { Log.d(TAG, "ACTION_CANCEL") - if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value == false || - !chatActivity.isRecordAudioPermissionGranted() + if (cancelPendingVideoRecordStart() || + 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 || + if (cancelPendingVideoRecordStart()) { + toggleRecordInputMode() + return@setOnTouchListener true + } + if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true || chatActivity.chatViewModel.getVoiceRecordingLocked.value == true || - !chatActivity.isRecordAudioPermissionGranted() + !isActiveRecordingPermissionGranted() ) { return@setOnTouchListener false } showRecordAudioUi(false) + val isVideo = chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO 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() + if (isVideo) { + chatActivity.stopAndSendRecording() + } else if (voiceRecordDuration < RECORD_BUTTON_TAP_DURATION) { + chatActivity.stopAndDiscardRecording() + toggleRecordInputMode() + return@setOnTouchListener false + } else if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) { + 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 (pendingVideoRecordStart != null) { + cancelPendingVideoRecordStartOnSlide(event.x) + 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 +722,77 @@ class MessageInputFragment : Fragment() { } } + private fun beginRecording(isVideo: Boolean) { + val base = SystemClock.elapsedRealtime() + if (isVideo) { + if (!chatActivity.startVideoRecording()) return + } else { + chatActivity.chatViewModel.startAudioRecording(requireContext(), chatActivity.currentConversation!!) + } + binding.fragmentMessageInputView.audioRecordDuration.base = base + messageInputViewModel.setRecordingTime(base) + binding.fragmentMessageInputView.audioRecordDuration.start() + showRecordAudioUi(true) + } + + /** + * The camera is only opened after a short hold, so that a tap on the record button can switch the mode without + * flashing the camera preview. + */ + private fun scheduleVideoRecordStart() { + val runnable = Runnable { + pendingVideoRecordStart = null + beginRecording(true) + } + pendingVideoRecordStart = runnable + recordButtonHandler.postDelayed(runnable, RECORD_BUTTON_TAP_DURATION) + } + + private fun cancelPendingVideoRecordStart(): Boolean { + val runnable = pendingVideoRecordStart ?: return false + recordButtonHandler.removeCallbacks(runnable) + pendingVideoRecordStart = null + return true + } + + private fun cancelPendingVideoRecordStartOnSlide(x: Float): Boolean = + x < VOICE_RECORD_CANCEL_SLIDER_X && cancelPendingVideoRecordStart() + + 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) { + Snackbar.make(binding.root, requireContext().getString(messageRes), Snackbar.LENGTH_SHORT).show() + } + + 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 +924,7 @@ class MessageInputFragment : Fragment() { } private fun showRecordAudioUi(show: Boolean) { + recordingUiShown = show if (show) { restoreKeyboardOnEmojiDismiss = false binding.emojiPicker.isVisible = false @@ -1328,6 +1433,7 @@ class MessageInputFragment : Fragment() { private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 + private const val RECORD_BUTTON_TAP_DURATION: Long = 300 private const val ANIMATION_DURATION: Long = 750 private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -300 private const val VOICE_RECORD_LOCK_THRESHOLD: Float = 100f 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..fa5c3d04042 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -75,6 +75,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 +117,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()) { + binding.micInputCloud.visibility = View.INVISIBLE + } else { + binding.micInputCloud.setOnClickListener { + togglePreviewVisibility() + } } binding.playPauseBtn.setOnClickListener { @@ -155,10 +158,16 @@ class MessageInputVoiceRecordingFragment : Fragment() { }) } + private fun isVideoRecording() = chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO + private fun clear() { chatActivity.chatViewModel.setVoiceRecordingLocked(false) messageInputViewModel.stopMicInput() - chatActivity.chatViewModel.stopAudioRecording() + if (isVideoRecording()) { + 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/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/VideoMessageRecorder.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt new file mode 100644 index 00000000000..184ed7b0e8d --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -0,0 +1,271 @@ +/* + * 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.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.LifecycleOwner +import java.io.File + +/** + * Records a plain mp4 video with CameraX and shows a live preview while doing so. + * + * All methods must be called on the main thread. [onFinished] is called exactly once per [start] on the main thread: + * with [Outcome.SEND] and the file to send, 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( + private val context: Context, + private val lifecycleOwner: LifecycleOwner, + private val previewView: PreviewView, + private val onFinished: (Outcome, File?) -> Unit +) { + + enum class Outcome { SEND, CANCELLED, TOO_SHORT, FAILED } + + private enum class State { IDLE, STARTING, RECORDING } + private enum class StopAction { SEND, DISCARD } + + private var state = State.IDLE + private var stopAction: StopAction? = null + 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 + + val isActive: Boolean + get() = state != State.IDLE + + fun start(file: File) { + check(state == State.IDLE) { "Video recording already active" } + state = State.STARTING + session++ + val startedSession = session + stopAction = null + outputFile = file + lensFacing = CameraSelector.LENS_FACING_FRONT + + val providerFuture = ProcessCameraProvider.getInstance(context) + providerFuture.addListener({ + if (startedSession != session) return@addListener + try { + onCameraProviderReady(providerFuture.get()) + } catch (e: java.util.concurrent.ExecutionException) { + Log.e(TAG, "camera provider is not available", e) + finish(Outcome.FAILED, null) + } + }, ContextCompat.getMainExecutor(context)) + } + + 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. + */ + fun switchCamera() { + val provider = cameraProvider + if (state == State.IDLE || provider == null) return + + val newLens = if (lensFacing == CameraSelector.LENS_FACING_FRONT) { + CameraSelector.LENS_FACING_BACK + } else { + CameraSelector.LENS_FACING_FRONT + } + if (!provider.hasCamera(selectorFor(newLens))) return + + val previousLens = lensFacing + lensFacing = newLens + provider.unbind(preview, videoCapture) + if (!bindUseCases(provider)) { + lensFacing = previousLens + bindUseCases(provider) + } + } + + private fun stop(action: StopAction) { + if (stopAction == StopAction.SEND) return + when (state) { + State.IDLE -> Unit + + State.STARTING -> { + session++ + finish(if (action == StopAction.SEND) Outcome.TOO_SHORT else Outcome.CANCELLED, null) + } + + State.RECORDING -> { + stopAction = action + recording?.stop() + if (action == StopAction.DISCARD) { + releaseCamera() + } + } + } + } + + private fun onCameraProviderReady(provider: ProcessCameraProvider) { + if (state != State.STARTING) { + return + } + cameraProvider = provider + + val recorder = Recorder.Builder() + .setQualitySelector( + QualitySelector.from(Quality.HD, FallbackStrategy.lowerQualityOrHigherThan(Quality.HD)) + ) + .setTargetVideoEncodingBitRate(TARGET_VIDEO_BIT_RATE) + .build() + val rotation = previewView.display?.rotation ?: Surface.ROTATION_0 + videoCapture = VideoCapture.Builder(recorder).setTargetRotation(rotation).build() + preview = Preview.Builder().setTargetRotation(rotation).build() + + if (!bindUseCases(provider)) { + finish(Outcome.FAILED, null) + return + } + startRecording() + } + + private fun bindUseCases(provider: ProcessCameraProvider): Boolean { + var selector = selectorFor(lensFacing) + if (!provider.hasCamera(selector)) { + lensFacing = if (lensFacing == CameraSelector.LENS_FACING_FRONT) { + CameraSelector.LENS_FACING_BACK + } else { + CameraSelector.LENS_FACING_FRONT + } + selector = selectorFor(lensFacing) + } + val previewUseCase = preview + val videoUseCase = videoCapture + return try { + requireNotNull(previewUseCase).surfaceProvider = previewView.surfaceProvider + provider.bindToLifecycle(lifecycleOwner, selector, previewUseCase, requireNotNull(videoUseCase)) + true + } catch (e: IllegalArgumentException) { + Log.e(TAG, "cannot bind camera use cases", e) + false + } catch (e: IllegalStateException) { + Log.e(TAG, "cannot bind camera use cases", e) + false + } + } + + @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 outcome = resolveOutcome( + discardRequested = stopAction == StopAction.DISCARD, + hasError = event.hasError(), + error = event.error, + recordedDurationNanos = event.recordingStats.recordedDurationNanos + ) + if (event.hasError()) { + Log.w(TAG, "recording finalized with error ${event.error}, outcome: $outcome") + } + finish(outcome, if (outcome == Outcome.SEND) outputFile else null) + } + } + + private fun finish(outcome: Outcome, fileToSend: File?) { + val file = outputFile + releaseCamera() + recording = null + outputFile = null + stopAction = null + state = State.IDLE + if (fileToSend == null) { + file?.delete() + } + onFinished(outcome, fileToSend) + } + + private fun releaseCamera() { + cameraProvider?.unbind(preview, videoCapture) + } + + private fun selectorFor(lens: Int): CameraSelector = CameraSelector.Builder().requireLensFacing(lens).build() + + 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 + } + } +} 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 6d3215cd33c..6d4c8c7fa78 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 @@ -23,6 +23,7 @@ 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.data.ChatMessageRepository import com.nextcloud.talk.chat.data.io.AudioFocusRequestManager import com.nextcloud.talk.chat.data.io.MediaPlayerManager @@ -2268,6 +2269,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) @@ -2300,6 +2302,46 @@ 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 + } + + 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/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 16a10d70a5b..a7d1b881a43 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -118,6 +118,34 @@ 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" /> 48dp 48dp + 180dp + 240dp 8dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8ab569cb88a..03a57300363 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -743,6 +743,12 @@ 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 + 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/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) + ) + } +} From 6ad6592b2f997dda4a5c72fc434542264b9700a1 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:19:58 +0000 Subject: [PATCH 04/18] fix(chat): start recording only on hold and show the record hint as a popup The record button used to start a recording on the first touch, so a short tap that should switch the mode started a recording first. Recording now starts only when the button is held. A tap shows the record hint as a popup above the button instead of a snackbar, placed in the window of the button and kept above it after layout changes. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/chat/MessageInputFragment.kt | 92 +++++----- .../talk/chat/RecordButtonGesture.kt | 69 ++++++++ .../nextcloud/talk/chat/RecordHintPopup.kt | 165 ++++++++++++++++++ app/src/main/res/drawable/bg_record_hint.xml | 12 ++ .../res/drawable/ic_record_hint_arrow.xml | 16 ++ app/src/main/res/layout/view_record_hint.xml | 29 +++ .../talk/chat/RecordButtonGestureTest.kt | 92 ++++++++++ .../talk/chat/RecordHintPopupPlacementTest.kt | 72 ++++++++ 8 files changed, 501 insertions(+), 46 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/RecordHintPopup.kt create mode 100644 app/src/main/res/drawable/bg_record_hint.xml create mode 100644 app/src/main/res/drawable/ic_record_hint_arrow.xml create mode 100644 app/src/main/res/layout/view_record_hint.xml create mode 100644 app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/RecordHintPopupPlacementTest.kt 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 1919a101afc..e2f3fd8b380 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -27,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 @@ -58,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 @@ -135,7 +135,8 @@ class MessageInputFragment : Fragment() { private var hasSharedText = false private val recordButtonHandler = Handler(Looper.getMainLooper()) - private var pendingVideoRecordStart: Runnable? = null + private var pendingRecordStart: Runnable? = null + private var recordHintPopup: RecordHintPopup? = null private var recordingUiShown = false private var lastQuotedJsonId: Int? = null @@ -163,13 +164,15 @@ class MessageInputFragment : Fragment() { } override fun onPause() { - cancelPendingVideoRecordStart() + cancelPendingRecordStart() super.onPause() } override fun onDestroyView() { restoreKeyboardOnEmojiDismiss = false - cancelPendingVideoRecordStart() + cancelPendingRecordStart() + recordHintPopup?.dismiss() + recordHintPopup = null super.onDestroyView() if (mentionAutocomplete != null && mentionAutocomplete!!.isPopupShowing) { mentionAutocomplete?.dismissPopup() @@ -608,36 +611,23 @@ class MessageInputFragment : Fragment() { 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 -> { - val isVideo = chatActivity.chatViewModel.recordInputMode == RecordInputMode.VIDEO - if (isVideo && !chatActivity.isVideoRecordPermissionGranted()) { - chatActivity.requestVideoRecordPermissions() - return@setOnTouchListener true - } - if (!isVideo && !chatActivity.isRecordAudioPermissionGranted()) { - chatActivity.requestRecordAudioPermissions() - return@setOnTouchListener true - } - if (!chatActivity.permissionUtil.isFilesPermissionGranted()) { - UploadAndShareFilesWorker.requestStoragePermission(chatActivity) - return@setOnTouchListener true - } - + // A tap only switches the mode, the recording starts only after the hold threshold. + recordHintPopup?.dismiss() voiceRecordStartTime = System.currentTimeMillis() prevDx = event.x - if (isVideo) { - scheduleVideoRecordStart() - } else { - beginRecording(false) - } + gesture.down() + scheduleRecordStart(gesture) } MotionEvent.ACTION_CANCEL -> { Log.d(TAG, "ACTION_CANCEL") - if (cancelPendingVideoRecordStart() || + gesture.cancel() + if (cancelPendingRecordStart() || chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true || !isActiveRecordingPermissionGranted() ) { @@ -652,7 +642,8 @@ class MessageInputFragment : Fragment() { MotionEvent.ACTION_UP -> { Log.d(TAG, "ACTION_UP") - if (cancelPendingVideoRecordStart()) { + val release = gesture.up() + if (cancelPendingRecordStart() && release == RecordButtonGesture.Release.TOGGLE_MODE) { toggleRecordInputMode() return@setOnTouchListener true } @@ -669,10 +660,6 @@ class MessageInputFragment : Fragment() { val voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime if (isVideo) { chatActivity.stopAndSendRecording() - } else if (voiceRecordDuration < RECORD_BUTTON_TAP_DURATION) { - chatActivity.stopAndDiscardRecording() - toggleRecordInputMode() - return@setOnTouchListener false } else if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) { showRecordHint(R.string.nc_voice_message_hold_to_record_info) chatActivity.stopAndDiscardRecording() @@ -684,8 +671,8 @@ class MessageInputFragment : Fragment() { } MotionEvent.ACTION_MOVE -> { - if (pendingVideoRecordStart != null) { - cancelPendingVideoRecordStartOnSlide(event.x) + if (gesture.isPending) { + if (gesture.move(event.x)) cancelPendingRecordStart() return@setOnTouchListener true } if (chatActivity.chatViewModel.getVoiceRecordingInProgress.value != true || @@ -736,28 +723,40 @@ class MessageInputFragment : Fragment() { } /** - * The camera is only opened after a short hold, so that a tap on the record button can switch the mode without - * flashing the camera preview. + * 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 scheduleVideoRecordStart() { + private fun scheduleRecordStart(gesture: RecordButtonGesture) { + cancelPendingRecordStart() val runnable = Runnable { - pendingVideoRecordStart = null - beginRecording(true) + 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) + } } - pendingVideoRecordStart = runnable - recordButtonHandler.postDelayed(runnable, RECORD_BUTTON_TAP_DURATION) + pendingRecordStart = runnable + recordButtonHandler.postDelayed( + runnable, + RecordButtonGesture.holdThresholdMs(ViewConfiguration.getLongPressTimeout()) + ) } - private fun cancelPendingVideoRecordStart(): Boolean { - val runnable = pendingVideoRecordStart ?: return false + private fun cancelPendingRecordStart(): Boolean { + val runnable = pendingRecordStart ?: return false recordButtonHandler.removeCallbacks(runnable) - pendingVideoRecordStart = null + pendingRecordStart = null return true } - private fun cancelPendingVideoRecordStartOnSlide(x: Float): Boolean = - x < VOICE_RECORD_CANCEL_SLIDER_X && cancelPendingVideoRecordStart() - private fun isActiveRecordingPermissionGranted(): Boolean = if (chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { chatActivity.isVideoRecordPermissionGranted() @@ -778,7 +777,9 @@ class MessageInputFragment : Fragment() { } private fun showRecordHint(@StringRes messageRes: Int) { - Snackbar.make(binding.root, requireContext().getString(messageRes), Snackbar.LENGTH_SHORT).show() + val popup = recordHintPopup ?: RecordHintPopup(binding.fragmentMessageInputView.recordAudioButton) + .also { recordHintPopup = it } + popup.show(messageRes) } private fun updateRecordButtonMode() { @@ -1433,7 +1434,6 @@ class MessageInputFragment : Fragment() { private const val QUOTED_MESSAGE_IMAGE_MAX_HEIGHT = 96f private const val MENTION_AUTO_COMPLETE_ELEVATION = 6f private const val MINIMUM_VOICE_RECORD_DURATION: Int = 1000 - private const val RECORD_BUTTON_TAP_DURATION: Long = 300 private const val ANIMATION_DURATION: Long = 750 private const val VOICE_RECORD_CANCEL_SLIDER_X: Int = -300 private const val VOICE_RECORD_LOCK_THRESHOLD: Float = 100f 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..e28574e531a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt @@ -0,0 +1,69 @@ +/* + * 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) + } +} 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/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/layout/view_record_hint.xml b/app/src/main/res/layout/view_record_hint.xml new file mode 100644 index 00000000000..90f22aefc97 --- /dev/null +++ b/app/src/main/res/layout/view_record_hint.xml @@ -0,0 +1,29 @@ + + + + + + + + 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..ab89e0fa730 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt @@ -0,0 +1,92 @@ +/* + * 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)) + } +} 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) + } +} From fcd0d383fc4abdf5cc70c215102716a6bac28364 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:21:11 +0000 Subject: [PATCH 05/18] fix(chat): keep a video recording over a screen rotation The activity is recreated on rotation while the video recorder belonged to it: onPause cancelled the recording and deleted its file, and binding the camera to the next activity made the CameraX recorder configure itself in a state where that is not allowed. - The video recorder lives in ChatViewModel like the voice recorder. The activity attaches its preview and callback, and the recorder lets go of them when the activity is destroyed. A result that arrives without an activity is delivered to the next one. - The camera is bound to a lifecycle of the recorder itself, not to the one of the activity, so it stays bound while the activity is recreated. - A new activity picks up the recording and locks it when it was held, so the timer, stop, send and cancel are shown. - The recording is cancelled when the user leaves the chat, not on a configuration change. - A recording cut off by the camera is not lost but shown in the attachment preview. - The video is recorded in the rotation of the sensor. - A recording is never stopped while a switched camera settles. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/CameraLens.kt | 60 +++ .../com/nextcloud/talk/chat/ChatActivity.kt | 65 +++- .../talk/chat/RecordingStopCoordinator.kt | 104 ++++++ .../talk/chat/VideoMessageRecorder.kt | 343 ++++++++++++++---- .../talk/chat/viewmodels/ChatViewModel.kt | 19 + .../talk/chat/RecordingStopCoordinatorTest.kt | 141 +++++++ .../talk/chat/VideoRecorderLifecycleTest.kt | 45 +++ .../talk/chat/VideoRecordingRecreationTest.kt | 83 +++++ 8 files changed, 780 insertions(+), 80 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/CameraLens.kt create mode 100644 app/src/main/java/com/nextcloud/talk/chat/RecordingStopCoordinator.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/RecordingStopCoordinatorTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/VideoRecorderLifecycleTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt 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 3065891711e..6b11310d44b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -37,6 +37,7 @@ import android.provider.MediaStore import android.provider.Settings import android.text.TextUtils import android.util.Log +import android.view.Surface import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup @@ -272,7 +273,7 @@ class ChatActivity : private lateinit var binding: ActivityChatBinding - private var videoMessageRecorder: VideoMessageRecorder? = null + private val orientationTracker by lazy { DeviceOrientationTracker(this) } @Inject lateinit var ncApi: NcApi @@ -673,6 +674,7 @@ class ChatActivity : messageInputViewModel.setData(chatViewModel.getChatRepository()) initObservers() + resumeRecordingAfterRecreation() pendingTargetMessageId?.let { messageId -> lifecycleScope.launch { @@ -1427,6 +1429,7 @@ class ChatActivity : override fun onStart() { super.onStart() active = true + orientationTracker.enable() this.lifecycle.addObserver(AudioUtils) this.lifecycle.addObserver(chatViewModel) @@ -1467,8 +1470,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) @@ -2489,21 +2498,26 @@ class ChatActivity : * @return true if the recording was started */ fun startVideoRecording(): Boolean { - val recorder = videoMessageRecorder - ?: VideoMessageRecorder(this, this, binding.videoRecordingPreview, ::onVideoRecordingFinished) - .also { videoMessageRecorder = it } + 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.start(file) + recorder.attach(this, binding.videoRecordingPreview, ::onVideoRecordingFinished) + recorder.start( + file, + VideoMessageRecorder.videoTargetRotation( + orientationTracker.degrees, + binding.videoRecordingPreview.display?.rotation ?: Surface.ROTATION_0 + ) + ) return true } fun stopAndSendRecording() { if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { - videoMessageRecorder?.stopAndSend() + chatViewModel.activeVideoMessageRecorder?.stopAndSend() } else { chatViewModel.stopAndSendAudioRecording( roomToken = roomToken, @@ -2515,7 +2529,7 @@ class ChatActivity : fun stopAndDiscardRecording() { if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { - videoMessageRecorder?.cancel() + chatViewModel.activeVideoMessageRecorder?.cancel() } else { chatViewModel.stopAndDiscardAudioRecording() } @@ -2544,10 +2558,41 @@ class ChatActivity : 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 + ) + 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) { + recorder.attach(this, binding.videoRecordingPreview, ::onVideoRecordingFinished) + } + if (action == RecordingResume.ATTACH_AND_LOCK) { + chatViewModel.setVoiceRecordingLocked(true) + } + } + private fun showVideoRecordingPreview(show: Boolean) { if (show) { binding.videoRecordingContainer.clipToOutline = true @@ -2557,7 +2602,9 @@ class ChatActivity : outline.setRoundRect(0, 0, view.width, view.height, radius) } } - binding.videoRecordingSwitchCamera.setOnClickListener { videoMessageRecorder?.switchCamera() } + binding.videoRecordingSwitchCamera.setOnClickListener { + chatViewModel.activeVideoMessageRecorder?.switchCamera() + } } binding.videoRecordingContainer.visibility = if (show) View.VISIBLE else View.GONE } @@ -3117,8 +3164,6 @@ class ChatActivity : override fun onPause() { super.onPause() - videoMessageRecorder?.cancel() - logConversationInfos("onPause") eventBus.unregister(this) 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 index 184ed7b0e8d..f542c3b0c77 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -10,6 +10,7 @@ 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 @@ -25,30 +26,54 @@ 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. * - * All methods must be called on the main thread. [onFinished] is called exactly once per [start] on the main thread: - * with [Outcome.SEND] and the file to send, or with another outcome and null (the file is deleted then). + * 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( - private val context: Context, - private val lifecycleOwner: LifecycleOwner, - private val previewView: PreviewView, - private val onFinished: (Outcome, File?) -> Unit -) { +class VideoMessageRecorder(context: Context) { - enum class Outcome { SEND, CANCELLED, TOO_SHORT, FAILED } + /** + * [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 enum class StopAction { SEND, DISCARD } + + 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 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 stopAction: StopAction? = null private var cameraProvider: ProcessCameraProvider? = null private var preview: Preview? = null private var videoCapture: VideoCapture? = null @@ -56,29 +81,89 @@ class VideoMessageRecorder( private var outputFile: File? = null private var lensFacing = CameraSelector.LENS_FACING_FRONT private var session = 0 + private var videoRotation = Surface.ROTATION_0 + private val stopCoordinator = RecordingStopCoordinator(MainThreadScheduler()) { recording?.stop() } val isActive: Boolean get() = state != State.IDLE - fun start(file: File) { + /** + * 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. + */ + fun attach(owner: LifecycleOwner, view: PreviewView, callback: (Outcome, File?) -> Unit) { + if (released) return + detach() + activityLifecycle = owner.lifecycle + previewView = view + onFinished = callback + 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 + } + + /** + * 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 - stopAction = null + stopCoordinator.reset() outputFile = file + videoRotation = targetRotation lensFacing = CameraSelector.LENS_FACING_FRONT - val providerFuture = ProcessCameraProvider.getInstance(context) - providerFuture.addListener({ - if (startedSession != session) return@addListener - try { - onCameraProviderReady(providerFuture.get()) - } catch (e: java.util.concurrent.ExecutionException) { - Log.e(TAG, "camera provider is not available", e) + whenCameraProviderReady(context) { provider -> + if (startedSession != session) return@whenCameraProviderReady + if (provider != null) { + onCameraProviderReady(provider) + } else { finish(Outcome.FAILED, null) } - }, ContextCompat.getMainExecutor(context)) + } } fun stopAndSend() { @@ -94,18 +179,14 @@ class VideoMessageRecorder( /** * Switches between the front and the back camera. A running recording continues, the picture is frozen - * until the new camera delivers frames. + * 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 - if (state == State.IDLE || provider == null) return - - val newLens = if (lensFacing == CameraSelector.LENS_FACING_FRONT) { - CameraSelector.LENS_FACING_BACK - } else { - CameraSelector.LENS_FACING_FRONT - } - if (!provider.hasCamera(selectorFor(newLens))) return + val newLens = oppositeLens(lensFacing) + if (provider == null || state != State.RECORDING || !provider.hasCamera(cameraSelectorFor(newLens))) return + if (!stopCoordinator.beginSwitch()) return val previousLens = lensFacing lensFacing = newLens @@ -117,7 +198,6 @@ class VideoMessageRecorder( } private fun stop(action: StopAction) { - if (stopAction == StopAction.SEND) return when (state) { State.IDLE -> Unit @@ -126,13 +206,7 @@ class VideoMessageRecorder( finish(if (action == StopAction.SEND) Outcome.TOO_SHORT else Outcome.CANCELLED, null) } - State.RECORDING -> { - stopAction = action - recording?.stop() - if (action == StopAction.DISCARD) { - releaseCamera() - } - } + State.RECORDING -> stopCoordinator.requestStop(action) } } @@ -141,6 +215,8 @@ class VideoMessageRecorder( return } cameraProvider = provider + val owner = RecordingLifecycleOwner().also { cameraOwner = it } + owner.start() val recorder = Recorder.Builder() .setQualitySelector( @@ -148,9 +224,11 @@ class VideoMessageRecorder( ) .setTargetVideoEncodingBitRate(TARGET_VIDEO_BIT_RATE) .build() - val rotation = previewView.display?.rotation ?: Surface.ROTATION_0 - videoCapture = VideoCapture.Builder(recorder).setTargetRotation(rotation).build() - preview = Preview.Builder().setTargetRotation(rotation).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) @@ -160,28 +238,12 @@ class VideoMessageRecorder( } private fun bindUseCases(provider: ProcessCameraProvider): Boolean { - var selector = selectorFor(lensFacing) - if (!provider.hasCamera(selector)) { - lensFacing = if (lensFacing == CameraSelector.LENS_FACING_FRONT) { - CameraSelector.LENS_FACING_BACK - } else { - CameraSelector.LENS_FACING_FRONT - } - selector = selectorFor(lensFacing) - } + lensFacing = resolveLens(provider, lensFacing) + val owner = cameraOwner val previewUseCase = preview val videoUseCase = videoCapture - return try { - requireNotNull(previewUseCase).surfaceProvider = previewView.surfaceProvider - provider.bindToLifecycle(lifecycleOwner, selector, previewUseCase, requireNotNull(videoUseCase)) - true - } catch (e: IllegalArgumentException) { - Log.e(TAG, "cannot bind camera use cases", e) - false - } catch (e: IllegalStateException) { - Log.e(TAG, "cannot bind camera use cases", e) - false - } + if (owner == null || previewUseCase == null || videoUseCase == null) return false + return provider.bindSafely(owner, lensFacing, previewUseCase, videoUseCase) != null } @SuppressLint("MissingPermission") @@ -202,16 +264,22 @@ class VideoMessageRecorder( private fun onRecordEvent(event: VideoRecordEvent) { if (event is VideoRecordEvent.Finalize) { - val outcome = resolveOutcome( - discardRequested = stopAction == StopAction.DISCARD, + val duration = event.recordingStats.recordedDurationNanos + var outcome = resolveOutcome( + discardRequested = stopCoordinator.action == StopAction.DISCARD, hasError = event.hasError(), error = event.error, - recordedDurationNanos = event.recordingStats.recordedDurationNanos + 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) outputFile else null) + finish(outcome, if (outcome == Outcome.SEND || outcome == Outcome.INTERRUPTED) outputFile else null) } } @@ -220,20 +288,33 @@ class VideoMessageRecorder( releaseCamera() recording = null outputFile = null - stopAction = null state = State.IDLE - if (fileToSend == null) { + if (fileToSend == null || released) { file?.delete() } - onFinished(outcome, fileToSend) + 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 } - private fun selectorFor(lens: Int): CameraSelector = CameraSelector.Builder().requireLensFacing(lens).build() - companion object { private val TAG = VideoMessageRecorder::class.java.simpleName const val MAX_DURATION_MS = 120_000L @@ -267,5 +348,127 @@ class VideoMessageRecorder( 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 +} + +fun resolveRecordingResume( + recorderActive: Boolean, + hasPendingResult: Boolean, + recordingInProgress: Boolean, + recordingLocked: Boolean +): RecordingResume = + when { + hasPendingResult -> RecordingResume.DELIVER_RESULT + recorderActive && recordingLocked -> RecordingResume.ATTACH + recorderActive -> RecordingResume.ATTACH_AND_LOCK + recordingInProgress && !recordingLocked -> RecordingResume.ATTACH_AND_LOCK + else -> RecordingResume.NONE + } + +/** + * 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/viewmodels/ChatViewModel.kt b/app/src/main/java/com/nextcloud/talk/chat/viewmodels/ChatViewModel.kt index 6d4c8c7fa78..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 @@ -24,6 +24,7 @@ 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 @@ -2337,6 +2338,24 @@ class ChatViewModel @AssistedInject constructor( 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 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/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..abb787678da --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt @@ -0,0 +1,83 @@ +/* + * 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 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 + ) + ) + } +} From 4528d9ede137330a2c7336d62ff448a5de4aeca2 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:21:15 +0000 Subject: [PATCH 06/18] fix(chat): block taps and TalkBack focus on the video recording preview The container of the video recording preview did not consume touches, so taps on it reached the chat below. Make it consume them and hide it from TalkBack, where it is only decoration. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- app/src/main/res/layout/activity_chat.xml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index a7d1b881a43..8f16349a3c7 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -120,6 +120,9 @@ Date: Sun, 4 Oct 2026 08:17:01 +0000 Subject: [PATCH 07/18] feat(chat): replace attachment menu with a media bottom sheet Show recent photos and videos from MediaStore in a 3-column grid with a live camera tile and multi-select, and keep every former menu entry in a bar below with unchanged visibility rules. Selected media open the existing attachment preview with caption. Supports Android 14 partial media access. Removes AttachmentDialog and dialog_attachment.xml. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- app/src/main/AndroidManifest.xml | 1 + .../talk/attachmentsheet/AttachmentAction.kt | 56 +++ .../AttachmentActionResources.kt | 42 ++ .../talk/attachmentsheet/AttachmentSheet.kt | 433 ++++++++++++++++++ .../talk/attachmentsheet/CameraTile.kt | 120 +++++ .../talk/attachmentsheet/MediaAccess.kt | 59 +++ .../talk/attachmentsheet/MediaSelection.kt | 47 ++ .../talk/attachmentsheet/RecentMedia.kt | 46 ++ .../talk/attachmentsheet/RecentMediaLoader.kt | 118 +++++ .../com/nextcloud/talk/chat/ChatActivity.kt | 90 +++- .../talk/chat/MessageInputFragment.kt | 3 +- .../talk/ui/dialog/AttachmentDialog.kt | 153 ------- app/src/main/res/layout/dialog_attachment.xml | 339 -------------- app/src/main/res/values/strings.xml | 4 + .../attachmentsheet/AttachmentActionTest.kt | 81 ++++ .../talk/attachmentsheet/MediaAccessTest.kt | 68 +++ .../attachmentsheet/MediaSelectionTest.kt | 58 +++ .../talk/attachmentsheet/RecentMediaTest.kt | 66 +++ 18 files changed, 1282 insertions(+), 502 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentActionResources.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentsheet/CameraTile.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaSelection.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMedia.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoader.kt delete mode 100644 app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt delete mode 100644 app/src/main/res/layout/dialog_attachment.xml create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaSelectionTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3923ae833d7..2a51cb2b370 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -65,6 +65,7 @@ android:maxSdkVersion="32" /> + diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt new file mode 100644 index 00000000000..e81ebd475c4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt @@ -0,0 +1,56 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +/** + * Entries of the attachment sheet that open another screen or flow. The declaration order is the display order. + */ +enum class AttachmentAction { + PICTURE_FROM_CAM, + VIDEO_FROM_CAM, + GALLERY, + FILE_FROM_LOCAL, + FILE_FROM_CLOUD, + CREATE_THREAD, + CREATE_POLL, + SHARE_LOCATION, + SHARE_CONTACT +} + +data class AttachmentVisibilityInput( + val isRemoteConversation: Boolean, + val hasGeoLocationCapability: Boolean, + val hasPollsCapability: Boolean, + val isOneToOneConversation: Boolean, + val hasThreadsCapability: Boolean, + val isInsideThread: Boolean, + val hasCamera: Boolean +) + +/** + * Decides which entries are offered. Federated conversations only allow the gallery, polls and threads; the video + * entry additionally needs camera hardware. + */ +fun resolveAttachmentActions(input: AttachmentVisibilityInput): List = + AttachmentAction.entries.filter { action -> + when (action) { + AttachmentAction.PICTURE_FROM_CAM, + AttachmentAction.FILE_FROM_LOCAL, + AttachmentAction.FILE_FROM_CLOUD, + AttachmentAction.SHARE_CONTACT -> !input.isRemoteConversation + + AttachmentAction.VIDEO_FROM_CAM -> !input.isRemoteConversation && input.hasCamera + + AttachmentAction.GALLERY -> true + + AttachmentAction.SHARE_LOCATION -> !input.isRemoteConversation && input.hasGeoLocationCapability + + AttachmentAction.CREATE_POLL -> input.hasPollsCapability && !input.isOneToOneConversation + + AttachmentAction.CREATE_THREAD -> input.hasThreadsCapability && !input.isInsideThread + } + } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentActionResources.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentActionResources.kt new file mode 100644 index 00000000000..e363ce42f34 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentActionResources.kt @@ -0,0 +1,42 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.nextcloud.talk.R + +@DrawableRes +internal fun AttachmentAction.iconRes(): Int = + when (this) { + AttachmentAction.PICTURE_FROM_CAM -> R.drawable.ic_baseline_photo_camera_24 + AttachmentAction.VIDEO_FROM_CAM -> R.drawable.ic_baseline_videocam_24 + AttachmentAction.GALLERY -> R.drawable.baseline_photo_library_24 + AttachmentAction.FILE_FROM_LOCAL -> R.drawable.upload + AttachmentAction.FILE_FROM_CLOUD -> R.drawable.ic_share_variant + AttachmentAction.CREATE_THREAD -> R.drawable.outline_forum_24 + AttachmentAction.CREATE_POLL -> R.drawable.ic_baseline_bar_chart_24 + AttachmentAction.SHARE_LOCATION -> R.drawable.ic_baseline_location_on_24 + AttachmentAction.SHARE_CONTACT -> R.drawable.ic_baseline_person_24 + } + +/** + * Label for the entry, or null when it needs a runtime argument (the cloud entry carries the server name). + */ +@StringRes +internal fun AttachmentAction.labelRes(): Int? = + when (this) { + AttachmentAction.PICTURE_FROM_CAM -> R.string.nc_upload_picture_from_cam + AttachmentAction.VIDEO_FROM_CAM -> R.string.nc_upload_video_from_cam + AttachmentAction.GALLERY -> R.string.nc_gallery + AttachmentAction.FILE_FROM_LOCAL -> R.string.nc_upload_from_device + AttachmentAction.FILE_FROM_CLOUD -> null + AttachmentAction.CREATE_THREAD -> R.string.start_thread + AttachmentAction.CREATE_POLL -> R.string.nc_create_poll + AttachmentAction.SHARE_LOCATION -> R.string.nc_share_location + AttachmentAction.SHARE_CONTACT -> R.string.nc_share_contact + } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt new file mode 100644 index 00000000000..c5721217cdb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt @@ -0,0 +1,433 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import android.provider.Settings +import androidx.activity.compose.LocalActivity +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.GridItemSpan +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.app.ActivityCompat +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import coil.compose.AsyncImage +import com.nextcloud.talk.R +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +private const val GRID_COLUMNS = 3 +private const val TILE_GAP_DP = 2 +private const val BADGE_SIZE_DP = 24 +private const val BAR_ITEM_WIDTH_DP = 84 +private const val SCRIM_ALPHA = 0.45f + +data class AttachmentSheetModel( + val actions: List, + val cloudLabel: String, + val maxSelection: Int, + val livePreviewEnabled: Boolean +) + +internal data class CameraTileConfig(val livePreview: Boolean, val onClick: () -> Unit) + +data class AttachmentSheetCallbacks( + val onAction: (AttachmentAction) -> Unit, + val onTakePhoto: () -> Unit, + val onSend: (List) -> Unit, + val onDismiss: () -> Unit +) + +/** + * Bottom sheet in the style of messengers: recent photos and videos in a grid (first tile is the camera), a bar with + * all other attachment entries below. It opens half-high and can be dragged up. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AttachmentSheet(model: AttachmentSheetModel, callbacks: AttachmentSheetCallbacks) { + ModalBottomSheet( + onDismissRequest = callbacks.onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = false), + containerColor = MaterialTheme.colorScheme.surface + ) { + AttachmentSheetBody(model, callbacks) + } +} + +@Composable +private fun AttachmentSheetBody(model: AttachmentSheetModel, callbacks: AttachmentSheetCallbacks) { + val context = LocalContext.current + val refreshKey = rememberResumeRefreshKey() + val access = remember(refreshKey) { currentMediaAccess(context) } + val media by produceState?>(null, refreshKey, access) { + value = if (access == MediaAccess.NONE) { + emptyList() + } else { + withContext(Dispatchers.IO) { RecentMediaLoader.load(context.contentResolver) } + } + } + var selection by remember { mutableStateOf(MediaSelection(limit = model.maxSelection)) } + LaunchedEffect(media) { + media?.let { loaded -> selection = selection.retainAvailable(loaded.map { it.key }) } + } + val permissionRequest = rememberMediaPermissionRequest() + + val hiddenBottomPx = rememberHiddenBottomPx() + val hiddenBottomDp = with(LocalDensity.current) { hiddenBottomPx.value.toDp() } + Column( + modifier = Modifier + .fillMaxSize() + .onGloballyPositioned { hiddenBottomPx.update(it.positionInWindow().y, it.size.height) } + .padding(bottom = hiddenBottomDp) + ) { + Box(modifier = Modifier.weight(1f)) { + if (access == MediaAccess.NONE) { + MediaAccessRequest(onRequest = permissionRequest) + } else { + MediaGrid( + media = media.orEmpty(), + selection = selection, + onToggle = { selection = selection.toggle(it.key) }, + camera = CameraTileConfig(model.livePreviewEnabled, callbacks.onTakePhoto) + .takeIf { AttachmentAction.PICTURE_FROM_CAM in model.actions }, + onSelectMore = permissionRequest.takeIf { access == MediaAccess.PARTIAL } + ) + } + } + if (selection.isEmpty) { + ActionBar( + model = model, + onAction = callbacks.onAction, + modifier = if (hiddenBottomPx.value > 0) Modifier.navigationBarsPadding() else Modifier + ) + } else { + SendBar( + count = selection.count, + onClear = { selection = MediaSelection(limit = model.maxSelection) }, + onSend = { + val chosen = media.orEmpty().associateBy { it.key } + val uris = selection.ids.mapNotNull { chosen[it] }.map { RecentMediaLoader.uriOf(it) } + if (uris.isNotEmpty()) callbacks.onSend(uris) + } + ) + } + } +} + +/** + * Changes after the sheet's screen came back from the background (e.g. from the app settings), so the media list + * and the permissions are read again. The first resume while the sheet opens does not count. + */ +@Composable +private fun rememberResumeRefreshKey(): Int { + var refreshKey by remember { mutableIntStateOf(0) } + var wasPaused by remember { mutableStateOf(false) } + LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { wasPaused = true } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + if (wasPaused) { + wasPaused = false + refreshKey++ + } + } + return refreshKey +} + +/** + * Part of the sheet body that is currently pushed below the window bottom while the sheet is half open, so the + * bars stay visible. + */ +private class HiddenBottom(private val windowHeightPx: Int) { + var value by mutableIntStateOf(0) + private set + + fun update(topInWindow: Float, height: Int) { + value = (topInWindow.roundToInt() + height - windowHeightPx).coerceAtLeast(0) + } +} + +@Composable +private fun rememberHiddenBottomPx(): HiddenBottom { + val windowHeight = LocalWindowInfo.current.containerSize.height + return remember(windowHeight) { HiddenBottom(windowHeight) } +} + +private fun grantedMediaPermissions(context: Context): Set = + mediaPermissionsToRequest(Build.VERSION.SDK_INT) + .filter { ContextCompat.checkSelfPermission(context, it) == PackageManager.PERMISSION_GRANTED } + .toSet() + +private fun currentMediaAccess(context: Context): MediaAccess = + resolveMediaAccess(Build.VERSION.SDK_INT, grantedMediaPermissions(context)) + +/** + * Asks for media access. When the system dialog will not appear again (denied with "don't ask again"), the call + * opens the app settings instead. + */ +@Composable +private fun rememberMediaPermissionRequest(): () -> Unit { + val context = LocalContext.current + val activity = LocalActivity.current + var blocked by remember { mutableStateOf(false) } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + blocked = currentMediaAccess(context) == MediaAccess.NONE && + activity != null && + mediaPermissionsToRequest(Build.VERSION.SDK_INT).none { + ActivityCompat.shouldShowRequestPermissionRationale(activity, it) + } + } + return { + if (blocked) { + val appDetails = Uri.fromParts("package", context.packageName, null) + context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, appDetails)) + } else { + launcher.launch(mediaPermissionsToRequest(Build.VERSION.SDK_INT)) + } + } +} + +@Composable +private fun MediaAccessRequest(onRequest: () -> Unit) { + Column( + modifier = Modifier.fillMaxSize().padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon( + painter = painterResource(R.drawable.baseline_photo_library_24), + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = stringResource(R.string.read_storage_no_permission), + modifier = Modifier.padding(vertical = 16.dp), + textAlign = TextAlign.Center, + style = MaterialTheme.typography.bodyMedium + ) + Button(onClick = onRequest) { + Text(stringResource(R.string.nc_permissions_ask)) + } + } +} + +@Composable +private fun MediaGrid( + media: List, + selection: MediaSelection, + onToggle: (RecentMedia) -> Unit, + camera: CameraTileConfig?, + onSelectMore: (() -> Unit)? +) { + LazyVerticalGrid( + columns = GridCells.Fixed(GRID_COLUMNS), + modifier = Modifier.fillMaxSize(), + horizontalArrangement = Arrangement.spacedBy(TILE_GAP_DP.dp), + verticalArrangement = Arrangement.spacedBy(TILE_GAP_DP.dp) + ) { + if (onSelectMore != null) { + item(span = { GridItemSpan(maxLineSpan) }) { + TextButton(onClick = onSelectMore, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.attachment_sheet_select_more)) + } + } + } + if (camera != null) { + item { CameraTile(livePreview = camera.livePreview, onClick = camera.onClick) } + } + items(media, key = { it.key }) { item -> + MediaTile(item, selection.positionOf(item.key), onClick = { onToggle(item) }) + } + } +} + +@Composable +private fun MediaTile(media: RecentMedia, position: Int?, onClick: () -> Unit) { + val description = if (media.isVideo) { + stringResource(R.string.attachment_sheet_video, formatVideoDuration(media.durationMs)) + } else { + stringResource(R.string.attachment_sheet_photo) + } + Box( + modifier = Modifier + .aspectRatio(1f) + .semantics { + contentDescription = description + selected = position != null + role = Role.Checkbox + } + .clickable(onClick = onClick) + ) { + MediaThumbnail(media) + if (media.isVideo) { + Text( + text = formatVideoDuration(media.durationMs), + modifier = Modifier + .align(Alignment.BottomStart) + .padding(4.dp) + .background(Color.Black.copy(alpha = SCRIM_ALPHA), CircleShape) + .padding(horizontal = 6.dp), + color = Color.White, + fontSize = 11.sp + ) + } + SelectionBadge(position, Modifier.align(Alignment.TopEnd).padding(6.dp)) + } +} + +@Composable +private fun MediaThumbnail(media: RecentMedia) { + if (media.isVideo) { + val resolver = LocalContext.current.contentResolver + val frame by produceState(null, media.key) { + value = withContext(Dispatchers.IO) { RecentMediaLoader.videoThumbnail(resolver, media) } + } + frame?.let { + Image( + bitmap = it.asImageBitmap(), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize() + ) + } ?: Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant)) + } else { + AsyncImage( + model = RecentMediaLoader.uriOf(media), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant) + ) + } +} + +@Composable +private fun SelectionBadge(position: Int?, modifier: Modifier = Modifier) { + val base = modifier.size(BADGE_SIZE_DP.dp).clip(CircleShape) + if (position == null) { + Box(base.border(2.dp, Color.White, CircleShape).background(Color.Black.copy(alpha = 0.15f))) + } else { + Box( + modifier = base.background(MaterialTheme.colorScheme.primary), + contentAlignment = Alignment.Center + ) { + Text(text = position.toString(), color = MaterialTheme.colorScheme.onPrimary, fontSize = 13.sp) + } + } +} + +@Composable +private fun SendBar(count: Int, onClear: () -> Unit, onSend: () -> Unit) { + Row( + modifier = Modifier.fillMaxWidth().navigationBarsPadding().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + TextButton(onClick = onClear) { Text(stringResource(R.string.nc_cancel)) } + Button(onClick = onSend) { Text(stringResource(R.string.attachment_sheet_send, count)) } + } +} + +@Composable +private fun ActionBar( + model: AttachmentSheetModel, + onAction: (AttachmentAction) -> Unit, + modifier: Modifier = Modifier +) { + Row( + modifier = modifier.fillMaxWidth().horizontalScroll(rememberScrollState()).padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + model.actions.forEach { action -> + val label = action.labelRes()?.let { stringResource(it) } ?: model.cloudLabel + Column( + modifier = Modifier + .width(BAR_ITEM_WIDTH_DP.dp) + .clip(MaterialTheme.shapes.medium) + .clickable { onAction(action) } + .padding(vertical = 8.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Icon( + painter = painterResource(action.iconRes()), + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant + ) + Text( + text = label, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + style = MaterialTheme.typography.labelSmall + ) + } + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/CameraTile.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/CameraTile.kt new file mode 100644 index 00000000000..45507becf2a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/CameraTile.kt @@ -0,0 +1,120 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import android.Manifest +import android.content.pm.PackageManager +import androidx.camera.core.CameraSelector +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.nextcloud.talk.R +import com.nextcloud.talk.chat.bindSafely +import com.nextcloud.talk.chat.resolveLens +import com.nextcloud.talk.chat.whenCameraProviderReady + +/** + * Holds the bound preview so it can be released before the system camera starts. + */ +private class PreviewBinding { + private var provider: ProcessCameraProvider? = null + private var preview: Preview? = null + + fun bind(provider: ProcessCameraProvider, preview: Preview?) { + this.provider = provider + this.preview = preview + } + + fun release() { + preview?.let { provider?.unbind(it) } + preview = null + } +} + +/** + * First grid tile. With [livePreview] and the camera permission it shows the back camera; otherwise only the camera + * icon (no camera is opened, e.g. during a call). The tap goes through the regular photo flow, which asks for the + * camera permission; the preview is released first so the system camera can open it. + */ +@Composable +internal fun CameraTile(livePreview: Boolean, onClick: () -> Unit) { + val context = LocalContext.current + val hasCameraPermission = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + val binding = remember { PreviewBinding() } + Box( + modifier = Modifier + .aspectRatio(1f) + .background(Color.Black) + .clickable { + binding.release() + onClick() + } + ) { + if (livePreview && hasCameraPermission) { + LiveCameraPreview(binding, Modifier.fillMaxSize()) + } + Icon( + painter = painterResource(R.drawable.ic_baseline_photo_camera_24), + contentDescription = stringResource(R.string.nc_upload_picture_from_cam), + modifier = Modifier.align(Alignment.Center).padding(8.dp), + tint = Color.White + ) + } +} + +@Composable +private fun LiveCameraPreview(binding: PreviewBinding, modifier: Modifier) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + DisposableEffect(lifecycleOwner) { + var disposed = false + whenCameraProviderReady(context) { provider -> + if (!disposed && provider != null) { + binding.bind(provider, bindBackPreview(provider, lifecycleOwner, previewView)) + } + } + onDispose { + disposed = true + binding.release() + } + } + AndroidView(factory = { previewView }, modifier = modifier) +} + +private fun bindBackPreview(provider: ProcessCameraProvider, owner: LifecycleOwner, view: PreviewView): Preview? { + val lens = resolveLens(provider, CameraSelector.LENS_FACING_BACK) + val preview = Preview.Builder().build().also { it.surfaceProvider = view.surfaceProvider } + return preview.takeIf { provider.bindSafely(owner, lens, it) != null } +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt new file mode 100644 index 00000000000..c016d9f6976 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.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.attachmentsheet + +import android.Manifest +import android.os.Build + +/** + * How much of the device media library the app may read. + */ +enum class MediaAccess { FULL, PARTIAL, NONE } + +/** + * Derives [MediaAccess] from the granted runtime permissions. Android 14 and newer can grant only the photos the + * user picked (READ_MEDIA_VISUAL_USER_SELECTED); Android 13 uses per-type permissions; older versions use the + * single storage permission. + */ +fun resolveMediaAccess(sdkInt: Int, granted: Set): MediaAccess = + when { + sdkInt >= Build.VERSION_CODES.TIRAMISU -> + when { + Manifest.permission.READ_MEDIA_IMAGES in granted || + Manifest.permission.READ_MEDIA_VIDEO in granted -> MediaAccess.FULL + + sdkInt >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + READ_MEDIA_VISUAL_USER_SELECTED in granted -> MediaAccess.PARTIAL + + else -> MediaAccess.NONE + } + + Manifest.permission.READ_EXTERNAL_STORAGE in granted -> MediaAccess.FULL + + else -> MediaAccess.NONE + } + +/** + * Permissions to request in one dialog so that Android 14 offers the "select photos" choice next to "allow all". + */ +fun mediaPermissionsToRequest(sdkInt: Int): Array = + when { + sdkInt >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> + arrayOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + READ_MEDIA_VISUAL_USER_SELECTED + ) + + sdkInt >= Build.VERSION_CODES.TIRAMISU -> + arrayOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VIDEO) + + else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) + } + +// Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED exists only from API 34; the string is stable. +internal const val READ_MEDIA_VISUAL_USER_SELECTED = "android.permission.READ_MEDIA_VISUAL_USER_SELECTED" diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaSelection.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaSelection.kt new file mode 100644 index 00000000000..492a22ee369 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaSelection.kt @@ -0,0 +1,47 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +/** + * Ordered multi-selection of media ids with an upper limit. Immutable; every change returns a new instance. + */ +data class MediaSelection(val limit: Int, val ids: List = emptyList()) { + + val count: Int + get() = ids.size + + val isEmpty: Boolean + get() = ids.isEmpty() + + val isFull: Boolean + get() = ids.size >= limit + + fun contains(id: Long): Boolean = id in ids + + /** + * 1-based position of [id] in the selection order, or null when it is not selected. + */ + fun positionOf(id: Long): Int? = ids.indexOf(id).takeIf { it >= 0 }?.plus(1) + + /** + * Selects [id] or deselects it when already selected. Selecting is ignored once the limit is reached. + */ + fun toggle(id: Long): MediaSelection = + when { + contains(id) -> copy(ids = ids - id) + isFull -> this + else -> copy(ids = ids + id) + } + + /** + * Drops ids that are no longer part of [available], e.g. after the media list was reloaded. + */ + fun retainAvailable(available: Collection): MediaSelection { + val availableSet = available.toSet() + return copy(ids = ids.filter { it in availableSet }) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMedia.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMedia.kt new file mode 100644 index 00000000000..d5fd5e5a6ec --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMedia.kt @@ -0,0 +1,46 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import java.util.Locale + +/** + * One photo or video of the device library. [id] is the MediaStore row id within its own table (images and videos). + */ +data class RecentMedia(val id: Long, val isVideo: Boolean, val dateAddedSeconds: Long, val durationMs: Long = 0L) { + + /** + * Images and videos have separate id spaces, so the key combines both. + */ + val key: Long + get() = if (isVideo) -id - 1 else id +} + +private const val SECONDS_PER_MINUTE = 60 +private const val SECONDS_PER_HOUR = 3600 +private const val MILLIS_PER_SECOND = 1000 + +/** + * Newest first, at most [limit] entries. Equal dates keep images before videos so the order is stable. + */ +fun mergeRecentMedia(images: List, videos: List, limit: Int): List = + (images + videos).sortedByDescending { it.dateAddedSeconds }.take(limit) + +/** + * "m:ss" below an hour, "h:mm:ss" from an hour on. + */ +fun formatVideoDuration(durationMs: Long): String { + val totalSeconds = (durationMs.coerceAtLeast(0L) / MILLIS_PER_SECOND).toInt() + val hours = totalSeconds / SECONDS_PER_HOUR + val minutes = totalSeconds % SECONDS_PER_HOUR / SECONDS_PER_MINUTE + val seconds = totalSeconds % SECONDS_PER_MINUTE + return if (hours > 0) { + String.format(Locale.ROOT, "%d:%02d:%02d", hours, minutes, seconds) + } else { + String.format(Locale.ROOT, "%d:%02d", minutes, seconds) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoader.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoader.kt new file mode 100644 index 00000000000..01f8959ee6a --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/RecentMediaLoader.kt @@ -0,0 +1,118 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import android.content.ContentResolver +import android.content.ContentUris +import android.database.Cursor +import android.graphics.Bitmap +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.provider.MediaStore +import android.util.Log +import android.util.Size +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive +import java.io.IOException + +/** + * Reads the newest photos and videos from MediaStore. Without read access (or with partial access) the system + * returns only what the app may see, so no permission check happens here. + */ +object RecentMediaLoader { + private val TAG = RecentMediaLoader::class.java.simpleName + private const val LIMIT = 300 + private const val THUMBNAIL_PX = 320 + + suspend fun load(resolver: ContentResolver, limit: Int = LIMIT): List { + val images = query(resolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI, isVideo = false, limit) + val videos = query(resolver, MediaStore.Video.Media.EXTERNAL_CONTENT_URI, isVideo = true, limit) + return mergeRecentMedia(images, videos, limit) + } + + fun uriOf(media: RecentMedia): Uri { + val base = if (media.isVideo) { + MediaStore.Video.Media.EXTERNAL_CONTENT_URI + } else { + MediaStore.Images.Media.EXTERNAL_CONTENT_URI + } + return ContentUris.withAppendedId(base, media.id) + } + + /** + * Newest first with a row limit. Before Android 11 the provider ignores a limit passed in the query bundle, so + * it is appended to the sort order there. + */ + private fun openCursor(resolver: ContentResolver, uri: Uri, projection: Array, limit: Int): Cursor? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val args = Bundle().apply { + putStringArray(ContentResolver.QUERY_ARG_SORT_COLUMNS, arrayOf(MediaStore.MediaColumns.DATE_ADDED)) + putInt(ContentResolver.QUERY_ARG_SORT_DIRECTION, ContentResolver.QUERY_SORT_DIRECTION_DESCENDING) + putInt(ContentResolver.QUERY_ARG_LIMIT, limit) + } + resolver.query(uri, projection, args, null) + } else { + resolver.query(uri, projection, null, null, "${MediaStore.MediaColumns.DATE_ADDED} DESC LIMIT $limit") + } + + /** + * A small preview frame of a video, or null when the system has none. + */ + @Suppress("DEPRECATION") + fun videoThumbnail(resolver: ContentResolver, media: RecentMedia): Bitmap? = + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + resolver.loadThumbnail(uriOf(media), Size(THUMBNAIL_PX, THUMBNAIL_PX), null) + } else { + MediaStore.Video.Thumbnails.getThumbnail( + resolver, + media.id, + MediaStore.Video.Thumbnails.MINI_KIND, + null + ) + } + } catch (e: IOException) { + Log.w(TAG, "no video thumbnail for ${media.id}", e) + null + } catch (e: SecurityException) { + Log.w(TAG, "video thumbnail not readable for ${media.id}", e) + null + } + + private suspend fun query(resolver: ContentResolver, uri: Uri, isVideo: Boolean, limit: Int): List { + val projection = if (isVideo) { + arrayOf( + MediaStore.MediaColumns._ID, + MediaStore.MediaColumns.DATE_ADDED, + MediaStore.Video.VideoColumns.DURATION + ) + } else { + arrayOf(MediaStore.MediaColumns._ID, MediaStore.MediaColumns.DATE_ADDED) + } + val result = mutableListOf() + openCursor(resolver, uri, projection, limit)?.use { cursor -> + val idColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID) + val dateColumn = cursor.getColumnIndexOrThrow(MediaStore.MediaColumns.DATE_ADDED) + val durationColumn = if (isVideo) { + cursor.getColumnIndexOrThrow(MediaStore.Video.VideoColumns.DURATION) + } else { + -1 + } + while (cursor.moveToNext()) { + currentCoroutineContext().ensureActive() + result += RecentMedia( + id = cursor.getLong(idColumn), + isVideo = isVideo, + dateAddedSeconds = cursor.getLong(dateColumn), + durationMs = if (durationColumn >= 0) cursor.getLong(durationColumn) else 0L + ) + } + } + return result + } +} 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 6b11310d44b..345e07f5eda 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -114,6 +114,12 @@ import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.api.NcApiCoroutines import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.attachmentpreview.FileAttachmentPreviewFragment +import com.nextcloud.talk.attachmentsheet.AttachmentAction +import com.nextcloud.talk.attachmentsheet.AttachmentSheet +import com.nextcloud.talk.attachmentsheet.AttachmentSheetCallbacks +import com.nextcloud.talk.attachmentsheet.AttachmentSheetModel +import com.nextcloud.talk.attachmentsheet.AttachmentVisibilityInput +import com.nextcloud.talk.attachmentsheet.resolveAttachmentActions import com.nextcloud.talk.chat.data.io.VoiceMessageMediaService import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.FileParameters @@ -429,6 +435,7 @@ class ChatActivity : val participantPermissionsFlow: StateFlow = _participantPermissionsFlow.asStateFlow() private var pendingCameraUri: Uri? = null + private var attachmentSheetModel by mutableStateOf(null) private var pendingTargetMessageId: Long? = null private var pendingTargetThreadId: Long? = null private var pendingTargetSearchQuery: String? = null @@ -1090,6 +1097,8 @@ class ChatActivity : ) } } + + AttachmentSheetHost() } } } @@ -3038,7 +3047,7 @@ class ChatActivity : ) } - fun sendSelectLocalFileIntent() { + private fun sendSelectLocalFileIntent() { if (!permissionUtil.isFilesPermissionGranted()) { requestReadFilesPermissions() } else { @@ -3046,17 +3055,17 @@ class ChatActivity : } } - fun sendChooseContactIntent() { + private fun sendChooseContactIntent() { requestReadContacts() } - fun showBrowserScreen() { + private fun showBrowserScreen() { val sharingFileBrowserIntent = Intent(this, RemoteFileBrowserActivity::class.java) sharingFileBrowserIntent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) startRemoteFileBrowsingForResult.launch(sharingFileBrowserIntent) } - fun showShareLocationScreen() { + private fun showShareLocationScreen() { Log.d(TAG, "showShareLocationScreen") val locationManager = getSystemService(LOCATION_SERVICE) as LocationManager @@ -4270,7 +4279,72 @@ class ChatActivity : } } - fun sendPictureFromCamIntent() { + @Composable + private fun AttachmentSheetHost() { + val model = attachmentSheetModel ?: return + val callbacks = remember { + AttachmentSheetCallbacks( + onAction = { runAttachmentAction(it) }, + onTakePhoto = { + attachmentSheetModel = null + sendPictureFromCamIntent() + }, + onSend = { + attachmentSheetModel = null + onChooseFileResult(it) + }, + onDismiss = { attachmentSheetModel = null } + ) + } + AttachmentSheet(model = model, callbacks = callbacks) + } + + fun showAttachmentSheet() { + attachmentSheetModel = buildAttachmentSheetModel() + } + + private fun buildAttachmentSheetModel(): AttachmentSheetModel? { + val conversation = currentConversation ?: return null + val actions = resolveAttachmentActions( + AttachmentVisibilityInput( + isRemoteConversation = !conversation.remoteServer.isNullOrEmpty(), + hasGeoLocationCapability = hasSpreedFeatureCapability( + spreedCapabilities, + SpreedFeatures.GEO_LOCATION_SHARING + ), + hasPollsCapability = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.TALK_POLLS), + isOneToOneConversation = isOneToOneConversation(), + hasThreadsCapability = hasSpreedFeatureCapability(spreedCapabilities, SpreedFeatures.THREADS), + isInsideThread = conversationThreadId != null, + hasCamera = packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY) + ) + ) + val serverName = CapabilitiesUtil.getServerName(conversationUser) + .takeUnless { it.isNullOrEmpty() } ?: getString(R.string.nc_server_product_name) + return AttachmentSheetModel( + actions = actions, + cloudLabel = getString(R.string.nc_upload_from_cloud, serverName), + maxSelection = MAX_AMOUNT_MEDIA_FILE_PICKER, + livePreviewEnabled = !(CallActivity.active || !isNotInCall()) + ) + } + + private fun runAttachmentAction(action: AttachmentAction) { + attachmentSheetModel = null + when (action) { + AttachmentAction.PICTURE_FROM_CAM -> sendPictureFromCamIntent() + AttachmentAction.VIDEO_FROM_CAM -> sendVideoFromCamIntent() + AttachmentAction.GALLERY -> showGalleryPicker() + AttachmentAction.FILE_FROM_LOCAL -> sendSelectLocalFileIntent() + AttachmentAction.FILE_FROM_CLOUD -> showBrowserScreen() + AttachmentAction.CREATE_THREAD -> createThread() + AttachmentAction.CREATE_POLL -> createPoll() + AttachmentAction.SHARE_LOCATION -> showShareLocationScreen() + AttachmentAction.SHARE_CONTACT -> sendChooseContactIntent() + } + } + + private fun sendPictureFromCamIntent() { if (!permissionUtil.isCameraPermissionGranted()) { requestCameraPermissions() } else { @@ -4288,7 +4362,7 @@ class ChatActivity : } } - fun sendVideoFromCamIntent() { + private fun sendVideoFromCamIntent() { if (!permissionUtil.isCameraPermissionGranted()) { requestCameraPermissions() } else { @@ -4306,12 +4380,12 @@ class ChatActivity : } } - fun createPoll() { + private fun createPoll() { val pollVoteDialog = PollCreateDialogFragment.newInstance(conversationUser, roomToken) pollVoteDialog.show(supportFragmentManager, TAG) } - fun createThread() { + private fun createThread() { messageInputViewModel.startThreadCreation() } 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 e2f3fd8b380..b41ef08f038 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -77,7 +77,6 @@ import com.nextcloud.talk.models.json.signaling.NCSignalingMessageDto import com.nextcloud.talk.presenters.MentionAutocompletePresenter import com.nextcloud.talk.ui.CallStartedBanner import com.nextcloud.talk.ui.MicInputCloud -import com.nextcloud.talk.ui.dialog.AttachmentDialog import com.nextcloud.talk.ui.theme.ViewThemeUtils import com.nextcloud.talk.ui.theme.hostViewThemeUtils import com.nextcloud.talk.users.UserManager @@ -520,7 +519,7 @@ class MessageInputFragment : Fragment() { } binding.fragmentMessageInputView.setAttachmentsListener { - AttachmentDialog(requireActivity(), requireActivity() as ChatActivity).show() + chatActivity.showAttachmentSheet() } binding.fragmentMessageInputView.attachmentButton.setOnLongClickListener { diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt deleted file mode 100644 index a1cdb1e73c1..00000000000 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/AttachmentDialog.kt +++ /dev/null @@ -1,153 +0,0 @@ -/* - * Nextcloud Talk - Android Client - * - * SPDX-FileCopyrightText: 2021 Marcel Hibbe - * SPDX-FileCopyrightText: 2021 Andy Scherzinger - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.talk.ui.dialog - -import android.app.Activity -import android.content.pm.PackageManager -import android.os.Bundle -import android.view.View -import android.view.ViewGroup -import autodagger.AutoInjector -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog -import com.nextcloud.talk.R -import com.nextcloud.talk.application.NextcloudTalkApplication -import com.nextcloud.talk.chat.ChatActivity -import com.nextcloud.talk.databinding.DialogAttachmentBinding -import com.nextcloud.talk.ui.theme.ViewThemeUtils -import com.nextcloud.talk.ui.theme.hostViewThemeUtils -import com.nextcloud.talk.utils.CapabilitiesUtil -import com.nextcloud.talk.utils.SpreedFeatures -import javax.inject.Inject - -@AutoInjector(NextcloudTalkApplication::class) -class AttachmentDialog(val activity: Activity, var chatActivity: ChatActivity) : BottomSheetDialog(activity) { - - @Inject - lateinit var viewThemeUtils: ViewThemeUtils - - private lateinit var dialogAttachmentBinding: DialogAttachmentBinding - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - NextcloudTalkApplication.sharedApplication?.componentApplication?.inject(this) - viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) - - dialogAttachmentBinding = DialogAttachmentBinding.inflate(layoutInflater) - setContentView(dialogAttachmentBinding.root) - window?.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT) - - viewThemeUtils.material.colorBottomSheetBackground(dialogAttachmentBinding.root) - viewThemeUtils.material.colorBottomSheetDragHandle(dialogAttachmentBinding.bottomSheetDragHandle) - initItemsStrings() - initItemsVisibility() - initItemsClickListeners() - } - - private fun initItemsStrings() { - var serverName = CapabilitiesUtil.getServerName(chatActivity.conversationUser) - dialogAttachmentBinding.txtAttachFileFromCloud.text = chatActivity.resources?.let { - if (serverName.isNullOrEmpty()) { - serverName = it.getString(R.string.nc_server_product_name) - } - String.format(it.getString(R.string.nc_upload_from_cloud), serverName) - } - } - - private fun initItemsVisibility() { - if (!chatActivity.currentConversation!!.remoteServer.isNullOrEmpty()) { - dialogAttachmentBinding.menuAttachContact.visibility = View.GONE - dialogAttachmentBinding.menuShareLocation.visibility = View.GONE - dialogAttachmentBinding.menuAttachPictureFromCam.visibility = View.GONE - dialogAttachmentBinding.menuAttachVideoFromCam.visibility = View.GONE - dialogAttachmentBinding.menuAttachFileFromLocal.visibility = View.GONE - dialogAttachmentBinding.menuAttachFileFromCloud.visibility = View.GONE - } - - if (!CapabilitiesUtil.hasSpreedFeatureCapability( - chatActivity.spreedCapabilities, - SpreedFeatures.GEO_LOCATION_SHARING - ) - ) { - dialogAttachmentBinding.menuShareLocation.visibility = View.GONE - } - - if (!CapabilitiesUtil.hasSpreedFeatureCapability(chatActivity.spreedCapabilities, SpreedFeatures.TALK_POLLS) || - chatActivity.isOneToOneConversation() - ) { - dialogAttachmentBinding.menuAttachPoll.visibility = View.GONE - } - - if (!CapabilitiesUtil.hasSpreedFeatureCapability( - chatActivity.spreedCapabilities, - SpreedFeatures.THREADS - ) || - chatActivity.conversationThreadId != null - ) { - dialogAttachmentBinding.menuCreateThread.visibility = View.GONE - } - - if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_CAMERA_ANY)) { - dialogAttachmentBinding.menuAttachVideoFromCam.visibility = View.GONE - } - } - - private fun initItemsClickListeners() { - dialogAttachmentBinding.menuShareLocation.setOnClickListener { - chatActivity.showShareLocationScreen() - dismiss() - } - - dialogAttachmentBinding.menuAttachFileFromGallery.setOnClickListener { - chatActivity.showGalleryPicker() - dismiss() - } - - dialogAttachmentBinding.menuAttachFileFromLocal.setOnClickListener { - chatActivity.sendSelectLocalFileIntent() - dismiss() - } - - dialogAttachmentBinding.menuAttachPictureFromCam.setOnClickListener { - chatActivity.sendPictureFromCamIntent() - dismiss() - } - - dialogAttachmentBinding.menuAttachVideoFromCam.setOnClickListener { - chatActivity.sendVideoFromCamIntent() - dismiss() - } - - dialogAttachmentBinding.menuCreateThread.setOnClickListener { - chatActivity.createThread() - dismiss() - } - - dialogAttachmentBinding.menuAttachPoll.setOnClickListener { - chatActivity.createPoll() - dismiss() - } - - dialogAttachmentBinding.menuAttachFileFromCloud.setOnClickListener { - chatActivity.showBrowserScreen() - dismiss() - } - - dialogAttachmentBinding.menuAttachContact.setOnClickListener { - chatActivity.sendChooseContactIntent() - dismiss() - } - } - - override fun onStart() { - super.onStart() - val bottomSheet = findViewById(R.id.design_bottom_sheet) - val behavior = BottomSheetBehavior.from(bottomSheet as View) - behavior.state = BottomSheetBehavior.STATE_COLLAPSED - } -} diff --git a/app/src/main/res/layout/dialog_attachment.xml b/app/src/main/res/layout/dialog_attachment.xml deleted file mode 100644 index 74cc84ebb81..00000000000 --- a/app/src/main/res/layout/dialog_attachment.xml +++ /dev/null @@ -1,339 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 03a57300363..416ad2b1102 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -694,6 +694,10 @@ How to translate with transifex: Add to conversation + Select more photos and videos + Photo + Video, %1$s + Send (%1$d) Take photo Take video Create poll diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt new file mode 100644 index 00000000000..5e0fe503d44 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt @@ -0,0 +1,81 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AttachmentActionTest { + + private val everything = AttachmentVisibilityInput( + isRemoteConversation = false, + hasGeoLocationCapability = true, + hasPollsCapability = true, + isOneToOneConversation = false, + hasThreadsCapability = true, + isInsideThread = false, + hasCamera = true + ) + + @Test + fun localGroupConversationOffersEverything() { + assertEquals(AttachmentAction.entries, resolveAttachmentActions(everything)) + } + + @Test + fun remoteConversationKeepsOnlyGalleryPollAndThread() { + val actions = resolveAttachmentActions(everything.copy(isRemoteConversation = true)) + assertEquals( + listOf(AttachmentAction.GALLERY, AttachmentAction.CREATE_THREAD, AttachmentAction.CREATE_POLL), + actions + ) + } + + @Test + fun missingCameraHidesOnlyVideo() { + val actions = resolveAttachmentActions(everything.copy(hasCamera = false)) + assertFalse(AttachmentAction.VIDEO_FROM_CAM in actions) + assertTrue(AttachmentAction.PICTURE_FROM_CAM in actions) + } + + @Test + fun missingGeoCapabilityHidesLocation() { + val actions = resolveAttachmentActions(everything.copy(hasGeoLocationCapability = false)) + assertFalse(AttachmentAction.SHARE_LOCATION in actions) + } + + @Test + fun pollNeedsCapabilityAndGroupConversation() { + val withoutCapability = resolveAttachmentActions(everything.copy(hasPollsCapability = false)) + val oneToOne = resolveAttachmentActions(everything.copy(isOneToOneConversation = true)) + assertFalse(AttachmentAction.CREATE_POLL in withoutCapability) + assertFalse(AttachmentAction.CREATE_POLL in oneToOne) + } + + @Test + fun threadNeedsCapabilityAndNoThreadOpen() { + val withoutCapability = resolveAttachmentActions(everything.copy(hasThreadsCapability = false)) + val insideThread = resolveAttachmentActions(everything.copy(isInsideThread = true)) + assertFalse(AttachmentAction.CREATE_THREAD in withoutCapability) + assertFalse(AttachmentAction.CREATE_THREAD in insideThread) + } + + @Test + fun remoteConversationWithoutCapabilitiesKeepsOnlyGallery() { + val actions = resolveAttachmentActions( + everything.copy( + isRemoteConversation = true, + hasPollsCapability = false, + hasThreadsCapability = false, + hasCamera = false + ) + ) + assertEquals(listOf(AttachmentAction.GALLERY), actions) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt new file mode 100644 index 00000000000..1c81d228693 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt @@ -0,0 +1,68 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Krainov Gleb + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import android.Manifest +import android.os.Build +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Test + +class MediaAccessTest { + + private val images = Manifest.permission.READ_MEDIA_IMAGES + private val video = Manifest.permission.READ_MEDIA_VIDEO + private val storage = Manifest.permission.READ_EXTERNAL_STORAGE + private val selected = READ_MEDIA_VISUAL_USER_SELECTED + + @Test + fun android14WithAllMediaIsFull() { + assertEquals( + MediaAccess.FULL, + resolveMediaAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images, video, selected)) + ) + } + + @Test + fun android14WithOnlyUserSelectedIsPartial() { + assertEquals( + MediaAccess.PARTIAL, + resolveMediaAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(selected)) + ) + } + + @Test + fun android14WithNothingIsNone() { + assertEquals(MediaAccess.NONE, resolveMediaAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, emptySet())) + } + + @Test + fun android13WithOneMediaTypeIsFull() { + assertEquals(MediaAccess.FULL, resolveMediaAccess(Build.VERSION_CODES.TIRAMISU, setOf(video))) + } + + @Test + fun android13IgnoresUserSelectedPermission() { + assertEquals(MediaAccess.NONE, resolveMediaAccess(Build.VERSION_CODES.TIRAMISU, setOf(selected))) + } + + @Test + fun oldAndroidUsesStoragePermission() { + assertEquals(MediaAccess.FULL, resolveMediaAccess(Build.VERSION_CODES.S, setOf(storage))) + assertEquals(MediaAccess.NONE, resolveMediaAccess(Build.VERSION_CODES.S, setOf(images))) + } + + @Test + fun requestedPermissionsFollowTheVersion() { + assertArrayEquals( + arrayOf(images, video, selected), + mediaPermissionsToRequest(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) + ) + assertArrayEquals(arrayOf(images, video), mediaPermissionsToRequest(Build.VERSION_CODES.TIRAMISU)) + assertArrayEquals(arrayOf(storage), mediaPermissionsToRequest(Build.VERSION_CODES.S)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaSelectionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaSelectionTest.kt new file mode 100644 index 00000000000..dc5f338a658 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaSelectionTest.kt @@ -0,0 +1,58 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +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 MediaSelectionTest { + + @Test + fun toggleSelectsInOrderAndNumbersFromOne() { + val selection = MediaSelection(limit = 10).toggle(7).toggle(3).toggle(9) + assertEquals(listOf(7L, 3L, 9L), selection.ids) + assertEquals(1, selection.positionOf(7)) + assertEquals(3, selection.positionOf(9)) + assertEquals(3, selection.count) + } + + @Test + fun toggleTwiceDeselectsAndRenumbers() { + val selection = MediaSelection(limit = 10).toggle(1).toggle(2).toggle(3).toggle(1) + assertEquals(listOf(2L, 3L), selection.ids) + assertNull(selection.positionOf(1)) + assertEquals(1, selection.positionOf(2)) + } + + @Test + fun selectingBeyondLimitIsIgnored() { + val selection = MediaSelection(limit = 2).toggle(1).toggle(2).toggle(3) + assertEquals(listOf(1L, 2L), selection.ids) + assertTrue(selection.isFull) + } + + @Test + fun deselectingAtLimitFreesASlot() { + val selection = MediaSelection(limit = 2).toggle(1).toggle(2).toggle(1).toggle(3) + assertEquals(listOf(2L, 3L), selection.ids) + } + + @Test + fun emptySelectionIsEmpty() { + assertTrue(MediaSelection(limit = 10).isEmpty) + assertFalse(MediaSelection(limit = 10).toggle(1).isEmpty) + } + + @Test + fun retainAvailableDropsMissingIdsAndKeepsOrder() { + val selection = MediaSelection(limit = 10).toggle(5).toggle(6).toggle(7).retainAvailable(listOf(7L, 5L)) + assertEquals(listOf(5L, 7L), selection.ids) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaTest.kt new file mode 100644 index 00000000000..322fee064ca --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/RecentMediaTest.kt @@ -0,0 +1,66 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test + +class RecentMediaTest { + + private fun image(id: Long, date: Long) = RecentMedia(id, isVideo = false, dateAddedSeconds = date) + + private fun video(id: Long, date: Long) = + RecentMedia(id, isVideo = true, dateAddedSeconds = date, durationMs = 1000) + + @Test + fun mergeOrdersNewestFirstAcrossTypes() { + val merged = mergeRecentMedia(listOf(image(1, 50), image(2, 10)), listOf(video(1, 30)), limit = 10) + assertEquals(listOf(50L, 30L, 10L), merged.map { it.dateAddedSeconds }) + } + + @Test + fun mergeCutsToLimit() { + val merged = mergeRecentMedia(listOf(image(1, 5), image(2, 4)), listOf(video(1, 6), video(2, 3)), limit = 3) + assertEquals(listOf(6L, 5L, 4L), merged.map { it.dateAddedSeconds }) + } + + @Test + fun equalDatesKeepImagesBeforeVideos() { + val merged = mergeRecentMedia(listOf(image(1, 5)), listOf(video(2, 5)), limit = 10) + assertEquals(listOf(false, true), merged.map { it.isVideo }) + } + + @Test + fun mergeOfNothingIsEmpty() { + assertEquals(emptyList(), mergeRecentMedia(emptyList(), emptyList(), limit = 5)) + } + + @Test + fun imageAndVideoWithSameRowIdHaveDifferentKeys() { + assertNotEquals(image(4, 1).key, video(4, 1).key) + } + + @Test + fun durationBelowHourShowsMinutesAndSeconds() { + assertEquals("0:00", formatVideoDuration(0)) + assertEquals("0:07", formatVideoDuration(7_400)) + assertEquals("2:05", formatVideoDuration(125_000)) + assertEquals("59:59", formatVideoDuration(3_599_999)) + } + + @Test + fun durationFromHourShowsHours() { + assertEquals("1:00:00", formatVideoDuration(3_600_000)) + assertEquals("2:03:04", formatVideoDuration(7_384_000)) + } + + @Test + fun negativeDurationIsZero() { + assertEquals("0:00", formatVideoDuration(-5)) + } +} From 62f51a2bc17e912ee7ad71e84dbc62ff1e8f9e2f Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 08:30:27 +0000 Subject: [PATCH 08/18] fix(chat): count partial media access as files access on Android 14 READ_MEDIA_VISUAL_USER_SELECTED turns off the Android 14 compatibility mode, so isFilesPermissionGranted() must accept the partial grant, otherwise voice and video messages, camera photos, local files and share-to-Talk ask for permissions again. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/attachmentsheet/MediaAccess.kt | 10 +++- .../permissions/PlatformPermissionUtilImpl.kt | 29 +++++------ .../talk/attachmentsheet/MediaAccessTest.kt | 49 +++++++++++++++---- 3 files changed, 64 insertions(+), 24 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt index c016d9f6976..ee93a29af45 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt @@ -1,7 +1,7 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2026 Krainov Gleb + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.attachmentsheet @@ -37,6 +37,14 @@ fun resolveMediaAccess(sdkInt: Int, granted: Set): MediaAccess = else -> MediaAccess.NONE } +/** + * Whether the app may read files of the user without own picker: any media permission, including the partial + * grant of Android 14, or audio. Used by flows that already hold a URI (recording, camera, share-to-Talk). + */ +fun hasMediaFilesAccess(sdkInt: Int, granted: Set): Boolean = + sdkInt >= Build.VERSION_CODES.TIRAMISU && + (resolveMediaAccess(sdkInt, granted) != MediaAccess.NONE || Manifest.permission.READ_MEDIA_AUDIO in granted) + /** * Permissions to request in one dialog so that Android 14 offers the "select photos" choice next to "allow all". */ diff --git a/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt b/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt index a939561ab58..cf919ab01e8 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/permissions/PlatformPermissionUtilImpl.kt @@ -14,6 +14,8 @@ import android.util.Log import androidx.annotation.RequiresApi import androidx.core.content.PermissionChecker import com.nextcloud.talk.BuildConfig +import com.nextcloud.talk.attachmentsheet.READ_MEDIA_VISUAL_USER_SELECTED +import com.nextcloud.talk.attachmentsheet.hasMediaFilesAccess class PlatformPermissionUtilImpl(private val context: Context) : PlatformPermissionUtil { override val privateBroadcastPermission: String = @@ -41,20 +43,12 @@ class PlatformPermissionUtilImpl(private val context: Context) : PlatformPermiss override fun isFilesPermissionGranted(): Boolean = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> { - if ( - PermissionChecker.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) - == PermissionChecker.PERMISSION_GRANTED || - PermissionChecker.checkSelfPermission(context, Manifest.permission.READ_MEDIA_VIDEO) - == PermissionChecker.PERMISSION_GRANTED || - PermissionChecker.checkSelfPermission(context, Manifest.permission.READ_MEDIA_AUDIO) - == PermissionChecker.PERMISSION_GRANTED - ) { - Log.d(TAG, "Permission is granted (SDK 33 or greater)") - true - } else { - Log.d(TAG, "Permission is revoked (SDK 33 or greater)") - false - } + val granted = MEDIA_PERMISSIONS.filter { + PermissionChecker.checkSelfPermission(context, it) == PermissionChecker.PERMISSION_GRANTED + }.toSet() + val hasAccess = hasMediaFilesAccess(Build.VERSION.SDK_INT, granted) + Log.d(TAG, "Media permission is ${if (hasAccess) "granted" else "revoked"} (SDK 33 or greater)") + hasAccess } Build.VERSION.SDK_INT > Build.VERSION_CODES.Q -> { if (PermissionChecker.checkSelfPermission( @@ -98,6 +92,13 @@ class PlatformPermissionUtilImpl(private val context: Context) : PlatformPermiss ) == PermissionChecker.PERMISSION_GRANTED companion object { + private val MEDIA_PERMISSIONS = listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VIDEO, + Manifest.permission.READ_MEDIA_AUDIO, + READ_MEDIA_VISUAL_USER_SELECTED + ) + private val TAG = PlatformPermissionUtilImpl::class.simpleName } } diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt index 1c81d228693..b6896471925 100644 --- a/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt @@ -1,15 +1,16 @@ /* * Nextcloud Talk - Android Client * - * SPDX-FileCopyrightText: 2026 Krainov Gleb + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ package com.nextcloud.talk.attachmentsheet import android.Manifest import android.os.Build -import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Test class MediaAccessTest { @@ -57,12 +58,42 @@ class MediaAccessTest { } @Test - fun requestedPermissionsFollowTheVersion() { - assertArrayEquals( - arrayOf(images, video, selected), - mediaPermissionsToRequest(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) - ) - assertArrayEquals(arrayOf(images, video), mediaPermissionsToRequest(Build.VERSION_CODES.TIRAMISU)) - assertArrayEquals(arrayOf(storage), mediaPermissionsToRequest(Build.VERSION_CODES.S)) + fun android14WithOneMediaTypeIsFull() { + assertEquals(MediaAccess.FULL, resolveMediaAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images))) + } + + @Test + fun grantingEveryRequestedPermissionGivesFullAccess() { + listOf(Build.VERSION_CODES.S, Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.UPSIDE_DOWN_CAKE).forEach { + val requested = mediaPermissionsToRequest(it).toSet() + assertEquals("sdk $it", MediaAccess.FULL, resolveMediaAccess(it, requested)) + } + } + + @Test + fun grantingNothingGivesNoAccessOnEveryVersion() { + listOf(Build.VERSION_CODES.S, Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.UPSIDE_DOWN_CAKE).forEach { + assertEquals("sdk $it", MediaAccess.NONE, resolveMediaAccess(it, emptySet())) + } + } + + @Test + fun filesAccessCountsPartialGrantOnAndroid14() { + assertTrue(hasMediaFilesAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(selected))) + } + + @Test + fun filesAccessIgnoresPartialGrantBeforeAndroid14() { + assertFalse(hasMediaFilesAccess(Build.VERSION_CODES.TIRAMISU, setOf(selected))) + } + + @Test + fun filesAccessCountsAudioOnAndroid13() { + assertTrue(hasMediaFilesAccess(Build.VERSION_CODES.TIRAMISU, setOf(Manifest.permission.READ_MEDIA_AUDIO))) + } + + @Test + fun filesAccessIsFalseWithoutAnyMediaPermission() { + assertFalse(hasMediaFilesAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, emptySet())) } } From 1d06ca6ec9d0db163d55676a763aaddb34f0fb40 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:15:34 +0000 Subject: [PATCH 09/18] fix(chat): measure the minimum voice message length from the recording start The start time was taken on ACTION_DOWN, but the recording starts only after the 400-600 ms hold threshold, so a voice message was judged too short (or long enough) by a duration which included the hold. Take the time in beginRecording() after the recording has started and use the monotonic clock. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/MessageInputFragment.kt | 13 +++++++------ .../com/nextcloud/talk/chat/RecordButtonGesture.kt | 7 +++++++ .../nextcloud/talk/chat/RecordButtonGestureTest.kt | 8 ++++++++ 3 files changed, 22 insertions(+), 6 deletions(-) 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 b41ef08f038..817f0eded75 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt @@ -135,6 +135,7 @@ class MessageInputFragment : Fragment() { private val recordButtonHandler = Handler(Looper.getMainLooper()) private var pendingRecordStart: Runnable? = null + private var recordingStartedAt = 0L private var recordHintPopup: RecordHintPopup? = null private var recordingUiShown = false @@ -608,8 +609,6 @@ class MessageInputFragment : Fragment() { 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() @@ -617,7 +616,6 @@ class MessageInputFragment : Fragment() { MotionEvent.ACTION_DOWN -> { // A tap only switches the mode, the recording starts only after the hold threshold. recordHintPopup?.dismiss() - voiceRecordStartTime = System.currentTimeMillis() prevDx = event.x gesture.down() scheduleRecordStart(gesture) @@ -655,11 +653,9 @@ class MessageInputFragment : Fragment() { showRecordAudioUi(false) val isVideo = chatActivity.chatViewModel.activeRecordingMode == RecordInputMode.VIDEO - voiceRecordEndTime = System.currentTimeMillis() - val voiceRecordDuration = voiceRecordEndTime - voiceRecordStartTime if (isVideo) { chatActivity.stopAndSendRecording() - } else if (voiceRecordDuration < MINIMUM_VOICE_RECORD_DURATION) { + } else if (isVoiceRecordTooShort()) { showRecordHint(R.string.nc_voice_message_hold_to_record_info) chatActivity.stopAndDiscardRecording() return@setOnTouchListener false @@ -708,6 +704,9 @@ 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) { @@ -715,6 +714,8 @@ class MessageInputFragment : Fragment() { } 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() diff --git a/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt b/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt index e28574e531a..6a0a101ac08 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/RecordButtonGesture.kt @@ -65,5 +65,12 @@ class RecordButtonGesture(private val cancelX: Float) { */ 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/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt index ab89e0fa730..feeb5cff55a 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/RecordButtonGestureTest.kt @@ -89,4 +89,12 @@ class RecordButtonGestureTest { 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)) + } } From c85b647da891a8879b3ee1cdb053a03dd71b2e62 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:15:34 +0000 Subject: [PATCH 10/18] fix(chat): release a locked video recording only when CameraX has finalized it Send or delete of a locked video recording cleared the lock and the in-progress state at once, while CameraX was still finalizing the file. A new recording started in that window was silently dropped in beginRecording(). For video the state is now released in onVideoRecordingFinished(), which CameraX reaches for every outcome (send, cancel, error, too short). Audio, and a video recorder which is already idle, are still cleared at once. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../chat/MessageInputVoiceRecordingFragment.kt | 18 +++++++++++++----- .../talk/chat/VideoMessageRecorder.kt | 8 ++++++++ .../talk/chat/VideoRecordingRecreationTest.kt | 12 ++++++++++++ 3 files changed, 33 insertions(+), 5 deletions(-) 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 fa5c3d04042..83dcdb0863e 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -161,12 +161,20 @@ class MessageInputVoiceRecordingFragment : Fragment() { 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() - if (isVideoRecording()) { - chatActivity.chatViewModel.onVideoRecordingEnded() - } else { - 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() diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt index f542c3b0c77..b51e77f8525 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -429,6 +429,14 @@ fun resolveRecordingResume( 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. */ diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt index abb787678da..a1199ff50dc 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt @@ -80,4 +80,16 @@ class VideoRecordingRecreationTest { ) ) } + + @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)) + } } From 8bf23e8becaab2017d5e40b7fa57a9599bb179c0 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:15:34 +0000 Subject: [PATCH 11/18] fix(chat): dim the chat and fit the video message preview to the recorded frame The locked video recording preview was a fixed 180x240dp box floating over the message bubbles, squashed to about 2:1 in landscape. Now a scrim dims the chat pane (it blocks touches and is hidden from TalkBack), and the preview is centered in it in the aspect of the recorded frame, fitted with margins, at most 75% of the width and 480dp on the longest side. The placement is a pure function (videoPreviewPlacement) with unit tests; it is recomputed when the pane changes size and when the recorder learns the frame aspect from CameraX. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 64 +++++++- .../talk/chat/VideoMessageRecorder.kt | 31 +++- .../talk/chat/VideoPreviewPlacement.kt | 101 +++++++++++++ app/src/main/res/layout/activity_chat.xml | 56 ++++--- app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/dimens.xml | 5 +- .../talk/chat/VideoFrameAspectTest.kt | 41 ++++++ .../talk/chat/VideoPreviewPlacementTest.kt | 138 ++++++++++++++++++ 8 files changed, 408 insertions(+), 29 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt 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 345e07f5eda..8e4d9fd6956 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -37,12 +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 @@ -2513,7 +2515,12 @@ class ChatActivity : return false } showVideoRecordingPreview(true) - recorder.attach(this, binding.videoRecordingPreview, ::onVideoRecordingFinished) + recorder.attach( + this, + binding.videoRecordingPreview, + ::onVideoRecordingFinished, + ::updateVideoRecordingPreviewLayout + ) recorder.start( file, VideoMessageRecorder.videoTargetRotation( @@ -2595,15 +2602,65 @@ class ChatActivity : action == RecordingResume.ATTACH_AND_LOCK || action == RecordingResume.DELIVER_RESULT if (recorder != null && attach) { - recorder.attach(this, binding.videoRecordingPreview, ::onVideoRecordingFinished) + recorder.attach( + this, + binding.videoRecordingPreview, + ::onVideoRecordingFinished, + ::updateVideoRecordingPreviewLayout + ) } if (action == RecordingResume.ATTACH_AND_LOCK) { chatViewModel.setVoiceRecordingLocked(true) } } + // the update only touches the layout of the preview when its placement 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 placement = videoPreviewPlacement( + areaWidth = scrim.width, + areaHeight = scrim.height, + aspect = chatViewModel.activeVideoMessageRecorder?.frameAspect ?: fallbackVideoFrameAspect( + binding.videoRecordingPreview.display?.rotation ?: Surface.ROTATION_0 + ), + 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) + ) + ) + val container = binding.videoRecordingContainer + val params = container.layoutParams as FrameLayout.LayoutParams + val changed = params.width != placement.width || + params.height != placement.height || + params.leftMargin != placement.left || + params.topMargin != placement.top + if (changed) { + params.width = placement.width + params.height = placement.height + params.gravity = Gravity.TOP or Gravity.START + params.marginStart = placement.left + params.topMargin = placement.top + container.layoutParams = params + container.invalidateOutline() + } + } + 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) { @@ -2615,7 +2672,7 @@ class ChatActivity : chatViewModel.activeVideoMessageRecorder?.switchCamera() } } - binding.videoRecordingContainer.visibility = if (show) View.VISIBLE else View.GONE + binding.videoRecordingScrim.visibility = if (show) View.VISIBLE else View.GONE } private fun createAttachmentFile(@StringRes nameRes: Int, suffix: String): File? = @@ -4498,6 +4555,7 @@ class ChatActivity : } companion object { + private const val VIDEO_PREVIEW_MAX_WIDTH_FRACTION = 0.75f val TAG = ChatActivity::class.java.simpleName /** diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt index b51e77f8525..d648c774c1b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -66,6 +66,7 @@ class VideoMessageRecorder(context: Context) { 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 @@ -87,6 +88,25 @@ class VideoMessageRecorder(context: Context) { 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]. */ @@ -97,13 +117,20 @@ class VideoMessageRecorder(context: Context) { * 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) { + 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 -> @@ -127,6 +154,7 @@ class VideoMessageRecorder(context: Context) { activityLifecycle = null previewView = null onFinished = null + onFrameAspectChanged = null } /** @@ -235,6 +263,7 @@ class VideoMessageRecorder(context: Context) { return } startRecording() + onFrameAspectChanged?.invoke() } private fun bindUseCases(provider: ProcessCameraProvider): Boolean { 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..d282d78d5cc --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt @@ -0,0 +1,101 @@ +/* + * 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 and position of the video recording preview inside the area of the chat. All values are in the same unit + * (pixels); [left] and [top] are measured from the top start corner of the area. + */ +data class VideoPreviewPlacement(val width: Int, val height: Int, val left: Int, val top: 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 + */ +data class VideoPreviewLimits(val margin: Int, val maxWidthFraction: Float, val maxSide: Int) + +/** + * Fits a frame of the given aspect ratio into the area, centered, 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 margin = max(min(limits.margin, 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 + } + + val finalWidth = w.roundToInt() + val finalHeight = h.roundToInt() + return VideoPreviewPlacement( + width = finalWidth, + height = finalHeight, + left = (width - finalWidth) / 2, + top = (height - finalHeight) / 2 + ) +} + +/** + * 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 + } + +/** + * 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 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/res/layout/activity_chat.xml b/app/src/main/res/layout/activity_chat.xml index 8f16349a3c7..72522c2f22d 100644 --- a/app/src/main/res/layout/activity_chat.xml +++ b/app/src/main/res/layout/activity_chat.xml @@ -118,35 +118,45 @@ app:fabCustomSize="@dimen/min_size_clickable_area" app:srcCompat="@drawable/ic_lock_open_grey600_24dp" /> + - - - + + + + + + + #EF3B02 #DBE2E9 + #99000000 diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index d5288c5fdb7..f6d5759d5af 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -18,8 +18,9 @@ 48dp 48dp - 180dp - 240dp + 480dp + + 2dp 8dp 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..796d6e8060b --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt @@ -0,0 +1,41 @@ +/* + * 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) + } +} 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..3da76f6a227 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt @@ -0,0 +1,138 @@ +/* + * 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) + + private fun assertInside(p: VideoPreviewPlacement, areaWidth: Int, areaHeight: Int) { + assertTrue("left ${p.left}", p.left >= 0) + assertTrue("top ${p.top}", p.top >= 0) + assertTrue("right", p.left + p.width <= areaWidth) + assertTrue("bottom", p.top + p.height <= 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) + // centered: the gap on both sides is the same, within a pixel + assertTrue(Math.abs(p.left - (348 - p.left - p.width)) <= 1) + assertTrue(Math.abs(p.top - (560 - p.top - p.height)) <= 1) + } + + @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.top >= 16) + } + + @Test + fun landscapeInALandscapeAreaIsFittedByTheHeightAndNotSquashed() { + val p = place(700, 200, landscape) + assertEquals(168, p.height) + assertEquals(299, p.width) + assertAspect(p, landscape) + assertTrue(p.top >= 16) + assertTrue(p.left >= 16) + assertInside(p, 700, 200) + } + + @Test + fun landscapeFrameInAPortraitAreaIsFittedByTheWidth() { + val p = place(348, 560, landscape) + assertEquals(261, p.width) + assertEquals(147, p.height) + assertAspect(p, landscape) + assertEquals((560 - 147) / 2, p.top) + } + + @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.left >= 16) + 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.left >= 16) + } + + @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, 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)) + } +} From e499e42547e5f8cfe05bd0816340f98032aa5d0a Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:15:34 +0000 Subject: [PATCH 12/18] fix(chat): center the video preview by gravity, follow the screen orientation, hide chat from TalkBack - Center by Gravity.CENTER and change only width and height: the margin comparison never settled in RTL and re-laid out the chat every frame. - The frame aspect is turned into screen coordinates by the difference of the recording and the display rotation (screenFrameAspect), so the frame matches the picture with auto-rotate off and after a turn during the recording. - While recording, the siblings of the scrim are hidden from TalkBack and get their previous values back afterwards. - The preview size is recalculated after a camera switch. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 43 +++++++++++++------ .../talk/chat/VideoMessageRecorder.kt | 7 ++- .../talk/chat/VideoPreviewPlacement.kt | 32 ++++++++------ .../talk/chat/VideoFrameAspectTest.kt | 30 +++++++++++++ .../talk/chat/VideoPreviewPlacementTest.kt | 23 ++++------ 5 files changed, 96 insertions(+), 39 deletions(-) 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 8e4d9fd6956..8a1ecbd3ffc 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2614,7 +2614,9 @@ class ChatActivity : } } - // the update only touches the layout of the preview when its placement changed, so it does not loop + 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() } @@ -2628,12 +2630,13 @@ class ChatActivity : 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?.frameAspect ?: fallbackVideoFrameAspect( - binding.videoRecordingPreview.display?.rotation ?: Surface.ROTATION_0 - ), + 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, @@ -2642,21 +2645,36 @@ class ChatActivity : ) val container = binding.videoRecordingContainer val params = container.layoutParams as FrameLayout.LayoutParams - val changed = params.width != placement.width || - params.height != placement.height || - params.leftMargin != placement.left || - params.topMargin != placement.top - if (changed) { + if (params.width != placement.width || params.height != placement.height) { params.width = placement.width params.height = placement.height - params.gravity = Gravity.TOP or Gravity.START - params.marginStart = placement.left - params.topMargin = placement.top + 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) @@ -2672,6 +2690,7 @@ class ChatActivity : chatViewModel.activeVideoMessageRecorder?.switchCamera() } } + hideChatFromAccessibility(show) binding.videoRecordingScrim.visibility = if (show) View.VISIBLE else View.GONE } diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt index d648c774c1b..b810a490384 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -82,7 +82,10 @@ class VideoMessageRecorder(context: Context) { private var outputFile: File? = null private var lensFacing = CameraSelector.LENS_FACING_FRONT private var session = 0 - private var videoRotation = Surface.ROTATION_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 @@ -223,6 +226,8 @@ class VideoMessageRecorder(context: Context) { lensFacing = previousLens bindUseCases(provider) } + // the new camera may deliver another resolution + onFrameAspectChanged?.invoke() } private fun stop(action: StopAction) { diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt index d282d78d5cc..81226fff8a5 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt @@ -12,10 +12,10 @@ import kotlin.math.min import kotlin.math.roundToInt /** - * Size and position of the video recording preview inside the area of the chat. All values are in the same unit - * (pixels); [left] and [top] are measured from the top start corner of the area. + * 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, val left: Int, val top: Int) +data class VideoPreviewPlacement(val width: Int, val height: Int) /** * Bounds of the preview inside the area. @@ -27,7 +27,7 @@ data class VideoPreviewPlacement(val width: Int, val height: Int, val left: Int, data class VideoPreviewLimits(val margin: Int, val maxWidthFraction: Float, val maxSide: Int) /** - * Fits a frame of the given aspect ratio into the area, centered, inside the [limits]. A portrait frame is limited + * 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. * @@ -60,14 +60,7 @@ fun videoPreviewPlacement( h *= scale } - val finalWidth = w.roundToInt() - val finalHeight = h.roundToInt() - return VideoPreviewPlacement( - width = finalWidth, - height = finalHeight, - left = (width - finalWidth) / 2, - top = (height - finalHeight) / 2 - ) + return VideoPreviewPlacement(width = w.roundToInt(), height = h.roundToInt()) } /** @@ -83,6 +76,20 @@ fun videoFrameAspect(surfaceWidth: Int, surfaceHeight: Int, rotationDegrees: Int 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). @@ -94,6 +101,7 @@ fun fallbackVideoFrameAspect(surfaceRotation: Int): Float = 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 diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt index 796d6e8060b..17c91c18b08 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoFrameAspectTest.kt @@ -38,4 +38,34 @@ class VideoFrameAspectTest { 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/VideoPreviewPlacementTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt index 3da76f6a227..c88301fb805 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementTest.kt @@ -20,11 +20,10 @@ class VideoPreviewPlacementTest { 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("left ${p.left}", p.left >= 0) - assertTrue("top ${p.top}", p.top >= 0) - assertTrue("right", p.left + p.width <= areaWidth) - assertTrue("bottom", p.top + p.height <= areaHeight) + 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) { @@ -38,9 +37,6 @@ class VideoPreviewPlacementTest { assertEquals(464, p.height) assertAspect(p, portrait) assertInside(p, 348, 560) - // centered: the gap on both sides is the same, within a pixel - assertTrue(Math.abs(p.left - (348 - p.left - p.width)) <= 1) - assertTrue(Math.abs(p.top - (560 - p.top - p.height)) <= 1) } @Test @@ -50,7 +46,7 @@ class VideoPreviewPlacementTest { assertEquals(368, p.height) assertEquals(207, p.width) assertAspect(p, portrait) - assertTrue(p.top >= 16) + assertTrue(p.height <= 400 - 32) } @Test @@ -59,8 +55,8 @@ class VideoPreviewPlacementTest { assertEquals(168, p.height) assertEquals(299, p.width) assertAspect(p, landscape) - assertTrue(p.top >= 16) - assertTrue(p.left >= 16) + assertTrue(p.height <= 200 - 32) + assertTrue(p.width <= 700 - 32) assertInside(p, 700, 200) } @@ -70,7 +66,6 @@ class VideoPreviewPlacementTest { assertEquals(261, p.width) assertEquals(147, p.height) assertAspect(p, landscape) - assertEquals((560 - 147) / 2, p.top) } @Test @@ -80,7 +75,7 @@ class VideoPreviewPlacementTest { assertEquals(180, p.width) assertEquals(320, p.height) assertAspect(p, portrait) - assertTrue(p.left >= 16) + assertTrue(p.width <= 240 - 32) assertInside(p, 240, 520) } @@ -90,7 +85,7 @@ class VideoPreviewPlacementTest { // 200 - 2 * 16 = 168 is less than 95 % of 200 assertEquals(168, p.width) assertAspect(p, landscape) - assertTrue(p.left >= 16) + assertTrue(p.width <= 200 - 32) } @Test @@ -113,7 +108,7 @@ class VideoPreviewPlacementTest { @Test fun emptyAreaGivesAnEmptyPreview() { val p = place(0, 0, portrait) - assertEquals(VideoPreviewPlacement(0, 0, 0, 0), p) + assertEquals(VideoPreviewPlacement(0, 0), p) assertEquals(0, place(-5, 100, portrait).width) } From bf006f83e251ac5f9f6d0fe8ad0037cd4f085c20 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:15:34 +0000 Subject: [PATCH 13/18] fix(chat): one-line recording panel for video messages, larger preview In video mode the locked recording panel is one row: delete, red dot in a progress ring of the 120 s limit, timer, send. The voice panel is unchanged. The preview keeps a smaller gap on a short area. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 4 +- .../MessageInputVoiceRecordingFragment.kt | 31 +++++++++- .../talk/chat/VideoPreviewPlacement.kt | 13 +++- .../main/res/drawable/video_recording_dot.xml | 10 ++++ ...fragment_message_input_voice_recording.xml | 29 +++++++++ app/src/main/res/values/dimens.xml | 3 + .../VideoPreviewPlacementCompactPanelTest.kt | 59 +++++++++++++++++++ 7 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 app/src/main/res/drawable/video_recording_dot.xml create mode 100644 app/src/test/java/com/nextcloud/talk/chat/VideoPreviewPlacementCompactPanelTest.kt 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 8a1ecbd3ffc..6ab467ac646 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2640,7 +2640,9 @@ class ChatActivity : 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) + 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 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 83dcdb0863e..b1279efd561 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() @@ -127,7 +130,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { } if (isVideoRecording()) { - binding.micInputCloud.visibility = View.INVISIBLE + showCompactVideoRow() } else { binding.micInputCloud.setOnClickListener { togglePreviewVisibility() @@ -158,6 +161,32 @@ 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. + */ + 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.setPadding(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.micInputCloud.visibility = View.GONE + binding.videoRecordingIndicator.visibility = View.VISIBLE + 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() { diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt index 81226fff8a5..6461b9e67d6 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoPreviewPlacement.kt @@ -23,8 +23,16 @@ data class VideoPreviewPlacement(val width: Int, val height: Int) * @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) +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 @@ -43,7 +51,8 @@ fun videoPreviewPlacement( 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 margin = max(min(limits.margin, min(width, height) / MARGIN_DIVISOR), 0) + 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() 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/fragment_message_input_voice_recording.xml b/app/src/main/res/layout/fragment_message_input_voice_recording.xml index ad6fd24795b..a9fe0e8c62e 100644 --- a/app/src/main/res/layout/fragment_message_input_voice_recording.xml +++ b/app/src/main/res/layout/fragment_message_input_voice_recording.xml @@ -64,6 +64,7 @@ android:textStyle="bold" /> + + + + + + + + 480dp 2dp + 24dp + + 200dp 8dp 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 + } +} From fa2818c4b3085daa8330801159794f511cd87971 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:15:34 +0000 Subject: [PATCH 14/18] fix(chat): video recording row review fixes - Hide the whole recording indicator from TalkBack, so the progress ring does not announce its percentage every second. - Relative padding of the timer, so the gap stays between dot and timer in RTL. - No inset around the progress ring, so it fills its 24dp. - The ring scale is set in code only (PROGRESS_MAX). Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/chat/MessageInputVoiceRecordingFragment.kt | 6 ++++-- .../res/layout/fragment_message_input_voice_recording.xml | 4 ++-- 2 files changed, 6 insertions(+), 4 deletions(-) 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 b1279efd561..47c428b0745 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -164,7 +164,8 @@ 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. + * 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 @@ -172,7 +173,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { 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.setPadding(margin, 0, 0, 0) + timer.setPaddingRelative(margin, 0, 0, 0) row.setPadding(margin, margin, margin, margin) row.weightSum = 0f for (button in listOf(binding.deleteVoiceRecording, binding.sendVoiceRecording)) { @@ -180,6 +181,7 @@ class MessageInputVoiceRecordingFragment : Fragment() { } 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 = diff --git a/app/src/main/res/layout/fragment_message_input_voice_recording.xml b/app/src/main/res/layout/fragment_message_input_voice_recording.xml index a9fe0e8c62e..6753223994a 100644 --- a/app/src/main/res/layout/fragment_message_input_voice_recording.xml +++ b/app/src/main/res/layout/fragment_message_input_voice_recording.xml @@ -88,16 +88,16 @@ android:id="@+id/videoRecordingIndicator" android:layout_width="@dimen/video_recording_indicator_size" android:layout_height="@dimen/video_recording_indicator_size" - android:importantForAccessibility="no" + android:importantForAccessibility="noHideDescendants" android:visibility="gone"> From f3fdd7cd1cc35c306b4fef6130093efd4e55a162 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:15:47 +0000 Subject: [PATCH 15/18] fix(chat): ask for the selected-photos permission and judge the result by the effective state requestReadFilesPermissions() did not request READ_MEDIA_VISUAL_USER_SELECTED on Android 14+, although the manifest declares it, and the result handler looked only at grantResults[0]. With "selected photos" the first permission is denied, so a partial access showed a false refusal. The request is now the set of the attachment sheet (mediaPermissionsToRequest) plus READ_MEDIA_AUDIO from Android 13 on, as before. The result allows the file picker if any requested permission was granted or the effective access (permissionUtil.isFilesPermissionGranted()) is given: before Android 10 the granted READ permission differs from the WRITE one that check looks at. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/attachmentsheet/MediaAccess.kt | 20 +++++++ .../com/nextcloud/talk/chat/ChatActivity.kt | 22 ++------ .../talk/attachmentsheet/MediaAccessTest.kt | 15 ++++++ .../ShareFilePermissionTest.kt | 53 +++++++++++++++++++ 4 files changed, 92 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentsheet/ShareFilePermissionTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt index ee93a29af45..201a9e04cb1 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.attachmentsheet import android.Manifest +import android.content.pm.PackageManager import android.os.Build /** @@ -63,5 +64,24 @@ fun mediaPermissionsToRequest(sdkInt: Int): Array = else -> arrayOf(Manifest.permission.READ_EXTERNAL_STORAGE) } +/** + * Permissions of the "file from device" request: the set of the attachment sheet, plus audio from Android 13 on, + * which the request has always included. + */ +fun shareFilePermissionsToRequest(sdkInt: Int): Array = + if (sdkInt >= Build.VERSION_CODES.TIRAMISU) { + mediaPermissionsToRequest(sdkInt) + Manifest.permission.READ_MEDIA_AUDIO + } else { + mediaPermissionsToRequest(sdkInt) + } + +/** + * Whether the result of the "file from device" request allows to pick a file. No single result is the answer: + * Android 14 may grant only the selected photos, and before Android 10 the granted read permission differs from the + * one [effectivelyGranted] checks. Any granted permission or an effective access is enough. + */ +fun isShareFileRequestGranted(grantResults: IntArray, effectivelyGranted: Boolean): Boolean = + grantResults.any { it == PackageManager.PERMISSION_GRANTED } || effectivelyGranted + // Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED exists only from API 34; the string is stable. internal const val READ_MEDIA_VISUAL_USER_SELECTED = "android.permission.READ_MEDIA_VISUAL_USER_SELECTED" 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 6ab467ac646..508cd869d60 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -121,7 +121,9 @@ import com.nextcloud.talk.attachmentsheet.AttachmentSheet import com.nextcloud.talk.attachmentsheet.AttachmentSheetCallbacks import com.nextcloud.talk.attachmentsheet.AttachmentSheetModel import com.nextcloud.talk.attachmentsheet.AttachmentVisibilityInput +import com.nextcloud.talk.attachmentsheet.isShareFileRequestGranted import com.nextcloud.talk.attachmentsheet.resolveAttachmentActions +import com.nextcloud.talk.attachmentsheet.shareFilePermissionsToRequest import com.nextcloud.talk.chat.data.io.VoiceMessageMediaService import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.FileParameters @@ -2727,23 +2729,7 @@ class ChatActivity : } private fun requestReadFilesPermissions() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - requestPermissions( - arrayOf( - Manifest.permission.READ_MEDIA_IMAGES, - Manifest.permission.READ_MEDIA_VIDEO, - Manifest.permission.READ_MEDIA_AUDIO - ), - REQUEST_SHARE_FILE_PERMISSION - ) - } else { - requestPermissions( - arrayOf( - Manifest.permission.READ_EXTERNAL_STORAGE - ), - REQUEST_SHARE_FILE_PERMISSION - ) - } + requestPermissions(shareFilePermissionsToRequest(Build.VERSION.SDK_INT), REQUEST_SHARE_FILE_PERMISSION) } private fun checkShowCallButtons() { @@ -3031,7 +3017,7 @@ class ChatActivity : .show() } } else if (requestCode == REQUEST_SHARE_FILE_PERMISSION) { - if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { + if (isShareFileRequestGranted(grantResults, permissionUtil.isFilesPermissionGranted())) { showLocalFilePicker() } else { Snackbar.make( diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt index b6896471925..8acca066642 100644 --- a/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/MediaAccessTest.kt @@ -96,4 +96,19 @@ class MediaAccessTest { fun filesAccessIsFalseWithoutAnyMediaPermission() { assertFalse(hasMediaFilesAccess(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, emptySet())) } + + @Test + fun partialGrantOnAndroid14IsAccessEvenThoughTheFirstRequestedPermissionIsDenied() { + // User-selected photos are asked for from Android 14 on, not before. + val sdk = Build.VERSION_CODES.UPSIDE_DOWN_CAKE + val requested = mediaPermissionsToRequest(sdk) + assertEquals(setOf(images, video, selected), requested.toSet()) + assertEquals(setOf(images, video), mediaPermissionsToRequest(Build.VERSION_CODES.TIRAMISU).toSet()) + + // The result handler must not look at grantResults[0] only: with "selected photos" the first permission of + // the request (READ_MEDIA_IMAGES) is denied and only READ_MEDIA_VISUAL_USER_SELECTED is granted. + val granted = setOf(selected) + assertFalse(requested.first() in granted) + assertTrue(hasMediaFilesAccess(sdk, granted)) + } } diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/ShareFilePermissionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/ShareFilePermissionTest.kt new file mode 100644 index 00000000000..6c307380285 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/ShareFilePermissionTest.kt @@ -0,0 +1,53 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import android.Manifest +import android.content.pm.PackageManager +import android.os.Build +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ShareFilePermissionTest { + + private val granted = PackageManager.PERMISSION_GRANTED + private val denied = PackageManager.PERMISSION_DENIED + + @Test + fun requestIsTheSheetSetPlusAudioFromAndroid13() { + val audio = Manifest.permission.READ_MEDIA_AUDIO + listOf(Build.VERSION_CODES.TIRAMISU, Build.VERSION_CODES.UPSIDE_DOWN_CAKE).forEach { + assertEquals(mediaPermissionsToRequest(it).toSet() + audio, shareFilePermissionsToRequest(it).toSet()) + } + assertEquals( + setOf(Manifest.permission.READ_EXTERNAL_STORAGE), + shareFilePermissionsToRequest(Build.VERSION_CODES.Q).toSet() + ) + } + + @Test + fun anyGrantedPermissionAllowsThePicker() { + // Android 14, "selected photos": the first result is denied, a later one is granted + assertTrue(isShareFileRequestGranted(intArrayOf(denied, denied, granted), effectivelyGranted = false)) + // Android 8-10: READ is granted, the effective check looks at WRITE + assertTrue(isShareFileRequestGranted(intArrayOf(granted), effectivelyGranted = false)) + } + + @Test + fun effectiveAccessAllowsThePickerEvenWithoutGrantedResult() { + assertTrue(isShareFileRequestGranted(intArrayOf(denied), effectivelyGranted = true)) + assertTrue(isShareFileRequestGranted(intArrayOf(), effectivelyGranted = true)) + } + + @Test + fun nothingGrantedIsRefused() { + assertFalse(isShareFileRequestGranted(intArrayOf(denied, denied), effectivelyGranted = false)) + assertFalse(isShareFileRequestGranted(intArrayOf(), effectivelyGranted = false)) + } +} From 2163da2d3f6fd4a515d2e2b51eb91b6827d5ea4c Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 10:05:02 +0000 Subject: [PATCH 16/18] fix(chat): defer video result until chat is loaded, video a11y labels A video recording result that waited for a new activity was delivered from onCreate, before currentConversation and spreedCapabilities were set: the send path and the preview path could throw. The pending result is now delivered once the initial capabilities state has initialized both; a running recording still reattaches at once. The delete and send buttons of the compact video row announced the voice recording labels to TalkBack. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 27 ++++++++++++------- .../MessageInputVoiceRecordingFragment.kt | 2 ++ .../talk/chat/VideoMessageRecorder.kt | 9 +++++-- app/src/main/res/values/strings.xml | 2 ++ .../talk/chat/VideoRecordingRecreationTest.kt | 17 ++++++++++++ 5 files changed, 46 insertions(+), 11 deletions(-) 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 508cd869d60..1e726f6e2a0 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1642,6 +1642,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() @@ -2595,7 +2600,9 @@ class ChatActivity : recorderActive = recorder?.isActive == true, hasPendingResult = recorder?.hasPendingResult == true, recordingInProgress = chatViewModel.getVoiceRecordingInProgress.value == true, - recordingLocked = chatViewModel.getVoiceRecordingLocked.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) @@ -2603,19 +2610,21 @@ class ChatActivity : val attach = action == RecordingResume.ATTACH || action == RecordingResume.ATTACH_AND_LOCK || action == RecordingResume.DELIVER_RESULT - if (recorder != null && attach) { - recorder.attach( - this, - binding.videoRecordingPreview, - ::onVideoRecordingFinished, - ::updateVideoRecordingPreviewLayout - ) - } + 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 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 47c428b0745..7523a60f061 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/MessageInputVoiceRecordingFragment.kt @@ -179,6 +179,8 @@ class MessageInputVoiceRecordingFragment : Fragment() { 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 diff --git a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt index b810a490384..be41260214b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -446,16 +446,21 @@ enum class RecordingResume { ATTACH_AND_LOCK, /** The recording ended while there was no activity: deliver its result. */ - DELIVER_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 + 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 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 416ad2b1102..c918664b4a3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -751,6 +751,8 @@ How to translate with transifex: 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 diff --git a/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt index a1199ff50dc..6f4e9650aad 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/VideoRecordingRecreationTest.kt @@ -49,6 +49,23 @@ class VideoRecordingRecreationTest { 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 From b8eaef7f48f7504061dc7d4d16d51ae50c978230 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 13:13:19 +0000 Subject: [PATCH 17/18] fix(attachment-sheet): offer "select more" for mixed media grants on Android 14 With READ_MEDIA_VISUAL_USER_SELECTED and only one of READ_MEDIA_IMAGES/VIDEO granted, the access resolves to FULL and the sheet hid the action. canSelectMoreMedia also covers that mix; media loading is unchanged. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/attachmentsheet/AttachmentSheet.kt | 6 +- .../talk/attachmentsheet/MediaAccess.kt | 13 +++++ .../attachmentsheet/CanSelectMoreMediaTest.kt | 55 +++++++++++++++++++ 3 files changed, 72 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentsheet/CanSelectMoreMediaTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt index c5721217cdb..5eb800d5dfd 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt @@ -127,7 +127,9 @@ fun AttachmentSheet(model: AttachmentSheetModel, callbacks: AttachmentSheetCallb private fun AttachmentSheetBody(model: AttachmentSheetModel, callbacks: AttachmentSheetCallbacks) { val context = LocalContext.current val refreshKey = rememberResumeRefreshKey() - val access = remember(refreshKey) { currentMediaAccess(context) } + val granted = remember(refreshKey) { grantedMediaPermissions(context) } + val access = resolveMediaAccess(Build.VERSION.SDK_INT, granted) + val canSelectMore = canSelectMoreMedia(Build.VERSION.SDK_INT, granted) val media by produceState?>(null, refreshKey, access) { value = if (access == MediaAccess.NONE) { emptyList() @@ -159,7 +161,7 @@ private fun AttachmentSheetBody(model: AttachmentSheetModel, callbacks: Attachme onToggle = { selection = selection.toggle(it.key) }, camera = CameraTileConfig(model.livePreviewEnabled, callbacks.onTakePhoto) .takeIf { AttachmentAction.PICTURE_FROM_CAM in model.actions }, - onSelectMore = permissionRequest.takeIf { access == MediaAccess.PARTIAL } + onSelectMore = permissionRequest.takeIf { canSelectMore } ) } } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt index 201a9e04cb1..49f84db94e9 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/MediaAccess.kt @@ -38,6 +38,19 @@ fun resolveMediaAccess(sdkInt: Int, granted: Set): MediaAccess = else -> MediaAccess.NONE } +/** + * Whether the "select more" action makes sense: the access is partial, or Android 14 holds the user-selected grant + * next to only one of the per-type permissions (photos without videos or the other way round). [resolveMediaAccess] + * reports FULL for that mix because media can be read, yet the second type is still limited to the picked items. + */ +fun canSelectMoreMedia(sdkInt: Int, granted: Set): Boolean = + resolveMediaAccess(sdkInt, granted) == MediaAccess.PARTIAL || + ( + sdkInt >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + READ_MEDIA_VISUAL_USER_SELECTED in granted && + !(Manifest.permission.READ_MEDIA_IMAGES in granted && Manifest.permission.READ_MEDIA_VIDEO in granted) + ) + /** * Whether the app may read files of the user without own picker: any media permission, including the partial * grant of Android 14, or audio. Used by flows that already hold a URI (recording, camera, share-to-Talk). diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/CanSelectMoreMediaTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/CanSelectMoreMediaTest.kt new file mode 100644 index 00000000000..b2a3e818667 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/CanSelectMoreMediaTest.kt @@ -0,0 +1,55 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentsheet + +import android.Manifest +import android.os.Build +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class CanSelectMoreMediaTest { + + private val images = Manifest.permission.READ_MEDIA_IMAGES + private val video = Manifest.permission.READ_MEDIA_VIDEO + private val storage = Manifest.permission.READ_EXTERNAL_STORAGE + private val selected = READ_MEDIA_VISUAL_USER_SELECTED + + @Test + fun selectMoreIsOfferedForPartialAccess() { + assertTrue(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(selected))) + } + + @Test + fun selectMoreIsOfferedForPhotosWithoutVideosOnAndroid14() { + assertTrue(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images, selected))) + } + + @Test + fun selectMoreIsOfferedForVideosWithoutPhotosOnAndroid14() { + assertTrue(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(video, selected))) + } + + @Test + fun selectMoreIsHiddenForFullAccessOnAndroid14() { + assertFalse(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images, video, selected))) + assertFalse(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images, video))) + } + + @Test + fun selectMoreIsHiddenWithoutUserSelectedGrantOnAndroid14() { + assertFalse(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, setOf(images))) + assertFalse(canSelectMoreMedia(Build.VERSION_CODES.UPSIDE_DOWN_CAKE, emptySet())) + } + + @Test + fun selectMoreIsHiddenBeforeAndroid14() { + assertFalse(canSelectMoreMedia(Build.VERSION_CODES.TIRAMISU, setOf(images, selected))) + assertFalse(canSelectMoreMedia(Build.VERSION_CODES.TIRAMISU, setOf(selected))) + assertFalse(canSelectMoreMedia(Build.VERSION_CODES.S, setOf(storage))) + } +} From f9b4751ba7a92e91fbf171f91ef878fe436c37c0 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 13:48:23 +0000 Subject: [PATCH 18/18] fix(attachment-sheet): re-read media grants when the permission request returns When the system grants without a dialog there is no pause/resume, so the sheet kept the old grants and the placeholder. The request result now bumps a key that the grants and the media list depend on. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../talk/attachmentsheet/AttachmentSheet.kt | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt index 5eb800d5dfd..6820db600f4 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentSheet.kt @@ -47,6 +47,7 @@ import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableIntState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf @@ -127,10 +128,10 @@ fun AttachmentSheet(model: AttachmentSheetModel, callbacks: AttachmentSheetCallb private fun AttachmentSheetBody(model: AttachmentSheetModel, callbacks: AttachmentSheetCallbacks) { val context = LocalContext.current val refreshKey = rememberResumeRefreshKey() - val granted = remember(refreshKey) { grantedMediaPermissions(context) } + val granted = remember(refreshKey.intValue) { grantedMediaPermissions(context) } val access = resolveMediaAccess(Build.VERSION.SDK_INT, granted) val canSelectMore = canSelectMoreMedia(Build.VERSION.SDK_INT, granted) - val media by produceState?>(null, refreshKey, access) { + val media by produceState?>(null, refreshKey.intValue, access) { value = if (access == MediaAccess.NONE) { emptyList() } else { @@ -141,7 +142,8 @@ private fun AttachmentSheetBody(model: AttachmentSheetModel, callbacks: Attachme LaunchedEffect(media) { media?.let { loaded -> selection = selection.retainAvailable(loaded.map { it.key }) } } - val permissionRequest = rememberMediaPermissionRequest() + // The system grants without a dialog (no pause, no resume) when the permission group is already granted + val permissionRequest = rememberMediaPermissionRequest { refreshKey.intValue++ } val hiddenBottomPx = rememberHiddenBottomPx() val hiddenBottomDp = with(LocalDensity.current) { hiddenBottomPx.value.toDp() } @@ -187,17 +189,18 @@ private fun AttachmentSheetBody(model: AttachmentSheetModel, callbacks: Attachme /** * Changes after the sheet's screen came back from the background (e.g. from the app settings), so the media list - * and the permissions are read again. The first resume while the sheet opens does not count. + * and the permissions are read again. The first resume while the sheet opens does not count. The caller also bumps + * it when a permission request returns. */ @Composable -private fun rememberResumeRefreshKey(): Int { - var refreshKey by remember { mutableIntStateOf(0) } +private fun rememberResumeRefreshKey(): MutableIntState { + val refreshKey = remember { mutableIntStateOf(0) } var wasPaused by remember { mutableStateOf(false) } LifecycleEventEffect(Lifecycle.Event.ON_PAUSE) { wasPaused = true } LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { if (wasPaused) { wasPaused = false - refreshKey++ + refreshKey.intValue++ } } return refreshKey @@ -232,14 +235,15 @@ private fun currentMediaAccess(context: Context): MediaAccess = /** * Asks for media access. When the system dialog will not appear again (denied with "don't ask again"), the call - * opens the app settings instead. + * opens the app settings instead. [onPermissionResult] runs when the request returns, whatever the answer. */ @Composable -private fun rememberMediaPermissionRequest(): () -> Unit { +private fun rememberMediaPermissionRequest(onPermissionResult: () -> Unit): () -> Unit { val context = LocalContext.current val activity = LocalActivity.current var blocked by remember { mutableStateOf(false) } val launcher = rememberLauncherForActivityResult(ActivityResultContracts.RequestMultiplePermissions()) { + onPermissionResult() blocked = currentMediaAccess(context) == MediaAccess.NONE && activity != null && mediaPermissionsToRequest(Build.VERSION.SDK_INT).none {