From a6fda72b7492d4d44c17edfe43d0d5cbf09cc5a2 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:19:52 +0000 Subject: [PATCH 01/29] 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 1d9b04b0e2..13f1a99bb0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -42,6 +42,7 @@ val androidxTestVersion = "1.5.0" val media3Version = "1.11.1" val coroutinesVersion = "1.11.0" val mockitoKotlinVersion = "6.4.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 30bbf4d565..e0312db52c 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.media.MediaMetadataRetriever import android.net.Uri import android.os.Build @@ -37,6 +38,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 @@ -48,6 +50,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 @@ -268,6 +271,8 @@ class ChatActivity : private lateinit var binding: ActivityChatBinding + private var videoMessageRecorder: VideoMessageRecorder? = null + @Inject lateinit var ncApi: NcApi @@ -2466,6 +2471,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( @@ -2819,6 +2929,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 @@ -2946,6 +3064,8 @@ class ChatActivity : override fun onPause() { super.onPause() + videoMessageRecorder?.cancel() + logConversationInfos("onPause") eventBus.unregister(this) @@ -4059,21 +4179,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) @@ -4091,21 +4197,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) @@ -4266,6 +4358,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 a5d3fa86c7..1919a101af 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 6832cfcf70..fa5c3d0404 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 0000000000..0db7cdb4e2 --- /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 0000000000..184ed7b0e8 --- /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 140f2f8b0e..a9ec59e4e5 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 @@ -25,6 +25,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 @@ -2380,6 +2381,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) @@ -2412,6 +2414,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 440a83f37e..b9e6324e7b 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 ccda4e3e03..3880a57752 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 16a10d70a5..a7d1b881a4 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 5ef9f26e22..ca6cec2cc9 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -744,6 +744,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 0000000000..ab4cdaa165 --- /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 6186c740eb024ba6e703b75cbdb5cdfa3fb1689c Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:19:58 +0000 Subject: [PATCH 02/29] 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 1919a101af..e2f3fd8b38 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 0000000000..e28574e531 --- /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 0000000000..c2163dd9e8 --- /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 0000000000..f3958f677d --- /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 0000000000..cbcbb257a7 --- /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 0000000000..90f22aefc9 --- /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 0000000000..ab89e0fa73 --- /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 0000000000..e6bc2584e0 --- /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 ae71b0219ccd9d9b7ce5291e011d49508bf178dc Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:21:11 +0000 Subject: [PATCH 03/29] 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 0000000000..b9176f4700 --- /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 e0312db52c..6ea2ddf1a7 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -35,6 +35,7 @@ import android.provider.ContactsContract import android.provider.MediaStore import android.text.TextUtils import android.util.Log +import android.view.Surface import android.view.View import android.view.ViewConfiguration import android.view.ViewGroup @@ -271,7 +272,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 { @@ -1423,6 +1425,7 @@ class ChatActivity : override fun onStart() { super.onStart() active = true + orientationTracker.enable() this.lifecycle.addObserver(AudioUtils) this.lifecycle.addObserver(chatViewModel) @@ -1463,8 +1466,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) @@ -2491,21 +2500,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, @@ -2517,7 +2531,7 @@ class ChatActivity : fun stopAndDiscardRecording() { if (chatViewModel.activeRecordingMode == RecordInputMode.VIDEO) { - videoMessageRecorder?.cancel() + chatViewModel.activeVideoMessageRecorder?.cancel() } else { chatViewModel.stopAndDiscardAudioRecording() } @@ -2546,10 +2560,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 @@ -2559,7 +2604,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 } @@ -3064,8 +3111,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 0000000000..ac584bf336 --- /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 184ed7b0e8..f542c3b0c7 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 a9ec59e4e5..87eccf583b 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 @@ -26,6 +26,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 @@ -2449,6 +2450,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 0000000000..a4c8d6f0fd --- /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 0000000000..c4ec2ec9df --- /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 0000000000..abb787678d --- /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 64fbec68f6ebfaab6e23f538bc2d2cd2bafaa473 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:21:15 +0000 Subject: [PATCH 04/29] 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 a7d1b881a4..8f16349a3c 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 05/29] 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 4c27ccc2c8..530b0e0dbb 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 0000000000..e81ebd475c --- /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 0000000000..e363ce42f3 --- /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 0000000000..c5721217cd --- /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 0000000000..45507becf2 --- /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 0000000000..c016d9f697 --- /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 0000000000..492a22ee36 --- /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 0000000000..d5fd5e5a6e --- /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 0000000000..01f8959ee6 --- /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 6ea2ddf1a7..44539bd48c 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -112,6 +112,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 @@ -428,6 +434,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 @@ -1086,6 +1093,8 @@ class ChatActivity : ) } } + + AttachmentSheetHost() } } } @@ -3040,7 +3049,7 @@ class ChatActivity : ) } - fun sendSelectLocalFileIntent() { + private fun sendSelectLocalFileIntent() { if (!permissionUtil.isFilesPermissionGranted()) { requestReadFilesPermissions() } else { @@ -3048,17 +3057,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") // location permission and location services are optional: without them, a place can still be picked on the @@ -4218,7 +4227,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 { @@ -4236,7 +4310,7 @@ class ChatActivity : } } - fun sendVideoFromCamIntent() { + private fun sendVideoFromCamIntent() { if (!permissionUtil.isCameraPermissionGranted()) { requestCameraPermissions() } else { @@ -4254,12 +4328,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 e2f3fd8b38..b41ef08f03 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 a1cdb1e73c..0000000000 --- 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 74cc84ebb8..0000000000 --- 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 ca6cec2cc9..154bcdce2d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -696,6 +696,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 0000000000..5e0fe503d4 --- /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 0000000000..1c81d22869 --- /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 0000000000..dc5f338a65 --- /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 0000000000..322fee064c --- /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 b56b95fc91c4c3fb29240cddd918314d1205991d Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 08:30:27 +0000 Subject: [PATCH 06/29] 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 c016d9f697..ee93a29af4 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 f29ef0e4bc..6f3e8eff51 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( @@ -102,6 +96,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 1c81d22869..b689647192 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 05e19c4f14d0316b77e1890a0970914db675ceb1 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 09:03:34 +0000 Subject: [PATCH 07/29] feat(chat): take photos with an in-app camera instead of the system one Add a full screen CameraX capture screen (shutter, front/back lens, flash off/auto/on, close) behind the TakePhotoInApp result contract. The photo is returned right after the shutter, without a confirmation step of its own, and goes to the attachment preview. The camera tile, the camera action of the attachment sheet and the photo button of the preview use it. The system ACTION_IMAGE_CAPTURE path is removed; video capture is unchanged. The capture screen asks CameraX for the full 4:3 resolution explicitly, ignores a second shutter tap while a photo is taken and keeps the controls on the device body when the device is rotated. rotationForDeviceOrientation() is shared with the video message recorder. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- app/src/main/AndroidManifest.xml | 7 + .../attachmentpreview/CameraCaptureActions.kt | 24 +- .../nextcloud/talk/camera/CaptureActivity.kt | 61 ++++ .../talk/camera/CaptureOrientation.kt | 57 ++++ .../com/nextcloud/talk/camera/PhotoCamera.kt | 173 ++++++++++++ .../talk/camera/PhotoCaptureLogic.kt | 91 ++++++ .../talk/camera/PhotoCaptureScreen.kt | 262 ++++++++++++++++++ .../nextcloud/talk/camera/TakePhotoInApp.kt | 24 ++ .../com/nextcloud/talk/chat/ChatActivity.kt | 39 ++- .../talk/chat/VideoMessageRecorder.kt | 22 +- .../drawable/ic_baseline_flash_auto_24.xml | 16 ++ .../res/drawable/ic_baseline_flash_off_24.xml | 16 ++ .../res/drawable/ic_baseline_flash_on_24.xml | 16 ++ app/src/main/res/values/strings.xml | 3 + .../talk/camera/CaptureOrientationTest.kt | 99 +++++++ .../talk/camera/PhotoCaptureLogicTest.kt | 128 +++++++++ 16 files changed, 985 insertions(+), 53 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt create mode 100644 app/src/main/java/com/nextcloud/talk/camera/CaptureOrientation.kt create mode 100644 app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt create mode 100644 app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt create mode 100644 app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt create mode 100644 app/src/main/java/com/nextcloud/talk/camera/TakePhotoInApp.kt create mode 100644 app/src/main/res/drawable/ic_baseline_flash_auto_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_flash_off_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_flash_on_24.xml create mode 100644 app/src/test/java/com/nextcloud/talk/camera/CaptureOrientationTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/camera/PhotoCaptureLogicTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 530b0e0dbb..5cbb67ee91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -192,6 +192,13 @@ + + diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt index 0b987af8cb..762f458bdc 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CameraCaptureActions.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.platform.LocalContext import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import com.nextcloud.talk.camera.TakePhotoInApp import com.nextcloud.talk.utils.FileUtils import java.io.File import java.text.SimpleDateFormat @@ -38,7 +39,10 @@ internal fun rememberCameraCaptureActions(currentFiles: MutableList): Ca var pendingCameraUri by remember { mutableStateOf(null) } var pendingCameraPermissionType by remember { mutableStateOf(null) } - val onCaptureResult: (Boolean) -> Unit = { success -> + val takePhoto = rememberLauncherForActivityResult(TakePhotoInApp()) { uri -> + uri?.let { currentFiles.add(it.toString()) } + } + val captureVideo = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo()) { success -> val uri = pendingCameraUri if (success && uri != null) { currentFiles.add(uri.toString()) @@ -46,15 +50,14 @@ internal fun rememberCameraCaptureActions(currentFiles: MutableList): Ca pendingCameraUri = null } - val takePicture = rememberLauncherForActivityResult(ActivityResultContracts.TakePicture(), onCaptureResult) - val captureVideo = rememberLauncherForActivityResult(ActivityResultContracts.CaptureVideo(), onCaptureResult) - fun launchCameraCapture(type: CameraCaptureType) { - val uri = createCameraOutputUri(context, type) - pendingCameraUri = uri when (type) { - CameraCaptureType.PHOTO -> takePicture.launch(uri) - CameraCaptureType.VIDEO -> captureVideo.launch(uri) + CameraCaptureType.PHOTO -> takePhoto.launch(Unit) + CameraCaptureType.VIDEO -> { + val uri = createVideoOutputUri(context) + pendingCameraUri = uri + captureVideo.launch(uri) + } } } @@ -85,10 +88,9 @@ internal fun rememberCameraCaptureActions(currentFiles: MutableList): Ca ) } -private fun createCameraOutputUri(context: Context, type: CameraCaptureType): Uri { +private fun createVideoOutputUri(context: Context): Uri { val outputDir = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) ?: context.cacheDir val timestamp = SimpleDateFormat(CAMERA_FILE_DATE_PATTERN, Locale.ROOT).format(Date()) - val extension = if (type == CameraCaptureType.PHOTO) "jpg" else "mp4" - val file = File(outputDir, "$timestamp.$extension") + val file = File(outputDir, "$timestamp.mp4") return FileProvider.getUriForFile(context, context.packageName, file) } diff --git a/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt b/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt new file mode 100644 index 0000000000..c491f08d07 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt @@ -0,0 +1,61 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Bundle +import android.widget.Toast +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.core.content.ContextCompat +import androidx.core.content.FileProvider +import com.nextcloud.talk.R +import java.io.File +import java.util.Date + +/** + * Takes one photo with the in-app camera and returns its content uri as the result data. There is no confirmation + * step: the shutter finishes the activity. Without the camera permission the activity ends as cancelled, the caller + * asks for the permission before it starts this screen. + */ +class CaptureActivity : ComponentActivity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + val hasPermission = ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + if (!hasPermission) { + finish() + return + } + setContent { + PhotoCaptureScreen( + newPhotoFile = ::newPhotoFile, + onCaptured = ::returnPhoto, + onFailed = { + Toast.makeText(this, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() + }, + onClose = ::finish + ) + } + } + + private fun newPhotoFile(): File? { + val baseName = getString(R.string.nc_picture_filename, formatCaptureTimestamp(Date())) + return createPhotoFile(cacheDir, baseName) + } + + private fun returnPhoto(file: File) { + val uri = FileProvider.getUriForFile(this, packageName, file) + setResult(RESULT_OK, Intent().setData(uri)) + finish() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/camera/CaptureOrientation.kt b/app/src/main/java/com/nextcloud/talk/camera/CaptureOrientation.kt new file mode 100644 index 0000000000..e6d90877f6 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/camera/CaptureOrientation.kt @@ -0,0 +1,57 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import android.view.Surface + +private const val ROTATIONS = 4 +private const val QUARTER_TURN = 90 +private const val HALF_TURN = 180 +private const val FULL_TURN = 360 + +/** + * A spot on the device body in the natural orientation: [x] is -1 at the left edge, 0 in the middle and 1 at the + * right edge, [y] is -1 at the top edge, 0 in the middle and 1 at the bottom edge. + */ +internal data class BodyPoint(val x: Int, val y: Int) + +/** + * Where a [BodyPoint] lies in the window when the display has the given [Surface] rotation. The capture controls are + * tied to the device body, so the shutter stays under the same finger in every window rotation, whether the system + * kept the window in the natural orientation or turned it. + * + * ROTATION_90 means the device was turned counter-clockwise: the natural bottom edge is on the right of the window. + */ +internal fun BodyPoint.inWindow(displayRotation: Int): BodyPoint = + when (displayRotation) { + Surface.ROTATION_90 -> BodyPoint(y, -x) + Surface.ROTATION_180 -> BodyPoint(-x, -y) + Surface.ROTATION_270 -> BodyPoint(-y, x) + else -> this + } + +/** + * Clockwise angle (0, 90, 180 or 270 degrees) that keeps an icon upright for the user. [deviceOrientation] is the + * angle of OrientationEventListener, [displayRotation] the [Surface] rotation of the window. Returns null while the + * device orientation is unknown. + * + * The photo rotation is the display rotation the window would need to be upright for the current hold, so the icon + * turns by the difference between it and the rotation the window really has. + */ +internal fun iconRotationDegrees(deviceOrientation: Int, displayRotation: Int): Int? { + val upright = rotationForDeviceOrientation(deviceOrientation) ?: return null + return Math.floorMod(upright - displayRotation, ROTATIONS) * QUARTER_TURN +} + +/** + * The value to animate an icon to, so that it reaches [targetDegrees] by the shortest way. [currentDegrees] is + * the unbounded angle shown now; a turn from 0 to 270 gives -90, not 270. + */ +internal fun shortestRotationTarget(currentDegrees: Float, targetDegrees: Int): Float { + val delta = Math.floorMod((targetDegrees - currentDegrees).toInt() + HALF_TURN, FULL_TURN) - HALF_TURN + return currentDegrees + delta +} diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt new file mode 100644 index 0000000000..584f75acb7 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt @@ -0,0 +1,173 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import android.content.Context +import android.util.Log +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.ImageCaptureException +import androidx.camera.core.Preview +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleOwner +import com.nextcloud.talk.chat.bindSafely +import com.nextcloud.talk.chat.cameraSelectorFor +import com.nextcloud.talk.chat.oppositeLens +import com.nextcloud.talk.chat.resolveLens +import com.nextcloud.talk.chat.whenCameraProviderReady +import java.io.File + +/** + * Still-photo camera of the capture screen: a live preview plus [ImageCapture] on the front or the back lens. + * + * All methods must be called on the main thread. The observable properties drive the capture screen. + */ +internal class PhotoCamera( + private val context: Context, + private val lifecycleOwner: LifecycleOwner, + private val previewView: PreviewView +) { + var lensFacing by mutableIntStateOf(CameraSelector.LENS_FACING_BACK) + private set + var flash by mutableStateOf(FlashSetting.OFF) + private set + var hasFlashUnit by mutableStateOf(false) + private set + var canSwitchLens by mutableStateOf(false) + private set + var isBound by mutableStateOf(false) + private set + var isCapturing by mutableStateOf(false) + private set + + private var provider: ProcessCameraProvider? = null + private var preview: Preview? = null + private var imageCapture: ImageCapture? = null + private var released = false + + fun start() { + whenCameraProviderReady(context) { readyProvider -> + if (released || readyProvider == null) return@whenCameraProviderReady + provider = readyProvider + // No target rotation: the preview follows the display, the photo follows setTargetRotation(). + preview = Preview.Builder().build() + imageCapture = ImageCapture.Builder() + .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) + .setResolutionSelector(photoResolutionSelector()) + .build() + canSwitchLens = readyProvider.hasCamera(cameraSelectorFor(CameraSelector.LENS_FACING_BACK)) && + readyProvider.hasCamera(cameraSelectorFor(CameraSelector.LENS_FACING_FRONT)) + bind(resolveLens(readyProvider, lensFacing)) + } + } + + fun switchLens() { + if (provider == null || !canSwitchLens || isCapturing) return + val previousLens = lensFacing + unbind() + if (!bind(oppositeLens(previousLens))) { + bind(previousLens) + } + } + + fun cycleFlash() { + if (!hasFlashUnit) return + flash = flash.next() + imageCapture?.flashMode = effectiveFlashMode(flash, hasFlashUnit) + } + + /** + * Sets the rotation of the next photo, so the picture is upright however the phone is held. + */ + fun setTargetRotation(rotation: Int) { + imageCapture?.targetRotation = rotation + } + + /** + * Writes a JPEG with EXIF orientation to [file]. [onResult] gets true when the file is complete. + */ + fun takePicture(file: File, onResult: (Boolean) -> Unit) { + val capture = imageCapture + if (capture == null || !isBound || isCapturing) { + onResult(false) + return + } + isCapturing = true + val options = ImageCapture.OutputFileOptions.Builder(file).build() + capture.takePicture( + options, + ContextCompat.getMainExecutor(context), + object : ImageCapture.OnImageSavedCallback { + override fun onImageSaved(output: ImageCapture.OutputFileResults) { + isCapturing = false + onResult(true) + } + + override fun onError(exception: ImageCaptureException) { + Log.w(TAG, "taking the photo failed", exception) + isCapturing = false + onResult(false) + } + } + ) + } + + fun release() { + released = true + unbind() + } + + private fun bind(lens: Int): Boolean { + val previewUseCase = preview + val captureUseCase = imageCapture + val camera = if (previewUseCase != null && captureUseCase != null) { + previewUseCase.surfaceProvider = previewView.surfaceProvider + provider?.bindSafely(lifecycleOwner, lens, previewUseCase, captureUseCase) + } else { + null + } + isBound = camera != null + if (camera != null) { + onBound(camera, lens) + } + return isBound + } + + private fun onBound(camera: Camera, lens: Int) { + lensFacing = lens + hasFlashUnit = camera.cameraInfo.hasFlashUnit() + imageCapture?.flashMode = effectiveFlashMode(flash, hasFlashUnit) + logCaptureResolution() + } + + private fun logCaptureResolution() { + val info = imageCapture?.resolutionInfo ?: return + val size = info.resolution + Log.d( + TAG, + "photo resolution ${size.width}x${size.height}, " + + "displayed ${describeCaptureResolution(size.width, size.height, info.rotationDegrees)}" + ) + } + + private fun unbind() { + preview?.let { provider?.unbind(it) } + imageCapture?.let { provider?.unbind(it) } + isBound = false + } + + private companion object { + private val TAG = PhotoCamera::class.java.simpleName + } +} diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt new file mode 100644 index 0000000000..c9c15bdfa0 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt @@ -0,0 +1,91 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import android.view.Surface +import androidx.camera.core.ImageCapture +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import com.nextcloud.talk.utils.FileUtils +import java.io.File +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private const val FILE_DATE_PATTERN = "yyyy-MM-dd HH-mm-ss" +private const val PHOTO_SUFFIX = ".jpg" +private const val MAX_FILE_VARIANTS = 100 +private const val ORIENTATION_QUARTER = 90 +private const val ORIENTATION_EIGHTH = 45 +private const val FULL_CIRCLE = 360 +private const val HALF_TURN = 180 + +// 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 +) + +/** + * Flash choice of the capture screen. [next] is the order of the flash button: off, auto, on. + */ +internal enum class FlashSetting(val imageCaptureMode: Int) { + OFF(ImageCapture.FLASH_MODE_OFF), + AUTO(ImageCapture.FLASH_MODE_AUTO), + ON(ImageCapture.FLASH_MODE_ON); + + fun next(): FlashSetting = entries[(ordinal + 1) % entries.size] +} + +/** + * The flash mode to apply: a camera without a flash unit always gets [ImageCapture.FLASH_MODE_OFF]. + */ +internal fun effectiveFlashMode(setting: FlashSetting, hasFlashUnit: Boolean): Int = + if (hasFlashUnit) setting.imageCaptureMode else ImageCapture.FLASH_MODE_OFF + +/** + * Maps the device orientation reported by OrientationEventListener (degrees, clockwise from upright) to the + * [Surface] rotation for ImageCapture, 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] +} + +/** + * Full sensor resolution at 4:3 (the aspect ratio of the sensor), falling back to the closest ratio the camera + * offers. Ask for it explicitly so the photo size doesn't depend on the library's defaults. + */ +internal fun photoResolutionSelector(): ResolutionSelector = + ResolutionSelector.Builder() + .setAspectRatioStrategy(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY) + .setResolutionStrategy(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY) + .build() + +/** + * Pixel size of the photo as it looks once the EXIF [rotationDegrees] is applied, e.g. "3000x4000". + */ +internal fun describeCaptureResolution(width: Int, height: Int, rotationDegrees: Int): String = + if (rotationDegrees % HALF_TURN == 0) "${width}x$height" else "${height}x$width" + +internal fun formatCaptureTimestamp(date: Date): String = SimpleDateFormat(FILE_DATE_PATTERN, Locale.ROOT).format(date) + +/** + * Creates the target of a photo in the shared attachments cache directory, the one the file provider exposes. + * An existing file is never reused: the name gets a number instead. Returns null when no usable name exists. + */ +internal fun createPhotoFile(cacheDir: File, baseName: String): File? { + val directory = FileUtils.getSharedAttachmentsDirectory(cacheDir) ?: return null + return (0 until MAX_FILE_VARIANTS) + .asSequence() + .map { if (it == 0) baseName else "$baseName ($it)" } + .mapNotNull { FileUtils.resolveFileInDirectory(directory, it + PHOTO_SUFFIX) } + .firstOrNull { !it.exists() } +} diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt new file mode 100644 index 0000000000..9b38760e55 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt @@ -0,0 +1,262 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import android.hardware.display.DisplayManager +import android.view.OrientationEventListener +import android.view.Surface +import androidx.camera.view.PreviewView +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBarsPadding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.BiasAlignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +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.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.lifecycle.compose.LocalLifecycleOwner +import com.nextcloud.talk.R +import java.io.File + +private val ControlSize = 48.dp +private val ShutterSize = 72.dp +private val ShutterRingWidth = 4.dp +private val ScreenPadding = 16.dp +private val LensSwitchGap = 84.dp +private const val DISABLED_ALPHA = 0.4f + +/** + * Full screen photo capture: preview, close and flash at the top edge of the device body, shutter and lens switch at + * its bottom edge. + * [newPhotoFile] makes the target of a photo, [onCaptured] gets the finished file, [onFailed] the target of a + * photo that could not be written. + */ +@Composable +internal fun PhotoCaptureScreen( + newPhotoFile: () -> File?, + onCaptured: (File) -> Unit, + onFailed: () -> Unit, + onClose: () -> Unit +) { + val context = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + val previewView = remember { + PreviewView(context).apply { + scaleType = PreviewView.ScaleType.FILL_CENTER + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + } + } + val camera = remember { PhotoCamera(context, lifecycleOwner, previewView) } + + var deviceOrientation by remember { mutableIntStateOf(0) } + + DisposableEffect(camera) { + camera.start() + val orientationListener = object : OrientationEventListener(context) { + override fun onOrientationChanged(orientation: Int) { + val rotation = rotationForDeviceOrientation(orientation) ?: return + camera.setTargetRotation(rotation) + if (rotationForDeviceOrientation(deviceOrientation) != rotation) { + deviceOrientation = orientation + } + } + } + orientationListener.enable() + onDispose { + orientationListener.disable() + camera.release() + } + } + + val displayRotation = rememberDisplayRotation() + val iconAngle = rememberIconAngle(iconRotationDegrees(deviceOrientation, displayRotation) ?: 0) + + Box(Modifier.fillMaxSize().background(Color.Black)) { + AndroidView(factory = { previewView }, modifier = Modifier.fillMaxSize()) + CaptureControls(camera, displayRotation, iconAngle, onClose) { + if (camera.isCapturing || !camera.isBound) return@CaptureControls + val file = newPhotoFile() + if (file == null) { + onFailed() + } else { + camera.takePicture(file) { success -> + if (success) { + onCaptured(file) + } else { + file.delete() + onFailed() + } + } + } + } + } +} + +/** + * The window rotation of the display ([android.view.Surface] constant). The activity handles configuration changes + * itself and a half turn is no configuration change, so the display is observed. + */ +@Composable +private fun rememberDisplayRotation(): Int { + val view = LocalView.current + val context = LocalContext.current + var rotation by remember { mutableIntStateOf(view.display?.rotation ?: Surface.ROTATION_0) } + DisposableEffect(view) { + val manager = context.getSystemService(DisplayManager::class.java) + val listener = object : DisplayManager.DisplayListener { + override fun onDisplayAdded(displayId: Int) = Unit + + override fun onDisplayRemoved(displayId: Int) = Unit + + override fun onDisplayChanged(displayId: Int) { + view.display?.let { rotation = it.rotation } + } + } + manager.registerDisplayListener(listener, null) + rotation = view.display?.rotation ?: rotation + onDispose { manager.unregisterDisplayListener(listener) } + } + return rotation +} + +/** + * The animated icon angle for [targetDegrees]: every change takes the shortest way round. + */ +@Composable +private fun rememberIconAngle(targetDegrees: Int): Float { + var unbounded by remember { mutableFloatStateOf(targetDegrees.toFloat()) } + LaunchedEffect(targetDegrees) { + unbounded = shortestRotationTarget(unbounded, targetDegrees) + } + return animateFloatAsState(unbounded, label = "captureIconAngle").value +} + +/** + * Controls tied to the device body, not to the window: the shutter stays at the natural bottom edge, close and + * flash at the natural top edge, the lens switch next to the shutter, whatever rotation the window has. + */ +@Composable +private fun CaptureControls( + camera: PhotoCamera, + displayRotation: Int, + iconAngle: Float, + onClose: () -> Unit, + onShutter: () -> Unit +) { + Box(Modifier.fillMaxSize().systemBarsPadding().padding(ScreenPadding)) { + fun BoxScope.at(x: Int, y: Int): Modifier = + Modifier.align(BodyPoint(x, y).inWindow(displayRotation).toAlignment()) + + CaptureIconButton( + icon = R.drawable.ic_baseline_close_24, + description = stringResource(R.string.close), + angle = iconAngle, + modifier = at(-1, -1), + onClick = onClose + ) + if (camera.hasFlashUnit) { + val (icon, description) = flashPresentation(camera.flash) + CaptureIconButton( + icon = icon, + description = stringResource(description), + angle = iconAngle, + modifier = at(1, -1), + onClick = camera::cycleFlash + ) + } + val enabled = camera.isBound && !camera.isCapturing + val shutterDescription = stringResource(R.string.take_photo) + Box( + modifier = at(0, 1) + .size(ShutterSize) + .alpha(if (enabled) 1f else DISABLED_ALPHA) + .border(ShutterRingWidth, Color.White, CircleShape) + .padding(ShutterRingWidth * 2) + .clip(CircleShape) + .background(Color.White) + .semantics { + contentDescription = shutterDescription + role = Role.Button + } + .clickable(enabled = enabled, onClick = onShutter) + ) + if (camera.canSwitchLens) { + // Beside the shutter along the edge, and centered on it across the edge + val beside = BodyPoint(1, 0).inWindow(displayRotation) + val inward = BodyPoint(0, -1).inWindow(displayRotation) + val centering = (ShutterSize - ControlSize) / 2 + CaptureIconButton( + icon = R.drawable.ic_baseline_flip_camera_android_24, + description = stringResource(R.string.nc_video_recording_switch_camera), + angle = iconAngle, + modifier = at(0, 1).offset( + LensSwitchGap * beside.x + centering * inward.x, + LensSwitchGap * beside.y + centering * inward.y + ), + onClick = camera::switchLens + ) + } + } +} + +private fun BodyPoint.toAlignment(): Alignment = BiasAlignment(x.toFloat(), y.toFloat()) + +@Composable +private fun CaptureIconButton(icon: Int, description: String, angle: Float, modifier: Modifier, onClick: () -> Unit) { + Box( + modifier = modifier + .size(ControlSize) + .clip(CircleShape) + .background(Color.Black.copy(alpha = 0.4f)) + .clickable(role = Role.Button, onClick = onClick), + contentAlignment = Alignment.Center + ) { + Icon( + painter = painterResource(icon), + contentDescription = description, + tint = Color.White, + modifier = Modifier.rotate(angle) + ) + } +} + +private fun flashPresentation(setting: FlashSetting): Pair = + when (setting) { + FlashSetting.OFF -> R.drawable.ic_baseline_flash_off_24 to R.string.nc_photo_capture_flash_off + FlashSetting.AUTO -> R.drawable.ic_baseline_flash_auto_24 to R.string.nc_photo_capture_flash_auto + FlashSetting.ON -> R.drawable.ic_baseline_flash_on_24 to R.string.nc_photo_capture_flash_on + } diff --git a/app/src/main/java/com/nextcloud/talk/camera/TakePhotoInApp.kt b/app/src/main/java/com/nextcloud/talk/camera/TakePhotoInApp.kt new file mode 100644 index 0000000000..97f944e1a4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/camera/TakePhotoInApp.kt @@ -0,0 +1,24 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.activity.result.contract.ActivityResultContract + +/** + * Opens [CaptureActivity]. The result is the uri of the photo, or null when the user closed the camera. + * The camera permission must be granted before the contract is launched. + */ +class TakePhotoInApp : ActivityResultContract() { + override fun createIntent(context: Context, input: Unit): Intent = Intent(context, CaptureActivity::class.java) + + override fun parseResult(resultCode: Int, intent: Intent?): Uri? = + intent?.data.takeIf { resultCode == Activity.RESULT_OK } +} 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 44539bd48c..d40b8801e9 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -118,6 +118,7 @@ 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.camera.TakePhotoInApp import com.nextcloud.talk.chat.data.io.VoiceMessageMediaService import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.chat.data.model.FileParameters @@ -366,6 +367,10 @@ class ChatActivity : } } + private val takePhotoInApp = registerForActivityResult(TakePhotoInApp()) { uri -> + uri?.let { showCapturedFile(it) } + } + override val view: View get() = binding.root @@ -2876,14 +2881,17 @@ class ChatActivity : @Throws(IllegalStateException::class) private fun onPickCameraResult(intent: Intent?) { + // The system camera app is only guaranteed to write to the URI passed via EXTRA_OUTPUT; + // whether it also populates the result intent's data is device/vendor-dependent, so the + // URI we supplied up front is the one source of truth here. + val uri = pendingCameraUri ?: intent?.data + pendingCameraUri = null + showCapturedFile(uri) + } + + private fun showCapturedFile(uri: Uri?) { try { filesToUpload.clear() - - // The system camera app is only guaranteed to write to the URI passed via EXTRA_OUTPUT; - // whether it also populates the result intent's data is device/vendor-dependent, so the - // URI we supplied up front is the one source of truth here. - val uri = pendingCameraUri ?: intent?.data - pendingCameraUri = null if (uri != null) { filesToUpload.add(uri.toString()) } else { @@ -4235,7 +4243,7 @@ class ChatActivity : onAction = { runAttachmentAction(it) }, onTakePhoto = { attachmentSheetModel = null - sendPictureFromCamIntent() + takePhotoWithInAppCamera() }, onSend = { attachmentSheetModel = null @@ -4280,7 +4288,7 @@ class ChatActivity : private fun runAttachmentAction(action: AttachmentAction) { attachmentSheetModel = null when (action) { - AttachmentAction.PICTURE_FROM_CAM -> sendPictureFromCamIntent() + AttachmentAction.PICTURE_FROM_CAM -> takePhotoWithInAppCamera() AttachmentAction.VIDEO_FROM_CAM -> sendVideoFromCamIntent() AttachmentAction.GALLERY -> showGalleryPicker() AttachmentAction.FILE_FROM_LOCAL -> sendSelectLocalFileIntent() @@ -4292,21 +4300,11 @@ class ChatActivity : } } - private fun sendPictureFromCamIntent() { + private fun takePhotoWithInAppCamera() { if (!permissionUtil.isCameraPermissionGranted()) { requestCameraPermissions() } else { - Intent(MediaStore.ACTION_IMAGE_CAPTURE).also { takePictureIntent -> - takePictureIntent.resolveActivity(packageManager)?.also { - val photoFile = createAttachmentFile(R.string.nc_picture_filename, PICTURE_SUFFIX) - - photoFile?.also { - pendingCameraUri = FileProvider.getUriForFile(context, context.packageName, it) - takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, pendingCameraUri) - startPickCameraIntentForResult.launch(takePictureIntent) - } - } - } + takePhotoInApp.launch(Unit) } } @@ -4480,7 +4478,6 @@ class ChatActivity : 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" private const val VOICE_MESSAGE_SEEKBAR_BASE = 1000 private const val HTTP_BAD_REQUEST = 400 private const val HTTP_FORBIDDEN = 403 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 f542c3b0c7..ae7ba56c62 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -30,6 +30,7 @@ import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry +import com.nextcloud.talk.camera.rotationForDeviceOrientation import java.io.File /** @@ -451,24 +452,3 @@ class DeviceOrientationTracker(context: Context) { 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/res/drawable/ic_baseline_flash_auto_24.xml b/app/src/main/res/drawable/ic_baseline_flash_auto_24.xml new file mode 100644 index 0000000000..585b040f78 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flash_auto_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_flash_off_24.xml b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml new file mode 100644 index 0000000000..39d3993642 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flash_off_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_flash_on_24.xml b/app/src/main/res/drawable/ic_baseline_flash_on_24.xml new file mode 100644 index 0000000000..5890bafef6 --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_flash_on_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 154bcdce2d..d44096ff4b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -849,6 +849,9 @@ How to translate with transifex: Take a photo + Flash off + Flash automatic + Flash on Taking a photo is not possible without permissions Camera permission granted. Please choose camera again. Take a video diff --git a/app/src/test/java/com/nextcloud/talk/camera/CaptureOrientationTest.kt b/app/src/test/java/com/nextcloud/talk/camera/CaptureOrientationTest.kt new file mode 100644 index 0000000000..3f660af6d3 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/camera/CaptureOrientationTest.kt @@ -0,0 +1,99 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import android.view.OrientationEventListener.ORIENTATION_UNKNOWN +import android.view.Surface +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CaptureOrientationTest { + + @Test + fun sensorAngleMapsToTargetRotationAtTheBoundaries() { + assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(0)) + assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(44)) + assertEquals(Surface.ROTATION_270, rotationForDeviceOrientation(45)) + assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(315)) + assertEquals(Surface.ROTATION_90, rotationForDeviceOrientation(314)) + assertEquals(Surface.ROTATION_180, rotationForDeviceOrientation(180)) + assertNull(rotationForDeviceOrientation(-1)) + assertNull(rotationForDeviceOrientation(ORIENTATION_UNKNOWN)) + } + + @Test + fun iconStaysUprightForTheHoldThatMatchesTheWindow() { + // Window in the natural orientation, phone held upright. + assertEquals(0, iconRotationDegrees(0, Surface.ROTATION_0)) + // Window turned counter-clockwise (ROTATION_90) and the phone really held that way (sensor 270). + assertEquals(0, iconRotationDegrees(270, Surface.ROTATION_90)) + assertEquals(0, iconRotationDegrees(90, Surface.ROTATION_270)) + assertEquals(0, iconRotationDegrees(180, Surface.ROTATION_180)) + } + + @Test + fun iconCompensatesAFixedWindow() { + // Phone turned clockwise by 90 in a window that stayed natural: the icon turns back by 90. + assertEquals(270, iconRotationDegrees(90, Surface.ROTATION_0)) + assertEquals(180, iconRotationDegrees(180, Surface.ROTATION_0)) + assertEquals(90, iconRotationDegrees(270, Surface.ROTATION_0)) + } + + @Test + fun iconCompensatesAWindowTurnedAgainstTheHold() { + // Phone upright but the window turned (large screen ignoring the orientation lock). + assertEquals(270, iconRotationDegrees(0, Surface.ROTATION_90)) + assertEquals(180, iconRotationDegrees(0, Surface.ROTATION_180)) + assertEquals(90, iconRotationDegrees(0, Surface.ROTATION_270)) + } + + @Test + fun iconAngleIsUnknownWithoutSensorData() { + assertNull(iconRotationDegrees(ORIENTATION_UNKNOWN, Surface.ROTATION_0)) + } + + @Test + fun animationTakesTheShortestWay() { + assertEquals(-90f, shortestRotationTarget(0f, 270), 0f) + assertEquals(90f, shortestRotationTarget(0f, 90), 0f) + assertEquals(-90f, shortestRotationTarget(-90f, 270), 0f) + assertEquals(-180f, shortestRotationTarget(-90f, 180), 0f) + assertEquals(360f, shortestRotationTarget(270f, 0), 0f) + assertEquals(0f, shortestRotationTarget(0f, 0), 0f) + assertEquals(-180f, shortestRotationTarget(0f, 180), 0f) + } + + @Test + fun shutterStaysAtTheNaturalBottomEdgeWhateverTheWindowRotation() { + val shutter = BodyPoint(0, 1) + assertEquals(BodyPoint(0, 1), shutter.inWindow(Surface.ROTATION_0)) + assertEquals(BodyPoint(1, 0), shutter.inWindow(Surface.ROTATION_90)) + assertEquals(BodyPoint(0, -1), shutter.inWindow(Surface.ROTATION_180)) + assertEquals(BodyPoint(-1, 0), shutter.inWindow(Surface.ROTATION_270)) + } + + @Test + fun closeAndFlashStayAtTheNaturalTopEdge() { + val close = BodyPoint(-1, -1) + val flash = BodyPoint(1, -1) + assertEquals(BodyPoint(-1, 1), close.inWindow(Surface.ROTATION_90)) + assertEquals(BodyPoint(-1, -1), flash.inWindow(Surface.ROTATION_90)) + assertEquals(BodyPoint(1, -1), close.inWindow(Surface.ROTATION_270)) + assertEquals(BodyPoint(1, 1), flash.inWindow(Surface.ROTATION_270)) + assertEquals(BodyPoint(1, 1), close.inWindow(Surface.ROTATION_180)) + } + + @Test + fun lensSwitchDirectionFollowsTheBody() { + val right = BodyPoint(1, 0) + assertEquals(BodyPoint(1, 0), right.inWindow(Surface.ROTATION_0)) + assertEquals(BodyPoint(0, -1), right.inWindow(Surface.ROTATION_90)) + assertEquals(BodyPoint(-1, 0), right.inWindow(Surface.ROTATION_180)) + assertEquals(BodyPoint(0, 1), right.inWindow(Surface.ROTATION_270)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/camera/PhotoCaptureLogicTest.kt b/app/src/test/java/com/nextcloud/talk/camera/PhotoCaptureLogicTest.kt new file mode 100644 index 0000000000..dca843363c --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/camera/PhotoCaptureLogicTest.kt @@ -0,0 +1,128 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import android.view.Surface +import androidx.camera.core.CameraSelector +import androidx.camera.core.ImageCapture +import androidx.camera.core.resolutionselector.AspectRatioStrategy +import androidx.camera.core.resolutionselector.ResolutionStrategy +import com.nextcloud.talk.chat.oppositeLens +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.util.Date + +class PhotoCaptureLogicTest { + + @get:Rule + val folder = TemporaryFolder() + + @Test + fun flashCyclesOffAutoOnAndBack() { + assertEquals(FlashSetting.AUTO, FlashSetting.OFF.next()) + assertEquals(FlashSetting.ON, FlashSetting.AUTO.next()) + assertEquals(FlashSetting.OFF, FlashSetting.ON.next()) + } + + @Test + fun flashSettingMapsToImageCaptureMode() { + assertEquals(ImageCapture.FLASH_MODE_OFF, effectiveFlashMode(FlashSetting.OFF, true)) + assertEquals(ImageCapture.FLASH_MODE_AUTO, effectiveFlashMode(FlashSetting.AUTO, true)) + assertEquals(ImageCapture.FLASH_MODE_ON, effectiveFlashMode(FlashSetting.ON, true)) + } + + @Test + fun cameraWithoutFlashUnitNeverFires() { + FlashSetting.entries.forEach { + assertEquals(ImageCapture.FLASH_MODE_OFF, effectiveFlashMode(it, false)) + } + } + + @Test + fun lensSwitchesBetweenFrontAndBack() { + assertEquals(CameraSelector.LENS_FACING_BACK, oppositeLens(CameraSelector.LENS_FACING_FRONT)) + assertEquals(CameraSelector.LENS_FACING_FRONT, oppositeLens(CameraSelector.LENS_FACING_BACK)) + } + + @Test + fun deviceOrientationMapsToSurfaceRotation() { + assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(0)) + assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(44)) + assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(315)) + assertEquals(Surface.ROTATION_0, rotationForDeviceOrientation(359)) + assertEquals(Surface.ROTATION_270, rotationForDeviceOrientation(45)) + assertEquals(Surface.ROTATION_270, rotationForDeviceOrientation(90)) + assertEquals(Surface.ROTATION_180, rotationForDeviceOrientation(180)) + assertEquals(Surface.ROTATION_90, rotationForDeviceOrientation(270)) + assertEquals(Surface.ROTATION_90, rotationForDeviceOrientation(314)) + } + + @Test + fun unknownDeviceOrientationKeepsRotation() { + assertNull(rotationForDeviceOrientation(ORIENTATION_UNKNOWN)) + } + + @Test + fun photoFileLandsInSharedAttachmentsDirectory() { + val file = createPhotoFile(folder.root, "Picture from 2026-10-04 12-00-00") + + assertNotNull(file) + assertEquals("Picture from 2026-10-04 12-00-00.jpg", file!!.name) + assertEquals("shared_attachments", file.parentFile!!.name) + assertTrue(file.parentFile!!.isDirectory) + } + + @Test + fun existingPhotoFileIsNotReused() { + val first = createPhotoFile(folder.root, "photo")!! + first.writeText("data") + + val second = createPhotoFile(folder.root, "photo")!! + + assertNotEquals(first, second) + assertEquals("photo (1).jpg", second.name) + } + + @Test + fun unsafePhotoNameIsRefused() { + assertNull(createPhotoFile(folder.root, "../escape")) + } + + @Test + fun timestampHasFileSafeFormat() { + val text = formatCaptureTimestamp(Date(0)) + + assertTrue(Regex("\\d{4}-\\d{2}-\\d{2} \\d{2}-\\d{2}-\\d{2}").matches(text)) + } + + @Test + fun photoSelectorAsksForHighestResolutionAtFourToThree() { + val selector = photoResolutionSelector() + + assertSame(AspectRatioStrategy.RATIO_4_3_FALLBACK_AUTO_STRATEGY, selector.aspectRatioStrategy) + assertSame(ResolutionStrategy.HIGHEST_AVAILABLE_STRATEGY, selector.resolutionStrategy) + } + + @Test + fun captureResolutionIsDescribedInDisplayOrientation() { + assertEquals("4000x3000", describeCaptureResolution(4000, 3000, 0)) + assertEquals("4000x3000", describeCaptureResolution(4000, 3000, 180)) + assertEquals("3000x4000", describeCaptureResolution(4000, 3000, 90)) + assertEquals("3000x4000", describeCaptureResolution(4000, 3000, 270)) + } + + private companion object { + const val ORIENTATION_UNKNOWN = -1 + } +} From 0cfcf9dc7408c5afd5f463487838effc48b2f69e Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 09:10:03 +0000 Subject: [PATCH 08/29] feat(chat): full-screen attachment preview with photo editing Rework the pre-send attachment preview: the photo fills the screen with the caption floating over it, a compact bottom panel holds the SD/HD switch and View-only choice, and a round send button replaces the inline one. - a tick mark per file excludes it from sending (at least one stays) - "add more" menu next to the caption: gallery, photo, video - crop and rotate through the bundled uCrop (setMaxBitmapSize(4096), so uCrop does not downscale the picture to the screen size); the result replaces the file at the same position and is what gets sent - brush drawing on a Compose canvas, burned into a full-resolution copy - the preview result is delivered through the fragment manager, so a send after the activity was recreated still uploads the files - the original image size is shown in display orientation - selection, uri replacement, drawing geometry and file ownership are pure functions covered by unit tests Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../attachmentpreview/AttachmentEditLogic.kt | 136 ++++++ .../attachmentpreview/AttachmentEditing.kt | 222 +++++++++ .../attachmentpreview/AttachmentToolBar.kt | 231 +++++++++ .../AttachmentToolBarPreviews.kt | 63 +++ .../talk/attachmentpreview/CaptionInputBar.kt | 100 +++- .../talk/attachmentpreview/DetailVariants.kt | 15 + .../talk/attachmentpreview/DrawingEditor.kt | 234 +++++++++ .../talk/attachmentpreview/DrawingSession.kt | 33 ++ .../FileAttachmentPreviewFragment.kt | 45 +- .../FileAttachmentPreviewScreen.kt | 456 ++++++++++-------- .../FileAttachmentPreviewViewModel.kt | 128 ++++- .../talk/attachmentpreview/FileDescriber.kt | 49 +- .../attachmentpreview/FileThumbnailImage.kt | 14 +- .../attachmentpreview/ImageDetailLogic.kt | 45 ++ .../talk/attachmentpreview/LargePreview.kt | 86 ++-- .../talk/attachmentpreview/ThumbnailStrip.kt | 142 ++---- .../ThumbnailStripPreviews.kt | 29 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 21 +- app/src/main/res/values/strings.xml | 16 +- .../AttachmentSelectionAndNamingTest.kt | 93 ++++ .../AttachmentToolBarLogicTest.kt | 25 + .../attachmentpreview/DrawingGeometryTest.kt | 85 ++++ .../EditedFileOwnershipTest.kt | 53 ++ .../FileAttachmentPreviewResultTest.kt | 36 ++ .../FileAttachmentPreviewSelectionTest.kt | 105 ++++ .../FileAttachmentPreviewViewModelTest.kt | 6 +- .../attachmentpreview/ImageDetailLogicTest.kt | 109 +++++ .../attachmentpreview/UprightMatrixTest.kt | 85 ++++ 28 files changed, 2197 insertions(+), 465 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditLogic.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBar.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarPreviews.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/DetailVariants.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingEditor.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingSession.kt create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogic.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentSelectionAndNamingTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarLogicTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/DrawingGeometryTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/EditedFileOwnershipTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewResultTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewSelectionTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogicTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/UprightMatrixTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditLogic.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditLogic.kt new file mode 100644 index 0000000000..7757071903 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditLogic.kt @@ -0,0 +1,136 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import kotlin.math.ceil + +/* + * Pure, Android-free helpers behind the attachment preview editing features, kept apart from the + * UI so they can be covered by plain JVM unit tests. + */ + +private const val EDITED_SUFFIX = "_edited_" +private const val SHARED_URI_SEGMENT = "shared_attachments" +private val editedSuffixRegex = Regex("${EDITED_SUFFIX}[0-9-]+$") +private const val MAX_DECODE_PIXELS = 20_000_000L + +/** A point on the displayed image, in 0..1 of the image's width/height (resolution independent). */ +internal data class NormalizedPoint(val x: Float, val y: Float) + +/** One finger-down..finger-up brush stroke. [widthFraction] is the brush width relative to the image width. */ +internal data class DrawStroke(val colorArgb: Int, val widthFraction: Float, val points: List) + +/** Where an image of a given aspect ratio lands when fitted (letterboxed) into a view, in view pixels. */ +internal data class FitRect(val left: Float, val top: Float, val width: Float, val height: Float) + +/** Replaces [oldUri] by [newUri] keeping its position; returns [files] unchanged when [oldUri] is absent. */ +internal fun replaceUri(files: List, oldUri: String, newUri: String): List { + val index = files.indexOf(oldUri) + return if (index < 0 || oldUri == newUri) { + files + } else { + files.toMutableList().also { it[index] = newUri } + } +} + +/** + * Toggles [uri] in the set of files excluded from sending. At least one file always stays selected, + * so excluding the last selected file is refused (the set is returned unchanged). + */ +internal fun toggleSelection(files: List, unselected: Set, uri: String): Set = + when { + uri !in files -> unselected + uri in unselected -> unselected - uri + files.count { it !in unselected } <= 1 -> unselected + else -> unselected + uri + } + +/** The files that will actually be sent, in list order. */ +internal fun selectedFiles(files: List, unselected: Set): List = + files.filter { it !in unselected } + +/** + * Name for the file an edit produces: the original base name without extension and without an + * earlier edit suffix (so repeated edits don't pile suffixes up), plus a unique [stamp]. + */ +internal fun editedFileName(originalName: String, stamp: String, extension: String): String { + val base = originalName.substringBeforeLast('.', originalName).replace(editedSuffixRegex, "") + return "${base.ifBlank { "image" }}$EDITED_SUFFIX$stamp.$extension" +} + +/** True for names produced by [editedFileName], i.e. intermediates of an earlier edit. */ +internal fun isEditedFileName(fileName: String): Boolean = + editedSuffixRegex.containsMatchIn(fileName.substringBeforeLast('.', fileName)) + +/** + * The edited file's name when [pathSegments] of a URI under [authority] address exactly + * `shared_attachments/` in our own FileProvider; null for anything else + * (foreign authority, an original/camera file, a nested path). Only those are ours to delete. + */ +internal fun ownEditedName(authority: String?, ownAuthority: String, pathSegments: List): String? { + val name = pathSegments.getOrNull(1) + val matches = authority == ownAuthority && + pathSegments.size == 2 && + pathSegments.first() == SHARED_URI_SEGMENT && + name != null && + isEditedFileName(name) + return if (matches) name else null +} + +/** + * Whether drawing must turn the pixels upright instead of carrying the EXIF orientation tag over. + * Mirrored orientations (2/4/5/7) are not understood downstream, and PNG is not rotated by EXIF at + * all; only JPEG with the plain rotations 3/6/8 keeps the tag. + */ +internal fun rotatesPixels(png: Boolean, rotationDegrees: Int, flipped: Boolean): Boolean = + flipped || (png && rotationDegrees != 0) + +/** Output extension and whether it is PNG (lossless, keeps transparency) for a source MIME type. */ +internal fun editOutputIsPng(sourceMimeType: String?): Boolean = sourceMimeType == "image/png" + +/** Fits an image of [aspectRatio] (width / height) into the view like `ContentScale.Fit`, centered. */ +internal fun fitRect(viewWidth: Float, viewHeight: Float, aspectRatio: Float): FitRect { + val viewRatio = viewWidth / viewHeight + val (width, height) = if (viewRatio > aspectRatio) { + (viewHeight * aspectRatio) to viewHeight + } else { + viewWidth to (viewWidth / aspectRatio) + } + return FitRect((viewWidth - width) / 2f, (viewHeight - height) / 2f, width, height) +} + +/** Maps a view-space touch position to the image, clamped so strokes can't leave the picture. */ +internal fun toNormalized(x: Float, y: Float, rect: FitRect): NormalizedPoint = + NormalizedPoint( + ((x - rect.left) / rect.width).coerceIn(0f, 1f), + ((y - rect.top) / rect.height).coerceIn(0f, 1f) + ) + +/** Maps a normalized point to pixels of a bitmap of the given size. */ +internal fun toPixels(point: NormalizedPoint, bitmapWidth: Int, bitmapHeight: Int): Pair = + (point.x * bitmapWidth) to (point.y * bitmapHeight) + +/** Brush width in bitmap pixels; never thinner than one pixel. */ +internal fun strokeWidthPixels(stroke: DrawStroke, bitmapWidth: Int): Float = + (stroke.widthFraction * bitmapWidth).coerceAtLeast(1f) + +/** Drops the most recent stroke ("undo"). */ +internal fun undoLast(strokes: List): List = strokes.dropLast(1) + +/** + * Power-of-two `inSampleSize` that keeps a decoded bitmap under [maxPixels]; 1 means full resolution. + * Only very large sources (beyond ~20 MP) are downsampled, to avoid running out of memory. + */ +internal fun decodeSampleSize(width: Int, height: Int, maxPixels: Long = MAX_DECODE_PIXELS): Int { + var sample = 1 + var pixels = width.toLong() * height.toLong() + while (pixels > maxPixels) { + sample *= 2 + pixels = ceil(width / sample.toDouble()).toLong() * ceil(height / sample.toDouble()).toLong() + } + return sample +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt new file mode 100644 index 0000000000..4f79633816 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt @@ -0,0 +1,222 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Canvas +import android.graphics.Matrix +import android.graphics.Paint +import android.graphics.Path +import android.graphics.RectF +import android.net.Uri +import androidx.core.content.FileProvider +import androidx.core.graphics.createBitmap +import androidx.exifinterface.media.ExifInterface +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.utils.FileUtils +import com.yalantis.ucrop.UCrop +import java.io.File +import java.io.FileOutputStream +import java.io.IOException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import kotlin.math.roundToInt + +private const val TAG = "AttachmentEditing" +private const val EDIT_FILE_STAMP_PATTERN = "yyyyMMdd-HHmmss-SSS" +private const val EDIT_JPEG_QUALITY = 95 +private const val EDIT_PNG_QUALITY = 100 + +// uCrop crops the bitmap it decoded for display, not the original file, and decodes it no larger +// than this on either side (by default roughly the screen diagonal, ~2600 px) - so without an +// explicit limit an HD photo would silently come out downscaled. 4096 keeps a typical 12 MP photo +// at full size, stays inside every GPU's texture limit and bounds the decoded bitmap to ~64 MB. +private const val CROP_MAX_BITMAP_PX = 4096 + +/** + * Creates the (not yet existing) output file of an edit in the cache dir shared through the app's + * FileProvider; null when that directory is unavailable (anything else would be unshareable). + */ +internal fun createEditOutputFile(context: Context, sourceName: String, sourceMimeType: String?): File? { + val directory = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) ?: return null + val stamp = SimpleDateFormat(EDIT_FILE_STAMP_PATTERN, Locale.ROOT).format(Date()) + val extension = if (editOutputIsPng(sourceMimeType)) "png" else "jpg" + return File(directory, editedFileName(sourceName, stamp, extension)) +} + +/** The same URI form the camera capture uses for files in the shared attachments cache; null if not shareable. */ +internal fun editedFileUri(context: Context, file: File): Uri? = + try { + FileProvider.getUriForFile(context, context.packageName, file) + } catch (e: IllegalArgumentException) { + NextcloudTalkApplication.sharedApplication?.logger?.w(TAG, "Edited file is outside the shared paths", e) + null + } + +/** + * The file behind [uriString] if it is an intermediate produced by an earlier edit (our FileProvider, + * shared attachments cache, edit-suffixed name) - safe to delete once superseded. Null for anything + * else, e.g. the user's original photo or a camera shot. + */ +internal fun ownEditedFile(context: Context, uriString: String): File? { + val uri = Uri.parse(uriString) + val name = ownEditedName(uri.authority, context.packageName, uri.pathSegments) + return name?.let { FileUtils.resolveSharedAttachmentFile(context.cacheDir, it) } +} + +/** Intent of uCrop's crop-and-rotate screen, writing the result to [destination]. */ +internal fun createCropIntent(context: Context, source: Uri, destination: File, sourceMimeType: String?): Intent { + val png = editOutputIsPng(sourceMimeType) + val options = UCrop.Options().apply { + setCompressionFormat(if (png) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG) + setCompressionQuality(if (png) EDIT_PNG_QUALITY else EDIT_JPEG_QUALITY) + setFreeStyleCropEnabled(true) + setMaxBitmapSize(CROP_MAX_BITMAP_PX) + } + return UCrop.of(source, Uri.fromFile(destination)).withOptions(options).getIntent(context) +} + +/** + * Burns [strokes] into a copy of [source] at (at most ~20 MP of) its own resolution and writes it + * to [destination]. Only one bitmap is ever held: the strokes are drawn straight onto the decoded, + * un-rotated pixels through the inverse of the EXIF transform, and the orientation tag is carried + * over to the output instead of rotating the pixels. Returns false on any failure. + */ +internal fun renderDrawing( + context: Context, + source: Uri, + strokes: List, + destination: File, + png: Boolean +): Boolean = + try { + drawOnDecodedCopy(context, source, strokes, destination, png) + } catch (e: IOException) { + logRenderFailure(e) + } catch (e: SecurityException) { + // e.g. a Photo Picker uri whose read grant was revoked + logRenderFailure(e) + } catch (e: OutOfMemoryError) { + logRenderFailure(e) + }.also { if (!it) destination.delete() } + +private fun logRenderFailure(error: Throwable): Boolean { + NextcloudTalkApplication.sharedApplication?.logger?.w(TAG, "Failed to save drawing", error) + return false +} + +private fun drawOnDecodedCopy( + context: Context, + source: Uri, + strokes: List, + destination: File, + png: Boolean +): Boolean { + val resolver = context.contentResolver + val exif = resolver.openInputStream(source)?.use { ExifInterface(it) } + val bitmap = decodeRaw(context, source) + val written = bitmap != null && paintAndWrite(bitmap, exif, strokes, destination, png) + bitmap?.recycle() + return written +} + +private fun decodeRaw(context: Context, source: Uri): Bitmap? { + val resolver = context.contentResolver + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, bounds) } + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + val options = BitmapFactory.Options().apply { + inSampleSize = decodeSampleSize(bounds.outWidth, bounds.outHeight) + inMutable = true + } + return resolver.openInputStream(source)?.use { BitmapFactory.decodeStream(it, null, options) } +} + +private fun paintAndWrite( + bitmap: Bitmap, + exif: ExifInterface?, + strokes: List, + destination: File, + png: Boolean +): Boolean { + val degrees = exif?.rotationDegrees ?: 0 + val flipped = exif?.isFlipped == true + val toUpright = uprightMatrix(degrees, flipped, bitmap.width, bitmap.height) + val bounds = RectF(0f, 0f, bitmap.width.toFloat(), bitmap.height.toFloat()).also { toUpright.mapRect(it) } + drawStrokes(bitmap, strokes, toUpright, bounds.width().roundToInt(), bounds.height().roundToInt()) + + val turnPixels = rotatesPixels(png, degrees, flipped) + val output = if (turnPixels) uprightCopy(bitmap, toUpright, bounds) else bitmap + val format = if (png) Bitmap.CompressFormat.PNG else Bitmap.CompressFormat.JPEG + val quality = if (png) EDIT_PNG_QUALITY else EDIT_JPEG_QUALITY + val written = FileOutputStream(destination).use { output.compress(format, quality, it) } + if (output !== bitmap) output.recycle() + if (written && !turnPixels && degrees != 0) { + ExifInterface(destination.absolutePath).apply { + setAttribute(ExifInterface.TAG_ORIENTATION, exif?.getAttribute(ExifInterface.TAG_ORIENTATION)) + saveAttributes() + } + } + return written +} + +/** The pixels turned/mirrored upright; the raw [bitmap] is released first to keep the memory peak low. */ +private fun uprightCopy(bitmap: Bitmap, toUpright: Matrix, bounds: RectF): Bitmap { + val upright = createBitmap(bounds.width().roundToInt(), bounds.height().roundToInt()) + Canvas(upright).drawBitmap(bitmap, toUpright, Paint(Paint.FILTER_BITMAP_FLAG)) + bitmap.recycle() + return upright +} + +/** Raw-pixel to upright-display transform for an EXIF orientation, translated to start at 0,0. */ +internal fun uprightMatrix(rotationDegrees: Int, flipped: Boolean, width: Int, height: Int): Matrix { + val matrix = Matrix().apply { + if (flipped) postScale(-1f, 1f) + postRotate(rotationDegrees.toFloat()) + } + val mapped = RectF(0f, 0f, width.toFloat(), height.toFloat()).also { matrix.mapRect(it) } + matrix.postTranslate(-mapped.left, -mapped.top) + return matrix +} + +/** Draws [strokes] (positioned on the upright [uprightWidth]x[uprightHeight] picture) onto the raw [bitmap]. */ +private fun drawStrokes( + bitmap: Bitmap, + strokes: List, + toUpright: Matrix, + uprightWidth: Int, + uprightHeight: Int +) { + val canvas = Canvas(bitmap) + canvas.concat(Matrix().also { toUpright.invert(it) }) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + style = Paint.Style.STROKE + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + } + strokes.filter { it.points.isNotEmpty() }.forEach { stroke -> + paint.color = stroke.colorArgb + paint.strokeWidth = strokeWidthPixels(stroke, uprightWidth) + val pixels = stroke.points.map { toPixels(it, uprightWidth, uprightHeight) } + if (pixels.size == 1) { + canvas.drawPoint(pixels.first().first, pixels.first().second, paint) + } else { + canvas.drawPath(strokePath(pixels), paint) + } + } +} + +private fun strokePath(pixels: List>): Path = + Path().apply { + moveTo(pixels.first().first, pixels.first().second) + pixels.drop(1).forEach { lineTo(it.first, it.second) } + } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBar.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBar.kt new file mode 100644 index 0000000000..06ec0b8949 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBar.kt @@ -0,0 +1,231 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import androidx.annotation.StringRes +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.Row +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.selection.toggleable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Edit +import androidx.compose.material.icons.filled.EditOff +import androidx.compose.material.icons.outlined.Brush +import androidx.compose.material.icons.outlined.CropRotate +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.vector.ImageVector +import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import com.nextcloud.talk.R + +private const val BADGE_BORDER_DP = 1 +private const val BADGE_CORNER_RADIUS_DP = 4 +private const val BADGE_HORIZONTAL_PADDING_DP = 3 +private const val QUALITY_TOGGLE_SIZE_DP = 48 +private const val PERMISSION_ICON_SIZE_DP = 18 +private const val PERMISSION_COMPACT_PADDING_DP = 15 + +/** Where more files can come from: the "add" button next to the caption offers these. */ +internal data class AddMoreActions( + val onPickFromGallery: () -> Unit, + val onTakePhoto: () -> Unit, + val onTakeVideo: () -> Unit +) + +/** What the compact panel under the caption shows. */ +internal data class ToolBarState( + val showQuality: Boolean, + val highQuality: Boolean, + val showPermission: Boolean, + val allowUpdate: Boolean +) + +/** [onCrop]/[onDraw] are null when the current file isn't an image (no edit tools for video/documents). */ +internal data class ToolBarActions( + val onCrop: (() -> Unit)?, + val onDraw: (() -> Unit)?, + val onHighQualityChange: (Boolean) -> Unit, + val onAllowUpdateChange: (Boolean) -> Unit +) + +/** + * Telegram-style pill with edit tools (images only), the SD/HD switch and the View-only/Editable choice. + * Buttons keep their size. When the full pill is wider than its slot, the permission choice shrinks to its icon; + * should the pill still be too wide (large font), its content scrolls. + */ +@Composable +internal fun AttachmentToolBar(state: ToolBarState, actions: ToolBarActions, modifier: Modifier = Modifier) { + SubcomposeLayout( + modifier = modifier + .clip(RoundedCornerShape(percent = 50)) + .background(ScrimColor) + ) { constraints -> + val full = subcompose(PermissionStyle.LABELED) { ToolBarContent(state, actions, compactPermission = false) } + val fullWidth = full.maxOfOrNull { it.maxIntrinsicWidth(Constraints.Infinity) } ?: 0 + val measurables = if (state.showPermission && shouldCompactPermission(fullWidth, constraints.maxWidth)) { + subcompose(PermissionStyle.ICON_ONLY) { ToolBarContent(state, actions, compactPermission = true) } + } else { + full + } + val placeables = measurables.map { it.measure(constraints.copy(minWidth = 0)) } + layout(placeables.maxOfOrNull { it.width } ?: 0, placeables.maxOfOrNull { it.height } ?: 0) { + placeables.forEach { it.place(0, 0) } + } + } +} + +private enum class PermissionStyle { LABELED, ICON_ONLY } + +@Composable +private fun ToolBarContent(state: ToolBarState, actions: ToolBarActions, compactPermission: Boolean) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(2.dp), + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 4.dp) + ) { + actions.onCrop?.let { onCrop -> + ToolIconButton(Icons.Outlined.CropRotate, R.string.nc_attachment_crop, onCrop) + } + actions.onDraw?.let { onDraw -> + ToolIconButton(Icons.Outlined.Brush, R.string.nc_attachment_draw, onDraw) + } + if (state.showQuality) { + QualityToggle(state.highQuality, actions.onHighQualityChange) + } + if (state.showPermission) { + PermissionChoice(state.allowUpdate, compactPermission, actions.onAllowUpdateChange) + } + } +} + +@Composable +private fun ToolIconButton(icon: ImageVector, @StringRes description: Int, onClick: () -> Unit) { + IconButton(onClick = onClick) { + Icon(imageVector = icon, contentDescription = stringResource(description), tint = Color.White) + } +} + +@Composable +private fun TextBadge(text: String, color: Color) { + Text( + text = text, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + color = color, + modifier = Modifier + .border(BADGE_BORDER_DP.dp, color, RoundedCornerShape(BADGE_CORNER_RADIUS_DP.dp)) + .padding(horizontal = BADGE_HORIZONTAL_PADDING_DP.dp) + ) +} + +/** SD = compressed, HD = original; one tap flips it, the chip on the photo shows the resulting size. */ +@Composable +private fun QualityToggle(highQuality: Boolean, onHighQualityChange: (Boolean) -> Unit) { + val description = stringResource(R.string.nc_media_quality_original) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(QUALITY_TOGGLE_SIZE_DP.dp) + .clip(CircleShape) + .toggleable(value = highQuality, role = Role.Switch, onValueChange = onHighQualityChange) + .semantics { contentDescription = description } + ) { + TextBadge(if (highQuality) "HD" else "SD", Color.White) + } +} + +@Composable +private fun PermissionChoice(allowUpdate: Boolean, compact: Boolean, onAllowUpdateChange: (Boolean) -> Unit) { + var expanded by remember { mutableStateOf(false) } + val label = stringResource( + if (allowUpdate) R.string.nc_file_permission_editable else R.string.nc_file_permission_view_only + ) + + Box { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clip(RoundedCornerShape(percent = 50)) + .clickable(role = Role.Button, onClickLabel = label) { expanded = true } + .padding( + horizontal = if (compact) PERMISSION_COMPACT_PADDING_DP.dp else 10.dp, + vertical = if (compact) PERMISSION_COMPACT_PADDING_DP.dp else 12.dp + ) + ) { + Icon( + imageVector = if (allowUpdate) Icons.Filled.Edit else Icons.Filled.EditOff, + contentDescription = if (compact) label else null, + tint = Color.White, + modifier = Modifier.size(PERMISSION_ICON_SIZE_DP.dp) + ) + if (!compact) { + Text( + text = label, + style = MaterialTheme.typography.labelMedium, + color = Color.White, + maxLines = 1, + modifier = Modifier.padding(start = 6.dp) + ) + } + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.nc_file_permission_view_only)) }, + leadingIcon = { Icon(Icons.Filled.EditOff, contentDescription = null) }, + trailingIcon = { if (!allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) }, + onClick = { + onAllowUpdateChange(false) + expanded = false + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.nc_file_permission_editable)) }, + leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, + trailingIcon = { if (allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) }, + onClick = { + onAllowUpdateChange(true) + expanded = false + } + ) + } + } +} + +/** The labeled View-only/Editable choice is used while the full pill fits the slot; otherwise only its icon. */ +internal fun shouldCompactPermission(fullWidthPx: Int, availableWidthPx: Int): Boolean = fullWidthPx > availableWidthPx diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarPreviews.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarPreviews.kt new file mode 100644 index 0000000000..ef42bba6a9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarPreviews.kt @@ -0,0 +1,63 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.tooling.preview.Preview + +private val previewImage = FileDescription( + uri = "file:///sdcard/DCIM/photo.jpg", + name = "photo.jpg", + kind = MediaKind.IMAGE, + mimeType = "image/jpeg", + detail = "4000×3000, 3 MB", + aspectRatio = 1.33f +) + +@Composable +private fun BottomToolRowPreviewContainer(allowUpdate: Boolean = false) { + MaterialTheme { + BottomToolRow( + options = ToolBarState( + showQuality = true, + highQuality = false, + showPermission = true, + allowUpdate = allowUpdate + ), + current = previewImage, + onCrop = {}, + onDraw = {}, + onHighQualityChange = {}, + onAllowUpdateChange = {}, + sendEnabled = true, + onSend = {} + ) + } +} + +@Preview(name = "Narrow 360dp", widthDp = 360, showBackground = true, backgroundColor = 0xFF000000) +@Preview(name = "Phone 393dp", widthDp = 393, showBackground = true, backgroundColor = 0xFF000000) +@Preview(name = "Landscape 850dp", widthDp = 850, showBackground = true, backgroundColor = 0xFF000000) +@Preview(name = "Side pane 500dp", widthDp = 500, showBackground = true, backgroundColor = 0xFF000000) +@Composable +private fun BottomToolRowWidthsPreview() { + BottomToolRowPreviewContainer() +} + +@Preview( + name = "Huawei 348dp ru, font 1.3", + widthDp = 348, + fontScale = 1.3f, + locale = "ru", + showBackground = true, + backgroundColor = 0xFF000000 +) +@Composable +private fun BottomToolRowOwnerDevicePreview() { + BottomToolRowPreviewContainer(allowUpdate = true) +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt index 1cbb9afed8..d76b28b390 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/CaptionInputBar.kt @@ -12,16 +12,27 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.AddPhotoAlternate +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +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.SolidColor import androidx.compose.ui.res.dimensionResource import androidx.compose.ui.res.painterResource @@ -29,58 +40,107 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.nextcloud.talk.R -/** The bottom bar where the user types an optional caption and triggers sending. */ +internal val ScrimColor = Color.Black.copy(alpha = 0.5f) +private const val SEND_BUTTON_SIZE_DP = 56 + +/** The caption field floating over the photo, with the "add more files" button at its start. */ @Composable internal fun CaptionInputBar( caption: String, onCaptionChange: (String) -> Unit, - sendEnabled: Boolean, - onSend: () -> Unit + addMoreActions: AddMoreActions, + modifier: Modifier = Modifier ) { - val shape = RoundedCornerShape(dimensionResource(R.dimen.button_corner_radius)) Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp) - .clip(shape) - .background(MaterialTheme.colorScheme.surfaceVariant) + .clip(RoundedCornerShape(dimensionResource(R.dimen.button_corner_radius))) + .background(ScrimColor) ) { + AddMoreButton(addMoreActions) + Box( modifier = Modifier .weight(1f) .heightIn(min = dimensionResource(R.dimen.min_size_clickable_area)) - .padding(start = 16.dp), + .padding(start = 4.dp, end = 16.dp), contentAlignment = Alignment.CenterStart ) { if (caption.isEmpty()) { Text( text = stringResource(R.string.nc_caption), style = MaterialTheme.typography.bodyLarge, - color = MaterialTheme.colorScheme.onSurfaceVariant + color = Color.White.copy(alpha = 0.7f) ) } BasicTextField( value = caption, onValueChange = onCaptionChange, maxLines = 5, - textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface), - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + textStyle = MaterialTheme.typography.bodyLarge.copy(color = Color.White), + cursorBrush = SolidColor(Color.White), modifier = Modifier.fillMaxWidth() ) } + } +} - val sendTint = if (sendEnabled) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurfaceVariant - } - IconButton(onClick = onSend, enabled = sendEnabled) { +@Composable +private fun AddMoreButton(actions: AddMoreActions) { + var expanded by remember { mutableStateOf(false) } + + Box { + IconButton(onClick = { expanded = true }) { Icon( - painter = painterResource(R.drawable.ic_send_24px), - contentDescription = stringResource(R.string.nc_description_send_message_button), - tint = sendTint + imageVector = Icons.Outlined.AddPhotoAlternate, + contentDescription = stringResource(R.string.nc_attachment_add_menu), + tint = Color.White + ) + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + DropdownMenuItem( + text = { Text(stringResource(R.string.nc_add_more_files)) }, + onClick = { + expanded = false + actions.onPickFromGallery() + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.take_photo)) }, + onClick = { + expanded = false + actions.onTakePhoto() + } + ) + DropdownMenuItem( + text = { Text(stringResource(R.string.nc_take_video)) }, + onClick = { + expanded = false + actions.onTakeVideo() + } ) } } } + +/** The round "send" button. */ +@Composable +internal fun SendButton(enabled: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val container = if (enabled) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outline + IconButton( + onClick = onClick, + enabled = enabled, + modifier = modifier + .size(SEND_BUTTON_SIZE_DP.dp) + .clip(CircleShape) + .background(container) + ) { + Icon( + painter = painterResource(R.drawable.ic_send_24px), + contentDescription = stringResource(R.string.nc_description_send_message_button), + tint = MaterialTheme.colorScheme.onPrimary + ) + } +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/DetailVariants.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DetailVariants.kt new file mode 100644 index 0000000000..df2fdf444c --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DetailVariants.kt @@ -0,0 +1,15 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +/** + * [current] is the detail text for the file's active compress setting, [alternate] is what it + * would read as under the other setting. Both are computed regardless of [FileDescription]'s own + * `compress` flag, so the large preview's detail chip can reserve width for whichever is wider and + * never resize when the HQ toggle flips which one is actually shown. + */ +internal data class DetailVariants(val current: String, val alternate: String) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingEditor.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingEditor.kt new file mode 100644 index 0000000000..3ff25912e2 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingEditor.kt @@ -0,0 +1,234 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +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.statusBarsPadding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.Undo +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Close +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.StrokeJoin +import androidx.compose.ui.graphics.drawscope.DrawScope +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.nextcloud.talk.R + +private const val BRUSH_WIDTH_FRACTION = 0.012f +private const val SWATCH_SIZE_DP = 32 +private const val SWATCH_SELECTED_BORDER_DP = 3 +private const val COLOR_RED = 0xFFE53935 +private const val COLOR_YELLOW = 0xFFFDD835 +private const val COLOR_GREEN = 0xFF43A047 +private const val COLOR_BLUE = 0xFF1E88E5 +private val brushSwatches = listOf( + Swatch(Color.White, R.string.nc_attachment_color_white), + Swatch(Color.Black, R.string.nc_attachment_color_black), + Swatch(Color(COLOR_RED), R.string.nc_attachment_color_red), + Swatch(Color(COLOR_YELLOW), R.string.nc_attachment_color_yellow), + Swatch(Color(COLOR_GREEN), R.string.nc_attachment_color_green), + Swatch(Color(COLOR_BLUE), R.string.nc_attachment_color_blue) +) + +private data class Swatch(val color: Color, @StringRes val name: Int) + +/** + * Full-screen brush editor over an image. Strokes are kept in image-relative coordinates, so the + * result doesn't depend on the screen size; they live in [session], owned by the view model. + * [aspectRatio] is the image's displayed width / height. + */ +@Composable +internal fun DrawingEditor( + imageUri: String, + aspectRatio: Float, + session: DrawingSession, + onCancel: () -> Unit, + onDone: () -> Unit +) { + BackHandler(onBack = onCancel) + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + BoxWithConstraints(modifier = Modifier.fillMaxSize().statusBarsPadding().navigationBarsPadding()) { + val widthPx = with(LocalDensity.current) { maxWidth.toPx() } + val heightPx = with(LocalDensity.current) { maxHeight.toPx() } + val rect = remember(widthPx, heightPx, aspectRatio) { fitRect(widthPx, heightPx, aspectRatio) } + + AsyncImage( + model = imageUri, + contentDescription = null, + contentScale = ContentScale.Fit, + modifier = Modifier.fillMaxSize() + ) + DrawingSurface(rect = rect, session = session) + } + + EditorTopBar( + canUndo = session.strokes.isNotEmpty(), + onCancel = onCancel, + onUndo = session::undo, + onDone = onDone, + modifier = Modifier.align(Alignment.TopCenter) + ) + ColorRow( + selectedArgb = session.brushColorArgb, + onSelect = { session.brushColorArgb = it.toArgb() }, + modifier = Modifier.align(Alignment.BottomCenter) + ) + } +} + +@Composable +private fun DrawingSurface(rect: FitRect, session: DrawingSession) { + val live = remember { mutableStateListOf() } + + fun commit() { + if (live.isNotEmpty()) { + session.add(DrawStroke(session.brushColorArgb, BRUSH_WIDTH_FRACTION, live.toList())) + live.clear() + } + } + + Canvas( + modifier = Modifier + .fillMaxSize() + .pointerInput(rect) { + detectDragGestures( + onDragStart = { start -> + live.clear() + live.add(toNormalized(start.x, start.y, rect)) + }, + onDrag = { change, _ -> + change.consume() + live.add(toNormalized(change.position.x, change.position.y, rect)) + }, + onDragEnd = ::commit, + onDragCancel = ::commit + ) + } + ) { + session.strokes.forEach { drawStroke(it.colorArgb, it.widthFraction, it.points, rect) } + drawStroke(session.brushColorArgb, BRUSH_WIDTH_FRACTION, live.toList(), rect) + } +} + +private fun DrawScope.drawStroke(colorArgb: Int, widthFraction: Float, points: List, rect: FitRect) { + if (points.isEmpty()) return + val color = Color(colorArgb) + val width = (widthFraction * rect.width).coerceAtLeast(1f) + fun NormalizedPoint.toOffset() = Offset(rect.left + x * rect.width, rect.top + y * rect.height) + if (points.size == 1) { + drawCircle(color, radius = width / 2f, center = points.first().toOffset()) + return + } + val path = Path().apply { + val first = points.first().toOffset() + moveTo(first.x, first.y) + points.drop(1).forEach { point -> point.toOffset().let { lineTo(it.x, it.y) } } + } + drawPath(path, color, style = Stroke(width = width, cap = StrokeCap.Round, join = StrokeJoin.Round)) +} + +@Composable +private fun EditorTopBar( + canUndo: Boolean, + onCancel: () -> Unit, + onUndo: () -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier + .fillMaxWidth() + .background(ScrimColor) + .statusBarsPadding() + .padding(horizontal = 4.dp) + ) { + IconButton(onClick = onCancel) { + Icon(Icons.Filled.Close, stringResource(R.string.nc_common_dismiss), tint = Color.White) + } + Row { + IconButton(onClick = onUndo, enabled = canUndo) { + Icon( + Icons.AutoMirrored.Filled.Undo, + stringResource(R.string.nc_attachment_undo), + tint = Color.White.copy(alpha = if (canUndo) 1f else 0.4f) + ) + } + IconButton(onClick = onDone) { + Icon(Icons.Filled.Check, stringResource(R.string.save), tint = Color.White) + } + } + } +} + +@Composable +private fun ColorRow(selectedArgb: Int, onSelect: (Color) -> Unit, modifier: Modifier = Modifier) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterHorizontally), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .background(ScrimColor) + .navigationBarsPadding() + .padding(vertical = 12.dp) + ) { + brushSwatches.forEach { swatch -> + val isSelected = swatch.color.toArgb() == selectedArgb + val borderColor = if (isSelected) Color.White else Color.Gray + val borderWidth = if (isSelected) SWATCH_SELECTED_BORDER_DP else 1 + val name = stringResource(swatch.name) + Box( + modifier = Modifier + .size(SWATCH_SIZE_DP.dp) + .clip(CircleShape) + .background(swatch.color) + .border(borderWidth.dp, borderColor, CircleShape) + .selectable(selected = isSelected, role = Role.RadioButton) { onSelect(swatch.color) } + .semantics { contentDescription = name } + ) + } + } +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingSession.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingSession.kt new file mode 100644 index 0000000000..57b289a6c4 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/DrawingSession.kt @@ -0,0 +1,33 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue + +private const val DEFAULT_BRUSH_COLOR_ARGB = 0xFFFFFFFF + +/** Strokes and brush colour of the drawing being made, held by the view model across rotation. */ +internal class DrawingSession { + var strokes by mutableStateOf>(emptyList()) + private set + + var brushColorArgb by mutableStateOf(DEFAULT_BRUSH_COLOR_ARGB.toInt()) + + fun add(stroke: DrawStroke) { + strokes = strokes + stroke + } + + fun undo() { + strokes = undoLast(strokes) + } + + fun clear() { + strokes = emptyList() + } +} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt index 42677896a8..18f21a092f 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -14,7 +14,6 @@ import android.view.View import android.view.ViewGroup import android.view.WindowManager import androidx.compose.material3.MaterialTheme -import androidx.compose.ui.graphics.luminance import androidx.compose.ui.platform.ComposeView import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.core.view.WindowCompat @@ -34,9 +33,6 @@ class FileAttachmentPreviewFragment : DialogFragment() { private lateinit var filesList: ArrayList private var conversationName: String = "" private var showFilePermissionsOption: Boolean = false - private var uploadFiles: - (files: MutableList, caption: String, compressImages: Boolean, allowUpdate: Boolean) -> Unit = - { _, _, _, _ -> } private var composeView: ComposeView? = null @Inject @@ -52,17 +48,6 @@ class FileAttachmentPreviewFragment : DialogFragment() { ViewModelProvider(this, viewModelFactory)[FileAttachmentPreviewViewModel::class.java] } - fun setListener( - uploadFiles: ( - files: MutableList, - caption: String, - compressImages: Boolean, - allowUpdate: Boolean - ) -> Unit - ) { - this.uploadFiles = uploadFiles - } - override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { arguments?.let { filesList = it.getStringArrayList(FILES_TO_UPLOAD_ARG)!! @@ -90,11 +75,10 @@ class FileAttachmentPreviewFragment : DialogFragment() { statusBarColor = Color.TRANSPARENT navigationBarColor = Color.TRANSPARENT - val surfaceColor = viewThemeUtils.getColorScheme(requireActivity()).surface - val isLightSurface = surfaceColor.luminance() > LIGHT_LUMINANCE_THRESHOLD + // The screen is always dark (photo on black), so system bar icons are always light. WindowInsetsControllerCompat(this, decorView).apply { - isAppearanceLightStatusBars = isLightSurface - isAppearanceLightNavigationBars = isLightSurface + isAppearanceLightStatusBars = false + isAppearanceLightNavigationBars = false } } } @@ -117,7 +101,10 @@ class FileAttachmentPreviewFragment : DialogFragment() { showFilePermissionsOption = showFilePermissionsOption, onDismiss = { dismiss() }, onSend = { files, caption, compressImages, allowUpdate -> - uploadFiles(files.toMutableList(), caption, compressImages, allowUpdate) + parentFragmentManager.setFragmentResult( + RESULT_KEY, + packResult(files, caption, compressImages, allowUpdate) + ) dismiss() } ) @@ -133,11 +120,27 @@ class FileAttachmentPreviewFragment : DialogFragment() { companion object { - private const val LIGHT_LUMINANCE_THRESHOLD = 0.5f private const val FILES_TO_UPLOAD_ARG = "FILES_TO_UPLOAD_ARG" private const val CONVERSATION_NAME_ARG = "CONVERSATION_NAME_ARG" private const val FILE_PERMISSIONS_OPTION_ARG = "FILE_PERMISSIONS_OPTION_ARG" + const val RESULT_KEY = "FILE_ATTACHMENT_PREVIEW_RESULT" + const val RESULT_FILES = "RESULT_FILES" + const val RESULT_CAPTION = "RESULT_CAPTION" + const val RESULT_COMPRESS_IMAGES = "RESULT_COMPRESS_IMAGES" + const val RESULT_ALLOW_UPDATE = "RESULT_ALLOW_UPDATE" + + /** + * The result goes through the fragment manager, so it still arrives after the activity was recreated. + */ + fun packResult(files: List, caption: String, compressImages: Boolean, allowUpdate: Boolean): Bundle = + Bundle().apply { + putStringArrayList(RESULT_FILES, ArrayList(files)) + putString(RESULT_CAPTION, caption) + putBoolean(RESULT_COMPRESS_IMAGES, compressImages) + putBoolean(RESULT_ALLOW_UPDATE, allowUpdate) + } + @JvmStatic fun newInstance( filesToUpload: MutableList, diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index 5fd88b9df1..73d1032280 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -6,18 +6,24 @@ */ package com.nextcloud.talk.attachmentpreview +import android.app.Activity import android.content.res.Configuration +import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts.PickVisualMedia +import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.selection.toggleable import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height @@ -25,26 +31,16 @@ import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check -import androidx.compose.material.icons.filled.Close -import androidx.compose.material.icons.filled.Edit -import androidx.compose.material.icons.filled.EditOff -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.darkColorScheme import androidx.compose.material3.lightColorScheme @@ -58,24 +54,40 @@ import androidx.compose.runtime.saveable.rememberSaveable 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.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.core.net.toUri import com.nextcloud.talk.R +import com.nextcloud.talk.utils.FileUtils +import com.yalantis.ucrop.UCrop import kotlinx.coroutines.launch +import java.io.File private const val MAX_ADD_MORE_FILES = 10 private const val APP_BAR_HEIGHT_DP = 64 private const val APP_BAR_HORIZONTAL_PADDING_DP = 4 +private const val SELECTION_MARK_SIZE_DP = 32 +private const val SELECTION_MARK_BORDER_DP = 2 +private const val SCRIM_ALPHA = 0.6f +private const val TOOL_ROW_SPACING_DP = 12 /** - * Full-screen dialog content for reviewing, reordering and captioning files picked for upload, - * hosted by [FileAttachmentPreviewFragment]. [viewModel] owns the file list and its (IO-derived) - * descriptions so both survive configuration changes; everything else here is ephemeral UI state. + * Full-screen dialog content for reviewing, selecting, editing and captioning files picked for + * upload, hosted by [FileAttachmentPreviewFragment]. [viewModel] owns the file list, the selection + * and its (IO-derived) descriptions so they survive configuration changes; everything else here is + * ephemeral UI state. */ @Suppress("LongMethod", "LongParameterList") @Composable @@ -87,10 +99,12 @@ internal fun FileAttachmentPreviewContent( onDismiss: () -> Unit, onSend: (files: List, caption: String, compressImages: Boolean, allowUpdate: Boolean) -> Unit ) { + val context = LocalContext.current val currentFiles = viewModel.files var caption by rememberSaveable { mutableStateOf("") } var compressImages by rememberSaveable { mutableStateOf(initialCompressImages) } var allowUpdate by rememberSaveable { mutableStateOf(false) } + var drawingUri by rememberSaveable { mutableStateOf(null) } LaunchedEffect(currentFiles.toSet(), compressImages) { viewModel.describeFiles(compressImages) @@ -106,14 +120,22 @@ internal fun FileAttachmentPreviewContent( val pagerState = rememberPagerState(pageCount = { fileDescriptions.size }) val coroutineScope = rememberCoroutineScope() + val currentDescription = fileDescriptions.getOrNull(pagerState.currentPage) val pickMoreMedia = rememberLauncherForActivityResult( contract = ActivityResultContracts.PickMultipleVisualMedia(MAX_ADD_MORE_FILES) ) { uris -> viewModel.addFiles(uris.map { it.toString() }) } - val cameraCapture = rememberCameraCaptureActions(currentFiles) + val startCrop = rememberCropLauncher(viewModel) + + LaunchedEffect(viewModel.editFailed) { + if (viewModel.editFailed) { + Toast.makeText(context, R.string.nc_attachment_edit_failed, Toast.LENGTH_LONG).show() + viewModel.editFailureShown() + } + } LaunchedEffect(currentFiles.size) { if (currentFiles.isEmpty()) { @@ -121,89 +143,206 @@ internal fun FileAttachmentPreviewContent( } } - Surface(modifier = Modifier.fillMaxSize()) { + val density = LocalDensity.current + var bottomPanelHeight by remember { mutableStateOf(0.dp) } + val topBarHeight = WindowInsets.statusBars.asPaddingValues().calculateTopPadding() + APP_BAR_HEIGHT_DP.dp + + Box(modifier = Modifier.fillMaxSize().background(Color.Black)) { + if (fileDescriptions.isNotEmpty()) { + LargePreview( + descriptions = fileDescriptions, + pagerState = pagerState, + videoPadding = PaddingValues(top = topBarHeight, bottom = bottomPanelHeight), + detailTopPadding = topBarHeight + ) + } + + PreviewTopBar( + conversationName = conversationName, + position = pagerState.currentPage to fileDescriptions.size, + selectedCount = viewModel.selectedFiles().size, + currentSelected = currentDescription?.let { it.uri !in viewModel.unselected } ?: false, + onToggleSelected = { currentDescription?.let { viewModel.toggleSelected(it.uri) } }, + onDismiss = onDismiss, + modifier = Modifier.align(Alignment.TopCenter) + ) + Column( modifier = Modifier - .fillMaxSize() - .statusBarsPadding() + .align(Alignment.BottomCenter) + .onSizeChanged { bottomPanelHeight = with(density) { it.height.toDp() } } + .background(Brush.verticalGradient(listOf(Color.Transparent, Color.Black.copy(alpha = SCRIM_ALPHA)))) .navigationBarsPadding() .imePadding() ) { - PreviewTopBar(conversationName = conversationName, onDismiss = onDismiss) + if (fileDescriptions.size > 1) { + ThumbnailStrip( + descriptions = fileDescriptions, + selectedIndex = pagerState.currentPage, + unselected = viewModel.unselected.toSet(), + onSelect = { index -> coroutineScope.launch { pagerState.scrollToPage(index) } }, + onReorder = { from, to -> viewModel.reorder(from, to) } + ) + } - HorizontalDivider() + CaptionInputBar( + caption = caption, + onCaptionChange = { caption = it }, + addMoreActions = AddMoreActions( + onPickFromGallery = { + pickMoreMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) + }, + onTakePhoto = cameraCapture.onTakePhoto, + onTakeVideo = cameraCapture.onTakeVideo + ) + ) - Column(modifier = Modifier.weight(1f)) { - if (fileDescriptions.isNotEmpty()) { - LargePreview( - descriptions = fileDescriptions, - pagerState = pagerState, - modifier = Modifier.weight(1f) - ) + BottomToolRow( + options = ToolBarState( + showQuality = hasCompressibleMedia, + highQuality = !compressImages, + showPermission = showFilePermissionsOption, + allowUpdate = allowUpdate + ), + current = currentDescription?.takeUnless { viewModel.isEditing }, + onCrop = startCrop, + onDraw = { + viewModel.drawing.clear() + drawingUri = it.uri + }, + onHighQualityChange = { highQuality -> compressImages = !highQuality }, + onAllowUpdateChange = { allowUpdate = it }, + sendEnabled = viewModel.selectedFiles().isNotEmpty() && !viewModel.isEditing, + onSend = { onSend(viewModel.selectedFiles(), caption, compressImages, allowUpdate) } + ) + } - ThumbnailStrip( - descriptions = fileDescriptions, - selectedIndex = pagerState.currentPage, - onSelect = { index -> coroutineScope.launch { pagerState.scrollToPage(index) } }, - onRemove = { uri -> viewModel.removeFile(uri) }, - onReorder = { from, to -> viewModel.reorder(from, to) }, - onAddMore = { - pickMoreMedia.launch(PickVisualMediaRequest(PickVisualMedia.ImageAndVideo)) - }, - onTakePhoto = cameraCapture.onTakePhoto, - onTakeVideo = cameraCapture.onTakeVideo - ) - } + DrawingOverlay( + target = fileDescriptions.firstOrNull { it.uri == drawingUri }, + session = viewModel.drawing, + onCancel = { + viewModel.drawing.clear() + drawingUri = null + }, + onDone = { target -> + viewModel.saveDrawing(target) + drawingUri = null } + ) + } +} - if (showFilePermissionsOption || hasCompressibleMedia) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp, vertical = 8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally) - ) { - if (showFilePermissionsOption) { - FilePermissionOptionButton( - allowUpdate = allowUpdate, - onAllowUpdateChange = { allowUpdate = it } - ) - } +/** The edit tools / quality / permission pill on the left, the round send button on the right. */ +@Suppress("LongParameterList") +@Composable +internal fun BottomToolRow( + options: ToolBarState, + current: FileDescription?, + onCrop: (FileDescription) -> Unit, + onDraw: (FileDescription) -> Unit, + onHighQualityChange: (Boolean) -> Unit, + onAllowUpdateChange: (Boolean) -> Unit, + sendEnabled: Boolean, + onSend: () -> Unit +) { + // Edit tools exist for images only; drawing additionally needs a known aspect ratio to map touches. + val image = current?.takeIf { it.kind == MediaKind.IMAGE } + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(TOOL_ROW_SPACING_DP.dp), + modifier = Modifier.fillMaxWidth().padding(start = 16.dp, end = 16.dp, bottom = 12.dp) + ) { + // The pill gets all the width the send button leaves; it scrolls instead of squeezing its buttons. + Box(modifier = Modifier.weight(1f), contentAlignment = Alignment.CenterStart) { + AttachmentToolBar( + state = options, + actions = ToolBarActions( + onCrop = image?.let { { onCrop(it) } }, + onDraw = image?.takeIf { it.aspectRatio != null }?.let { { onDraw(it) } }, + onHighQualityChange = onHighQualityChange, + onAllowUpdateChange = onAllowUpdateChange + ) + ) + } + SendButton(enabled = sendEnabled, onClick = onSend) + } +} - if (hasCompressibleMedia) { - MediaQualityOptionButton( - highQuality = !compressImages, - onHighQualityChange = { highQuality -> compressImages = !highQuality } - ) - } - } - } +@Composable +private fun DrawingOverlay( + target: FileDescription?, + session: DrawingSession, + onCancel: () -> Unit, + onDone: (FileDescription) -> Unit +) { + val ratio = target?.aspectRatio ?: return + DrawingEditor( + imageUri = target.uri, + aspectRatio = ratio, + session = session, + onCancel = onCancel, + onDone = { onDone(target) } + ) +} - CaptionInputBar( - caption = caption, - onCaptionChange = { caption = it }, - sendEnabled = currentFiles.isNotEmpty(), - onSend = { - onSend(currentFiles.toList(), caption, compressImages, allowUpdate) - } - ) +/** Launches uCrop on a file and swaps the cropped/rotated result in at the original's position. */ +@Composable +private fun rememberCropLauncher(viewModel: FileAttachmentPreviewViewModel): (FileDescription) -> Unit { + val context = LocalContext.current + var sourceUri by rememberSaveable { mutableStateOf(null) } + var destinationPath by rememberSaveable { mutableStateOf(null) } + + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + val source = sourceUri + val destination = destinationPath?.let(::File) + val cropped = result.resultCode == Activity.RESULT_OK && result.data?.let { UCrop.getOutput(it) } != null + if (cropped && source != null && destination != null && destination.length() > 0) { + viewModel.applyCrop(source, destination) + } else { + // cancelled or failed: don't leave uCrop's empty/partial output behind + destination?.delete() + } + sourceUri = null + destinationPath = null + } + + return { description -> + val destination = createEditOutputFile(context, description.name, description.mimeType) + if (destination == null) { + Toast.makeText(context, R.string.nc_attachment_edit_failed, Toast.LENGTH_LONG).show() + } else { + sourceUri = description.uri + destinationPath = destination.absolutePath + launcher.launch(createCropIntent(context, description.uri.toUri(), destination, description.mimeType)) } } } +@Suppress("LongParameterList") @Composable -private fun PreviewTopBar(conversationName: String, onDismiss: () -> Unit) { +private fun PreviewTopBar( + conversationName: String, + position: Pair, + selectedCount: Int, + currentSelected: Boolean, + onToggleSelected: () -> Unit, + onDismiss: () -> Unit, + modifier: Modifier = Modifier +) { Row( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier + modifier = modifier .fillMaxWidth() + .background(Brush.verticalGradient(listOf(Color.Black.copy(alpha = SCRIM_ALPHA), Color.Transparent))) + .statusBarsPadding() .height(APP_BAR_HEIGHT_DP.dp) .padding(horizontal = APP_BAR_HORIZONTAL_PADDING_DP.dp) ) { IconButton(onClick = onDismiss) { Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(R.string.nc_common_dismiss) + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.nc_common_dismiss), + tint = Color.White ) } @@ -214,148 +353,61 @@ private fun PreviewTopBar(conversationName: String, onDismiss: () -> Unit) { ) { Text( text = conversationName, - style = MaterialTheme.typography.titleLarge, - color = MaterialTheme.colorScheme.onSurface, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - Text( - text = stringResource(R.string.nc_add_file), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + style = MaterialTheme.typography.titleMedium, + color = Color.White, maxLines = 1, overflow = TextOverflow.Ellipsis ) + if (position.second > 1) { + Text( + text = stringResource(R.string.nc_attachment_position, position.first + 1, position.second), + style = MaterialTheme.typography.labelMedium, + color = Color.White.copy(alpha = 0.8f) + ) + } } - } -} - -private const val OPTION_BUTTON_ICON_SIZE_DP = 16 -private const val OPTION_BUTTON_HORIZONTAL_PADDING_DP = 12 -private const val OPTION_BUTTON_VERTICAL_PADDING_DP = 6 -private const val OPTION_BUTTON_LABEL_PADDING_DP = 4 -private const val TEXT_BADGE_BORDER_DP = 1 -private const val TEXT_BADGE_CORNER_RADIUS_DP = 4 -private const val TEXT_BADGE_HORIZONTAL_PADDING_DP = 3 -@Composable -private fun OptionButton(label: String, onClick: () -> Unit, icon: @Composable () -> Unit) { - Button( - onClick = onClick, - shape = CircleShape, - colors = ButtonDefaults.buttonColors( - containerColor = MaterialTheme.colorScheme.surfaceVariant, - contentColor = MaterialTheme.colorScheme.onSurfaceVariant - ), - contentPadding = PaddingValues( - horizontal = OPTION_BUTTON_HORIZONTAL_PADDING_DP.dp, - vertical = OPTION_BUTTON_VERTICAL_PADDING_DP.dp - ) - ) { - icon() - Text( - text = label, - style = MaterialTheme.typography.labelMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(horizontal = OPTION_BUTTON_LABEL_PADDING_DP.dp) - ) - Icon( - imageVector = Icons.Filled.ArrowDropDown, - contentDescription = null, - modifier = Modifier.size(OPTION_BUTTON_ICON_SIZE_DP.dp) + SelectionMark( + selected = currentSelected, + onClick = onToggleSelected, + modifier = Modifier.padding(end = 4.dp) ) + SelectedCount(selectedCount) } } @Composable -private fun TextBadge(text: String) { - Text( - text = text, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = LocalContentColor.current, +private fun SelectedCount(count: Int) { + Box( + contentAlignment = Alignment.Center, modifier = Modifier - .border( - width = TEXT_BADGE_BORDER_DP.dp, - color = LocalContentColor.current, - shape = RoundedCornerShape(TEXT_BADGE_CORNER_RADIUS_DP.dp) - ) - .padding(horizontal = TEXT_BADGE_HORIZONTAL_PADDING_DP.dp) - ) -} - -@Composable -private fun MediaQualityOptionButton(highQuality: Boolean, onHighQualityChange: (Boolean) -> Unit) { - var expanded by remember { mutableStateOf(false) } - - Box { - OptionButton( - label = stringResource( - if (highQuality) R.string.nc_media_quality_original else R.string.nc_media_quality_reduced - ), - onClick = { expanded = true }, - icon = { TextBadge(if (highQuality) "HD" else "SD") } - ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.nc_media_quality_original)) }, - leadingIcon = { TextBadge("HD") }, - trailingIcon = { if (highQuality) Icon(Icons.Filled.Check, contentDescription = null) }, - onClick = { - onHighQualityChange(true) - expanded = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.nc_media_quality_reduced)) }, - leadingIcon = { TextBadge("SD") }, - trailingIcon = { if (!highQuality) Icon(Icons.Filled.Check, contentDescription = null) }, - onClick = { - onHighQualityChange(false) - expanded = false - } - ) - } + .padding(horizontal = 8.dp) + .size(SELECTION_MARK_SIZE_DP.dp) + .border(SELECTION_MARK_BORDER_DP.dp, Color.White, CircleShape) + ) { + Text(text = count.toString(), style = MaterialTheme.typography.labelLarge, color = Color.White) } } +/** The round tick that includes/excludes the current file; the last selected file can't be un-ticked. */ @Composable -private fun FilePermissionOptionButton(allowUpdate: Boolean, onAllowUpdateChange: (Boolean) -> Unit) { - var expanded by remember { mutableStateOf(false) } - - Box { - OptionButton( - label = stringResource( - if (allowUpdate) R.string.nc_file_permission_editable else R.string.nc_file_permission_view_only - ), - onClick = { expanded = true }, - icon = { - Icon( - imageVector = if (allowUpdate) Icons.Filled.Edit else Icons.Filled.EditOff, - contentDescription = null, - modifier = Modifier.size(OPTION_BUTTON_ICON_SIZE_DP.dp) - ) - } - ) - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - DropdownMenuItem( - text = { Text(stringResource(R.string.nc_file_permission_view_only)) }, - leadingIcon = { Icon(Icons.Filled.EditOff, contentDescription = null) }, - trailingIcon = { if (!allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) }, - onClick = { - onAllowUpdateChange(false) - expanded = false - } - ) - DropdownMenuItem( - text = { Text(stringResource(R.string.nc_file_permission_editable)) }, - leadingIcon = { Icon(Icons.Filled.Edit, contentDescription = null) }, - trailingIcon = { if (allowUpdate) Icon(Icons.Filled.Check, contentDescription = null) }, - onClick = { - onAllowUpdateChange(true) - expanded = false - } +private fun SelectionMark(selected: Boolean, onClick: () -> Unit, modifier: Modifier = Modifier) { + val description = stringResource(R.string.nc_attachment_include) + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(SELECTION_MARK_SIZE_DP.dp) + .clip(CircleShape) + .background(if (selected) MaterialTheme.colorScheme.primary else Color.Transparent) + .border(SELECTION_MARK_BORDER_DP.dp, if (selected) Color.Transparent else Color.White, CircleShape) + .toggleable(value = selected, role = Role.Checkbox, onValueChange = { onClick() }) + .semantics { contentDescription = description } + ) { + if (selected) { + Icon( + imageVector = Icons.Filled.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimary ) } } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt index f365753a92..452ffd2581 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt @@ -7,24 +7,31 @@ package com.nextcloud.talk.attachmentpreview import android.content.Context +import android.net.Uri +import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.net.toUri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.core.net.toUri import com.nextcloud.talk.utils.FileUtils import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import java.io.File import javax.inject.Inject /** - * Holds the file list being reviewed for upload and each file's (IO-derived) [FileDescription], - * so both survive configuration changes (e.g. screen rotation) instead of resetting back to the - * dialog's original arguments. Everything else on the screen — caption text, drag/scroll - * position, HQ toggle animation state — is ephemeral UI state and stays in Compose's `remember`. + * Holds the file list being reviewed for upload, which of them are excluded from sending, and + * each file's (IO-derived) [FileDescription], so all survive configuration changes (e.g. screen + * rotation) instead of resetting back to the dialog's original arguments. Everything else on the + * screen — caption text, drag/scroll position, HQ toggle animation state — is ephemeral UI state + * and stays in Compose's `remember`. */ internal class FileAttachmentPreviewViewModel( private val context: Context, @@ -64,8 +71,92 @@ internal class FileAttachmentPreviewViewModel( files.addAll(newFiles.filterNot { it in files }) } - fun removeFile(uri: String) { - files.remove(uri) + /** Files the user un-ticked; they stay in the list (and can be re-ticked) but are not sent. */ + val unselected = mutableStateListOf() + + /** True while an edit (e.g. a drawing) is being written, so sending can't race it with the old file. */ + var isEditing by mutableStateOf(false) + private set + + /** Set when an edit could not be saved; the screen reports it once and calls [editFailureShown]. */ + var editFailed by mutableStateOf(false) + private set + + /** The drawing in progress; here (not in `remember`) so it survives rotation. */ + val drawing = DrawingSession() + + fun editFailureShown() { + editFailed = false + } + + fun toggleSelected(uri: String) { + val updated = toggleSelection(files, unselected.toSet(), uri) + unselected.clear() + unselected.addAll(updated) + } + + fun selectedFiles(): List = selectedFiles(files, unselected.toSet()) + + /** + * Swaps an edited file in at the position of the original. The old description is carried over + * right away (re-pointed at [newUri]) so the pager doesn't drop the page while the fresh + * description is computed; [describeFiles] then refreshes size/resolution. + */ + fun replaceFile(oldUri: String, newUri: String) { + val updated = replaceUri(files, oldUri, newUri) + if (updated === files) return + files.clear() + files.addAll(updated) + if (unselected.remove(oldUri)) { + unselected.add(newUri) + } + _descriptionsByUri[oldUri]?.let { _descriptionsByUri[newUri] = it.copy(uri = newUri) } + _descriptionsByUri.remove(oldUri) + } + + /** Burns the current [drawing] strokes into a copy of [description]'s image; keeps the original on failure. */ + fun saveDrawing(description: FileDescription) { + val drawn = drawing.strokes + drawing.clear() + if (drawn.isEmpty()) return + isEditing = true + viewModelScope.launch { + val output = withContext(Dispatchers.IO) { + createEditOutputFile(context, description.name, description.mimeType)?.let { file -> + val saved = renderDrawing( + context, + description.uri.toUri(), + drawn, + file, + editOutputIsPng(description.mimeType) + ) + (if (saved) editedFileUri(context, file) else null).also { if (it == null) file.delete() } + } + } + finishEdit(description.uri, output) + } + } + + /** Swaps in the file uCrop wrote to [croppedFile]; reports a failure if it can't be shared. */ + fun applyCrop(sourceUri: String, croppedFile: File) { + val uri = editedFileUri(context, croppedFile) + if (uri == null) croppedFile.delete() + finishEdit(sourceUri, uri) + } + + private fun finishEdit(oldUri: String, newUri: Uri?) { + if (newUri == null) { + editFailed = true + } else { + replaceFile(oldUri, newUri.toString()) + discardIfOwnEdit(oldUri) + } + isEditing = false + } + + /** An intermediate of an earlier edit is of no use once superseded; originals are never touched. */ + private fun discardIfOwnEdit(uri: String) { + viewModelScope.launch(Dispatchers.IO) { ownEditedFile(context, uri)?.delete() } } fun reorder(from: Int, to: Int) { @@ -84,13 +175,13 @@ internal class FileAttachmentPreviewViewModel( // a stale job finishing last would retainAll away the description of a file added since describeJob?.cancel() describeJob = viewModelScope.launch(ioDispatcher) { - val described = snapshot.associateWith { describer(context, it, compress) } - // the blocking copy above cannot be interrupted, so a cancelled job reaches this point too; - // withContext checks for cancellation and runs on main, where cancel() is also called - withContext(mainDispatcher) { - _descriptionsByUri.putAll(described) - _descriptionsByUri.keys.retainAll(described.keys) + val described = snapshot.associateWith { + ensureActive() + describer(context, it, compress) } + ensureActive() + // files is mutated on the main thread, so publish there + withContext(mainDispatcher) { publishDescriptions(described) } } } @@ -109,4 +200,15 @@ internal class FileAttachmentPreviewViewModel( } } } + + /** + * Stores [described], keeping only files that are still in the list. A result computed before + * [replaceFile] therefore can't wipe out the replacement's carried-over description (which would + * drop the edited page from the pager) nor leave entries for files that are gone. + */ + fun publishDescriptions(described: Map) { + val current = files.toSet() + described.filterKeys { it in current }.forEach { (uri, description) -> _descriptionsByUri[uri] = description } + _descriptionsByUri.keys.retainAll(current) + } } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt index 4c64ed757c..4abb76ae51 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileDescriber.kt @@ -29,14 +29,6 @@ private val TAG = FileAttachmentPreviewViewModel::class.java.simpleName internal fun isCompressible(mimeType: String?): Boolean = ImageCompressor.isCompressible(mimeType) || VideoCompressor.isCompressible(mimeType) -/** - * [current] is the detail text for the file's active compress setting, [alternate] is what it - * would read as under the other setting. Both are computed regardless of [FileDescription]'s own - * `compress` flag, so the large preview's detail chip can reserve width for whichever is wider and - * never resize when the HQ toggle flips which one is actually shown. - */ -private data class DetailVariants(val current: String, val alternate: String) - @Suppress("ReturnCount") internal fun describeFile(context: Context, uriString: String, compress: Boolean): FileDescription { val uri = uriString.toUri() @@ -48,7 +40,7 @@ internal fun describeFile(context: Context, uriString: String, compress: Boolean val sizeOnly = formatSize(context, file.length()) val variants = when (kind) { - MediaKind.IMAGE -> describeImageDetail(context, file, compress, sizeOnly) + MediaKind.IMAGE -> describeImageDetail(context, file, mimeType, compress, sizeOnly) MediaKind.VIDEO -> describeVideoDetail(context, file, compress, sizeOnly) MediaKind.OTHER -> "$name, $sizeOnly".let { DetailVariants(it, it) } } @@ -72,31 +64,24 @@ internal fun describeFile(context: Context, uriString: String, compress: Boolean @Suppress("ReturnCount") private fun imageAspectRatio(file: File): Float? { - val info = ImageCompressor.readImageInfo(file) ?: return null + val info = ImageCompressor.readImageInfo(file)?.inDisplayOrientation(readExifOrientation(file)) ?: return null if (info.height <= 0) return null - val (width, height) = if (isSidewaysExifOrientation(file)) { - info.height to info.width - } else { - info.width to info.height - } - if (height <= 0) return null - return width.toFloat() / height.toFloat() + return info.width.toFloat() / info.height.toFloat() } // BitmapFactory's decode bounds are the raw, un-rotated pixel grid, so a photo whose sensor-native // orientation is landscape (with an EXIF tag marking it as rotated for portrait display) reports -// swapped width/height here compared to how it actually renders. Swap them back so the aspect ratio -// used to size the preview matches what Coil (which does honor EXIF) actually draws. -private fun isSidewaysExifOrientation(file: File): Boolean = +// swapped width/height compared to how it actually renders. Callers swap them back so the aspect +// ratio and the size caption match what Coil and the compressor (both honor EXIF) actually produce. +private fun readExifOrientation(file: File): Int = try { - val orientation = ExifInterface(file.absolutePath).getAttributeInt( + ExifInterface(file.absolutePath).getAttributeInt( ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL ) - orientation == ExifInterface.ORIENTATION_ROTATE_90 || orientation == ExifInterface.ORIENTATION_ROTATE_270 } catch (e: IOException) { NextcloudTalkApplication.sharedApplication?.logger?.w(TAG, "Failed to read EXIF orientation", e) - false + ExifInterface.ORIENTATION_NORMAL } @Suppress("ReturnCount") @@ -156,12 +141,18 @@ private fun downscaleIfNeeded(bitmap: Bitmap): Bitmap { } @Suppress("ReturnCount") -private fun describeImageDetail(context: Context, file: File, compress: Boolean, fallback: String): DetailVariants { - val original = ImageCompressor.readImageInfo(file) ?: return DetailVariants(fallback, fallback) - val originalText = describeMedia(context, original.width, original.height, original.sizeBytes) - val compressed = ImageCompressor.estimateCompression(file) - val compressedText = compressed?.let { describeMedia(context, it.width, it.height, it.sizeBytes) } ?: originalText - return if (compress) DetailVariants(compressedText, originalText) else DetailVariants(originalText, compressedText) +private fun describeImageDetail( + context: Context, + file: File, + mimeType: String?, + compress: Boolean, + fallback: String +): DetailVariants { + val original = ImageCompressor.readImageInfo(file)?.inDisplayOrientation(readExifOrientation(file)) + ?: return DetailVariants(fallback, fallback) + return imageDetailVariants(original, compressedImageInfo(mimeType, file), compress) { + describeMedia(context, it.width, it.height, it.sizeBytes) + } } @Suppress("ReturnCount") diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt index 0f3d0b8a48..1379a36693 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileThumbnailImage.kt @@ -17,7 +17,7 @@ import androidx.compose.runtime.Composable 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.Shape import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource @@ -41,17 +41,15 @@ internal fun FileThumbnailImage( modifier: Modifier = Modifier, iconSize: Dp = THUMBNAIL_ICON_SIZE_DP.dp, contentScale: ContentScale = ContentScale.Crop, - backgroundColor: Color = MaterialTheme.colorScheme.surfaceVariant + shape: Shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp) ) { - val shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp) - when (description.kind) { MediaKind.IMAGE -> AsyncImage( model = description.uri, contentDescription = description.name, contentScale = contentScale, - modifier = modifier.clip(shape).background(backgroundColor) + modifier = modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceVariant) ) MediaKind.VIDEO -> @@ -60,11 +58,11 @@ internal fun FileThumbnailImage( bitmap = description.videoThumbnail.asImageBitmap(), contentDescription = description.name, contentScale = contentScale, - modifier = modifier.clip(shape).background(backgroundColor) + modifier = modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceVariant) ) } else { Box( - modifier = modifier.clip(shape).background(backgroundColor), + modifier = modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center ) { Icon( @@ -78,7 +76,7 @@ internal fun FileThumbnailImage( MediaKind.OTHER -> { val mimetypeIcon = DrawableUtils.getDrawableResourceIdForMimeType(description.mimeType) Box( - modifier = modifier.clip(shape).background(backgroundColor), + modifier = modifier.clip(shape).background(MaterialTheme.colorScheme.surfaceVariant), contentAlignment = Alignment.Center ) { Icon( diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogic.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogic.kt new file mode 100644 index 0000000000..da1bcba6a9 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogic.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.attachmentpreview + +import androidx.exifinterface.media.ExifInterface +import com.nextcloud.talk.utils.ImageCompressor +import com.nextcloud.talk.utils.ImageCompressor.ImageInfo +import java.io.File + +/** True when the EXIF orientation turns the image by 90/270 degrees (also mirrored), swapping width and height. */ +internal fun exifSwapsDimensions(orientation: Int): Boolean = + orientation == ExifInterface.ORIENTATION_ROTATE_90 || + orientation == ExifInterface.ORIENTATION_ROTATE_270 || + orientation == ExifInterface.ORIENTATION_TRANSPOSE || + orientation == ExifInterface.ORIENTATION_TRANSVERSE + +/** Pixel size of an image as it is displayed: the decode-bounds size with the EXIF orientation applied. */ +internal fun ImageInfo.inDisplayOrientation(orientation: Int): ImageInfo = + if (exifSwapsDimensions(orientation)) copy(width = height, height = width) else this + +/** + * Detail texts of an image. [original] is the file as it is, in display orientation; [compressed] is what + * the compressor produces from it (null when it can't be estimated, then the original is shown for both). + */ +internal fun imageDetailVariants( + original: ImageInfo, + compressed: ImageInfo?, + compress: Boolean, + describe: (ImageInfo) -> String +): DetailVariants { + val originalText = describe(original) + val compressedText = compressed?.let(describe) ?: originalText + return if (compress) DetailVariants(compressedText, originalText) else DetailVariants(originalText, compressedText) +} + +/** What the upload worker makes of [file]; null for types it sends untouched (GIF) or when it can't be decoded. */ +internal fun compressedImageInfo( + mimeType: String?, + file: File, + estimate: (File) -> ImageInfo? = ImageCompressor::estimateCompression +): ImageInfo? = if (ImageCompressor.isCompressible(mimeType)) estimate(file) else null diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt index cc5a244e40..ab73b97082 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/LargePreview.kt @@ -16,10 +16,9 @@ import androidx.compose.animation.togetherWith import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -44,6 +43,7 @@ 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.RectangleShape import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalContext @@ -66,8 +66,6 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.ui.PlayerView import com.nextcloud.talk.R -private const val LARGE_MAX_HEIGHT_DP = 480 -private const val LARGE_VERTICAL_SPACING_DP = 12 private const val LARGE_ICON_SIZE_DP = 96 private const val LARGE_OTHER_BORDER_WIDTH_DP = 1 private const val LARGE_OTHER_BORDER_PADDING_DP = 24 @@ -81,29 +79,29 @@ private const val CLOSE_BUTTON_SIZE_DP = 40 private const val CLOSE_ICON_SIZE_DP = 20 private const val CLOSE_BUTTON_PADDING_DP = 8 -/** The large, swipeable preview shown above the thumbnail strip. */ +/** + * The full-screen, swipeable preview. [videoPadding] keeps an inline video player's own controls and + * close button clear of the overlaid top and bottom bars; images simply run behind them. + */ @Composable -internal fun LargePreview(descriptions: List, pagerState: PagerState, modifier: Modifier = Modifier) { - Box( - modifier = modifier - .fillMaxWidth() - .padding(vertical = LARGE_VERTICAL_SPACING_DP.dp), - contentAlignment = Alignment.Center - ) { - HorizontalPager( - state = pagerState, - key = { page -> descriptions.getOrNull(page)?.uri ?: page }, - modifier = Modifier - .fillMaxSize() - .heightIn(max = LARGE_MAX_HEIGHT_DP.dp) - ) { page -> - descriptions.getOrNull(page)?.let { description -> LargePage(description) } - } +internal fun LargePreview( + descriptions: List, + pagerState: PagerState, + videoPadding: PaddingValues, + detailTopPadding: Dp, + modifier: Modifier = Modifier +) { + HorizontalPager( + state = pagerState, + key = { page -> descriptions.getOrNull(page)?.uri ?: page }, + modifier = modifier.fillMaxSize() + ) { page -> + descriptions.getOrNull(page)?.let { description -> LargePage(description, videoPadding, detailTopPadding) } } } @Composable -private fun LargePage(description: FileDescription) { +private fun LargePage(description: FileDescription, videoPadding: PaddingValues, detailTopPadding: Dp) { var isPlayingVideo by remember(description.uri, description.detail) { mutableStateOf(false) } BoxWithConstraints(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -113,8 +111,8 @@ private fun LargePage(description: FileDescription) { } ?: Modifier.fillMaxSize() // Non-media files have no image content of their own to visually delimit the card, so - // without an explicit outline the icon would appear to float directly on the dialog's - // background instead of reading as a bounded preview, unlike images/videos. + // without an explicit outline the icon would appear to float directly on the dark screen + // instead of reading as a bounded preview, unlike images/videos. if (description.kind == MediaKind.OTHER) { cardModifier = cardModifier .padding(LARGE_OTHER_BORDER_PADDING_DP.dp) @@ -125,19 +123,23 @@ private fun LargePage(description: FileDescription) { ) } - Box(modifier = cardModifier) { - if (description.kind == MediaKind.VIDEO && isPlayingVideo) { - VideoPlayerCard( - uri = description.uri, - onStopped = { isPlayingVideo = false }, - modifier = Modifier.fillMaxSize() - ) - } else { + if (description.kind == MediaKind.VIDEO && isPlayingVideo) { + VideoPlayerCard( + uri = description.uri, + onStopped = { isPlayingVideo = false }, + modifier = Modifier.fillMaxSize().padding(videoPadding) + ) + } else { + Box(modifier = cardModifier) { FileThumbnailImage( description, iconSize = LARGE_ICON_SIZE_DP.dp, contentScale = ContentScale.Fit, - backgroundColor = MaterialTheme.colorScheme.surface, + shape = if (description.kind == MediaKind.OTHER) { + RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp) + } else { + RectangleShape + }, modifier = Modifier.fillMaxSize() ) @@ -147,16 +149,16 @@ private fun LargePage(description: FileDescription) { modifier = Modifier.align(Alignment.Center) ) } + } - description.detail?.let { detail -> - LargeDetailOverlay( - detail, - description.alternateDetail, - modifier = Modifier - .align(Alignment.BottomStart) - .padding(12.dp) - ) - } + description.detail?.let { detail -> + LargeDetailOverlay( + detail, + description.alternateDetail, + modifier = Modifier + .align(Alignment.TopStart) + .padding(start = 12.dp, top = detailTopPadding) + ) } } } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt index 9acac64bb0..8cfeb3857a 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStrip.kt @@ -6,7 +6,6 @@ */ package com.nextcloud.talk.attachmentpreview -import androidx.annotation.StringRes import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable @@ -25,11 +24,6 @@ import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.Delete -import androidx.compose.material.icons.outlined.PhotoCamera -import androidx.compose.material.icons.outlined.PhotoLibrary -import androidx.compose.material.icons.outlined.Videocam import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable @@ -42,35 +36,34 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription import androidx.compose.ui.unit.dp import androidx.compose.ui.zIndex import com.nextcloud.talk.R import kotlin.math.roundToInt -private const val STRIP_THUMBNAIL_SIZE_DP = 64 -private const val STRIP_ICON_SIZE_DP = 28 -private const val STRIP_PLAY_ICON_SIZE_DP = 28 +private const val STRIP_THUMBNAIL_SIZE_DP = 48 +private const val STRIP_ICON_SIZE_DP = 24 +private const val STRIP_PLAY_ICON_SIZE_DP = 24 private const val STRIP_ITEM_SPACING_DP = 8 private const val SELECTED_BORDER_WIDTH_DP = 2 private const val INDICATOR_WIDTH_DP = 3 private const val INDICATOR_GAP_OFFSET_DP = 5 -@Suppress("LongParameterList") +/** Compact strip of the picked files above the caption bar: tap to jump to a file, long-press and drag to reorder. */ @Composable internal fun ThumbnailStrip( descriptions: List, selectedIndex: Int, + unselected: Set, onSelect: (Int) -> Unit, - onRemove: (String) -> Unit, - onReorder: (from: Int, to: Int) -> Unit, - onAddMore: () -> Unit, - onTakePhoto: () -> Unit, - onTakeVideo: () -> Unit + onReorder: (from: Int, to: Int) -> Unit ) { val density = LocalDensity.current val itemExtentPx = remember(density) { @@ -78,57 +71,22 @@ internal fun ThumbnailStrip( } val dragState = remember { DragReorderState() } - // With no file thumbnails shown (single-file case, handled below), the action tiles are the - // only content — center them instead of leaving them stuck to the start like a scrollable list. - val horizontalArrangement = if (descriptions.size > 1) { - Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp) - } else { - Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp, Alignment.CenterHorizontally) - } - LazyRow( - horizontalArrangement = horizontalArrangement, + horizontalArrangement = Arrangement.spacedBy(STRIP_ITEM_SPACING_DP.dp), contentPadding = PaddingValues(horizontal = 16.dp, vertical = 8.dp), modifier = Modifier.fillMaxWidth() ) { - // With a single file it's already shown in full in the large preview above, and the - // "selected" thumbnail styling (dark overlay + trash icon) would otherwise cover the - // only thumbnail permanently — looking like the file is already marked for deletion - // rather than just being the one file that was picked. - if (descriptions.size > 1) { - itemsIndexed(descriptions, key = { _, description -> description.uri }) { index, description -> - DraggableStripItem( - description = description, - index = index, - selected = index == selectedIndex, - itemExtentPx = itemExtentPx, - itemCount = descriptions.size, - dragState = dragState, - onSelect = onSelect, - onRemove = onRemove, - onReorder = onReorder - ) - } - } - item { - ActionTile( - icon = Icons.Outlined.PhotoLibrary, - contentDescription = R.string.nc_add_more_files, - onClick = onAddMore - ) - } - item { - ActionTile( - icon = Icons.Outlined.PhotoCamera, - contentDescription = R.string.take_photo, - onClick = onTakePhoto - ) - } - item { - ActionTile( - icon = Icons.Outlined.Videocam, - contentDescription = R.string.nc_take_video, - onClick = onTakeVideo + itemsIndexed(descriptions, key = { _, description -> description.uri }) { index, description -> + DraggableStripItem( + description = description, + index = index, + current = index == selectedIndex, + included = description.uri !in unselected, + itemExtentPx = itemExtentPx, + itemCount = descriptions.size, + dragState = dragState, + onSelect = onSelect, + onReorder = onReorder ) } } @@ -152,12 +110,12 @@ private class DragReorderState { private fun DraggableStripItem( description: FileDescription, index: Int, - selected: Boolean, + current: Boolean, + included: Boolean, itemExtentPx: Float, itemCount: Int, dragState: DragReorderState, onSelect: (Int) -> Unit, - onRemove: (String) -> Unit, onReorder: (Int, Int) -> Unit ) { val isDragged = index == dragState.draggedIndex.value @@ -166,9 +124,9 @@ private fun DraggableStripItem( Box { StripThumbnail( description = description, - selected = selected, + current = current, + included = included, onClick = { onSelect(index) }, - onRemove = { onRemove(description.uri) }, modifier = Modifier .graphicsLayer { translationX = if (isDragged) dragState.dragOffsetX.value else 0f } .zIndex(if (isDragged) 1f else 0f) @@ -245,19 +203,24 @@ private fun DropIndicatorLine(modifier: Modifier = Modifier) { @Composable private fun StripThumbnail( description: FileDescription, - selected: Boolean, + current: Boolean, + included: Boolean, onClick: () -> Unit, - onRemove: () -> Unit, modifier: Modifier = Modifier ) { - val borderColor = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant + val borderColor = if (current) Color.White else Color.Transparent val shape = RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp) + val excludedState = stringResource(R.string.nc_attachment_excluded) Box( modifier = modifier .size(STRIP_THUMBNAIL_SIZE_DP.dp) .clip(shape) .border(SELECTED_BORDER_WIDTH_DP.dp, borderColor, shape) - .clickable(onClick = if (selected) onRemove else onClick) + .clickable(onClick = onClick) + .semantics { + selected = current + if (!included) stateDescription = excludedState + } ) { FileThumbnailImage( description, @@ -265,7 +228,7 @@ private fun StripThumbnail( modifier = Modifier.fillMaxSize() ) - if (description.kind == MediaKind.VIDEO && !selected) { + if (description.kind == MediaKind.VIDEO) { Box( modifier = Modifier .size(STRIP_PLAY_ICON_SIZE_DP.dp) @@ -283,39 +246,8 @@ private fun StripThumbnail( } } - if (selected) { - Box( - modifier = Modifier - .fillMaxSize() - .background(Color.Black.copy(alpha = 0.5f)), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = Icons.Outlined.Delete, - contentDescription = stringResource(R.string.nc_remove_file), - tint = Color.White, - modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp) - ) - } + if (!included) { + Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.6f))) } } } - -@Composable -private fun ActionTile(icon: ImageVector, @StringRes contentDescription: Int, onClick: () -> Unit) { - Box( - modifier = Modifier - .size(STRIP_THUMBNAIL_SIZE_DP.dp) - .clip(RoundedCornerShape(THUMBNAIL_CORNER_RADIUS_DP.dp)) - .background(MaterialTheme.colorScheme.secondaryContainer) - .clickable(onClick = onClick), - contentAlignment = Alignment.Center - ) { - Icon( - imageVector = icon, - contentDescription = stringResource(contentDescription), - tint = MaterialTheme.colorScheme.onSecondaryContainer, - modifier = Modifier.size(STRIP_ICON_SIZE_DP.dp) - ) - } -} diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStripPreviews.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStripPreviews.kt index d2a57e987b..0917522205 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStripPreviews.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/ThumbnailStripPreviews.kt @@ -55,19 +55,20 @@ private fun previewDescriptions() = ) @Composable -private fun ThumbnailStripPreviewContainer(descriptions: List, selectedIndex: Int) { +private fun ThumbnailStripPreviewContainer( + descriptions: List, + selectedIndex: Int, + unselected: Set = emptySet() +) { val colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme() MaterialTheme(colorScheme = colorScheme) { Surface { ThumbnailStrip( descriptions = descriptions, selectedIndex = selectedIndex, + unselected = unselected, onSelect = {}, - onRemove = {}, - onReorder = { _, _ -> }, - onAddMore = {}, - onTakePhoto = {}, - onTakeVideo = {} + onReorder = { _, _ -> } ) } } @@ -81,7 +82,11 @@ private fun ThumbnailStripPreviewContainer(descriptions: List, ) @Composable private fun ThumbnailStripPreview() { - ThumbnailStripPreviewContainer(descriptions = previewDescriptions(), selectedIndex = 0) + ThumbnailStripPreviewContainer( + descriptions = previewDescriptions(), + selectedIndex = 0, + unselected = setOf("file:///sdcard/Download/archive.zip") + ) } /** The mimetype-icon tiles, where [mimetypeIconTint] decides whether an icon is themed or keeps its own colors. */ @@ -103,13 +108,3 @@ private fun ThumbnailStripDocumentsPreview() { selectedIndex = 1 ) } - -/** A single file shows no thumbnails at all - just the centered action tiles. */ -@Preview(name = "Single File", showBackground = true) -@Composable -private fun ThumbnailStripSingleFilePreview() { - ThumbnailStripPreviewContainer( - descriptions = previewDescriptions().take(1), - selectedIndex = 0 - ) -} 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 d40b8801e9..5a292e2d5f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -620,6 +620,7 @@ class ChatActivity : // Not loaded in a coroutine: initData() must have run before onResume() (activity and ChatViewModel), // and registerForActivityResult() must be called before the activity is started. initialUser = setUpBoundUserOrFinish() ?: return + registerAttachmentPreviewResultListener() binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) @@ -2827,15 +2828,29 @@ class ChatActivity : } } + private fun registerAttachmentPreviewResultListener() { + supportFragmentManager.setFragmentResultListener( + FileAttachmentPreviewFragment.RESULT_KEY, + this + ) { _, result -> + val files = result.getStringArrayList(FileAttachmentPreviewFragment.RESULT_FILES) + if (files != null) { + uploadFiles( + files, + result.getString(FileAttachmentPreviewFragment.RESULT_CAPTION, ""), + result.getBoolean(FileAttachmentPreviewFragment.RESULT_COMPRESS_IMAGES), + result.getBoolean(FileAttachmentPreviewFragment.RESULT_ALLOW_UPDATE) + ) + } + } + } + private fun showFileAttachmentPreview(files: MutableList) { val newFragment = FileAttachmentPreviewFragment.newInstance( files, currentConversation?.displayName ?: "", CapabilitiesUtil.hasConversationSubfoldersForAttachments(spreedCapabilities) ) - newFragment.setListener { selectedFiles, caption, compressImages, allowUpdate -> - uploadFiles(selectedFiles, caption, compressImages, allowUpdate) - } newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG) } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d44096ff4b..d9844dacc5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -695,7 +695,6 @@ How to translate with transifex: Play media file - Add to conversation Select more photos and videos Photo Video, %1$s @@ -1023,7 +1022,20 @@ How to translate with transifex: Compress images and videos HQ High quality, no compression - Remove file + %1$d / %2$d + Include this file + Crop and rotate + Draw + Undo + Add photos or video + Not included in the message + Could not save the changes to the picture + White + Black + Red + Yellow + Green + Blue Add more files Retrieval failed Languages could not be retrieved diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentSelectionAndNamingTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentSelectionAndNamingTest.kt new file mode 100644 index 0000000000..1ddec9a007 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentSelectionAndNamingTest.kt @@ -0,0 +1,93 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertSame +import org.junit.Assert.assertTrue +import org.junit.Test + +class AttachmentSelectionAndNamingTest { + + private val files = listOf("a", "b", "c") + + @Test + fun replaceUriKeepsPosition() { + assertEquals(listOf("a", "B", "c"), replaceUri(files, "b", "B")) + } + + @Test + fun replaceUriOfUnknownFileChangesNothing() { + assertSame(files, replaceUri(files, "x", "y")) + } + + @Test + fun replaceUriWithSameValueChangesNothing() { + assertSame(files, replaceUri(files, "a", "a")) + } + + @Test + fun toggleExcludesAndReincludesFile() { + val excluded = toggleSelection(files, emptySet(), "b") + assertEquals(setOf("b"), excluded) + assertEquals(emptySet(), toggleSelection(files, excluded, "b")) + } + + @Test + fun lastSelectedFileCannotBeExcluded() { + val unselected = setOf("a", "b") + assertEquals(unselected, toggleSelection(files, unselected, "c")) + } + + @Test + fun singleFileCannotBeExcluded() { + assertEquals(emptySet(), toggleSelection(listOf("a"), emptySet(), "a")) + } + + @Test + fun toggleOfUnknownFileChangesNothing() { + assertEquals(setOf("a"), toggleSelection(files, setOf("a"), "zzz")) + } + + @Test + fun selectedFilesKeepListOrder() { + assertEquals(listOf("a", "c"), selectedFiles(files, setOf("b"))) + } + + @Test + fun editedFileNameAddsSuffixAndStamp() { + assertEquals("photo_edited_20261004-115100-123.jpg", editedFileName("photo.jpg", "20261004-115100-123", "jpg")) + } + + @Test + fun editedFileNameDoesNotStackSuffixes() { + val once = editedFileName("photo.jpg", "20261004-115100-123", "jpg") + assertEquals("photo_edited_20261004-120000-001.png", editedFileName(once, "20261004-120000-001", "png")) + } + + @Test + fun editedFileNameHandlesNamesWithoutExtensionOrBase() { + assertEquals("scan_edited_1.jpg", editedFileName("scan", "1", "jpg")) + assertEquals("image_edited_1.jpg", editedFileName(".jpg", "1", "jpg")) + } + + @Test + fun editedFileNamesAreRecognisedAndOriginalsAreNot() { + assertTrue(isEditedFileName(editedFileName("photo.jpg", "20261004-115100-123", "jpg"))) + assertFalse(isEditedFileName("photo.jpg")) + assertFalse(isEditedFileName("2026-10-04 11-51-00.jpg")) + assertFalse(isEditedFileName("my_edited_notes.jpg")) + } + + @Test + fun onlyPngSourcesStayPng() { + assertTrue(editOutputIsPng("image/png")) + assertFalse(editOutputIsPng("image/jpeg")) + assertFalse(editOutputIsPng(null)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarLogicTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarLogicTest.kt new file mode 100644 index 0000000000..efc6c426d1 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/AttachmentToolBarLogicTest.kt @@ -0,0 +1,25 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class AttachmentToolBarLogicTest { + + @Test + fun permissionShrinksToIconWhenFullPillIsWiderThanSlot() { + assertTrue(shouldCompactPermission(fullWidthPx = 910, availableWidthPx = 775)) + } + + @Test + fun permissionKeepsLabelWhenFullPillFits() { + assertFalse(shouldCompactPermission(fullWidthPx = 775, availableWidthPx = 775)) + assertFalse(shouldCompactPermission(fullWidthPx = 600, availableWidthPx = 775)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/DrawingGeometryTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/DrawingGeometryTest.kt new file mode 100644 index 0000000000..ce0b54ab52 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/DrawingGeometryTest.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import org.junit.Assert.assertEquals +import org.junit.Test + +class DrawingGeometryTest { + + @Test + fun fitRectLetterboxesWideImageInTallView() { + val rect = fitRect(1000f, 2000f, 2f) + assertEquals(FitRect(0f, 750f, 1000f, 500f), rect) + } + + @Test + fun fitRectPillarboxesTallImageInWideView() { + val rect = fitRect(2000f, 1000f, 0.5f) + assertEquals(FitRect(750f, 0f, 500f, 1000f), rect) + } + + @Test + fun toNormalizedMapsRectCornersAndClampsOutside() { + val rect = FitRect(0f, 750f, 1000f, 500f) + assertEquals(NormalizedPoint(0f, 0f), toNormalized(0f, 750f, rect)) + assertEquals(NormalizedPoint(1f, 1f), toNormalized(1000f, 1250f, rect)) + assertEquals(NormalizedPoint(0.5f, 0.5f), toNormalized(500f, 1000f, rect)) + assertEquals(NormalizedPoint(0f, 1f), toNormalized(-50f, 5000f, rect)) + } + + @Test + fun viewTouchLandsOnSamePixelRegardlessOfViewSize() { + // A touch at the same relative spot of the picture must give the same pixel on two screens. + val small = toNormalized(150f, 100f, fitRect(300f, 200f, 1.5f)) + val large = toNormalized(450f, 300f, fitRect(900f, 600f, 1.5f)) + assertEquals(small, large) + assertEquals(1224f to 1632f, toPixels(NormalizedPoint(0.5f, 0.5f), 2448, 3264)) + } + + @Test + fun strokeWidthScalesWithBitmapAndHasOnePixelFloor() { + val stroke = DrawStroke(0, 0.01f, emptyList()) + assertEquals(24.48f, strokeWidthPixels(stroke, 2448), 0.001f) + assertEquals(1f, strokeWidthPixels(stroke, 10), 0.001f) + } + + @Test + fun undoLastDropsOnlyNewestStroke() { + val first = DrawStroke(1, 0.01f, listOf(NormalizedPoint(0f, 0f))) + val second = DrawStroke(2, 0.01f, listOf(NormalizedPoint(1f, 1f))) + assertEquals(listOf(first), undoLast(listOf(first, second))) + assertEquals(emptyList(), undoLast(emptyList())) + } + + @Test + fun decodeSampleSizeKeepsFullResolutionBelowLimit() { + assertEquals(1, decodeSampleSize(4000, 3000)) + assertEquals(1, decodeSampleSize(5000, 4000)) + } + + @Test + fun decodeSampleSizeDownsamplesHugeImagesByPowersOfTwo() { + // 48 MP -> 12 MP, 108 MP -> 6.75 MP, 400 MP -> 6.25 MP + assertEquals(2, decodeSampleSize(8000, 6000)) + assertEquals(4, decodeSampleSize(12000, 9000)) + assertEquals(8, decodeSampleSize(20000, 20000)) + } + + @Test + fun drawingSessionKeepsStrokesAndUndoesThem() { + val session = DrawingSession() + val first = DrawStroke(1, 0.01f, listOf(NormalizedPoint(0f, 0f))) + val second = DrawStroke(2, 0.01f, listOf(NormalizedPoint(1f, 1f))) + session.add(first) + session.add(second) + session.undo() + assertEquals(listOf(first), session.strokes) + session.clear() + assertEquals(emptyList(), session.strokes) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditedFileOwnershipTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditedFileOwnershipTest.kt new file mode 100644 index 0000000000..5f6df84ef9 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditedFileOwnershipTest.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.attachmentpreview + +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 EditedFileOwnershipTest { + + private val edited = editedFileName("photo.jpg", "20261004-115100-123", "jpg") + + @Test + fun ownEditedNameAcceptsOnlyOurProviderAndTheSharedDirectory() { + val segments = listOf("shared_attachments", edited) + assertEquals(edited, ownEditedName("com.nextcloud.talk2", "com.nextcloud.talk2", segments)) + } + + @Test + fun ownEditedNameRejectsForeignAuthority() { + assertNull(ownEditedName("com.other", "com.nextcloud.talk2", listOf("shared_attachments", edited))) + assertNull(ownEditedName(null, "com.nextcloud.talk2", listOf("shared_attachments", edited))) + } + + @Test + fun ownEditedNameRejectsNamesWithoutEditSuffix() { + assertNull(ownEditedName("a", "a", listOf("shared_attachments", "photo.jpg"))) + } + + @Test + fun ownEditedNameRejectsNestedOrOtherPaths() { + assertNull(ownEditedName("a", "a", listOf("shared_attachments", "42", edited))) + assertNull(ownEditedName("a", "a", listOf("photos", edited))) + assertNull(ownEditedName("a", "a", listOf(edited))) + assertNull(ownEditedName("a", "a", emptyList())) + } + + @Test + fun onlyJpegWithPlainRotationKeepsTheExifTag() { + assertFalse(rotatesPixels(png = false, rotationDegrees = 90, flipped = false)) + assertFalse(rotatesPixels(png = false, rotationDegrees = 0, flipped = false)) + assertTrue(rotatesPixels(png = true, rotationDegrees = 90, flipped = false)) + assertFalse(rotatesPixels(png = true, rotationDegrees = 0, flipped = false)) + assertTrue(rotatesPixels(png = false, rotationDegrees = 0, flipped = true)) + assertTrue(rotatesPixels(png = true, rotationDegrees = 270, flipped = true)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewResultTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewResultTest.kt new file mode 100644 index 0000000000..1be51f303f --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewResultTest.kt @@ -0,0 +1,36 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class FileAttachmentPreviewResultTest { + + @Test + fun resultKeepsFilesCaptionAndFlags() { + val bundle = FileAttachmentPreviewFragment.packResult(listOf("a", "b"), "caption", true, false) + + assertEquals(listOf("a", "b"), bundle.getStringArrayList(FileAttachmentPreviewFragment.RESULT_FILES)) + assertEquals("caption", bundle.getString(FileAttachmentPreviewFragment.RESULT_CAPTION)) + assertTrue(bundle.getBoolean(FileAttachmentPreviewFragment.RESULT_COMPRESS_IMAGES)) + assertFalse(bundle.getBoolean(FileAttachmentPreviewFragment.RESULT_ALLOW_UPDATE)) + } + + @Test + fun resultKeepsFileOrderAndAllowUpdate() { + val bundle = FileAttachmentPreviewFragment.packResult(listOf("2", "1", "3"), "", false, true) + + assertEquals(listOf("2", "1", "3"), bundle.getStringArrayList(FileAttachmentPreviewFragment.RESULT_FILES)) + assertTrue(bundle.getBoolean(FileAttachmentPreviewFragment.RESULT_ALLOW_UPDATE)) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewSelectionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewSelectionTest.kt new file mode 100644 index 0000000000..4056141690 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewSelectionTest.kt @@ -0,0 +1,105 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import android.app.Application +import android.content.Context +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.Mockito.mock +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class FileAttachmentPreviewSelectionTest { + + private fun viewModelWith(vararg uris: String) = + FileAttachmentPreviewViewModel(mock(Context::class.java)).apply { setInitialFiles(uris.toList()) } + + @Test + fun replaceFileSwapsUriInPlace() { + val vm = viewModelWith("a", "b", "c") + vm.replaceFile("b", "b2") + assertEquals(listOf("a", "b2", "c"), vm.files.toList()) + } + + @Test + fun replaceFileKeepsExcludedState() { + val vm = viewModelWith("a", "b") + vm.toggleSelected("b") + vm.replaceFile("b", "b2") + assertEquals(listOf("a"), vm.selectedFiles()) + assertEquals(listOf("b2"), vm.unselected.toList()) + } + + @Test + fun replaceFileOfUnknownUriIsIgnored() { + val vm = viewModelWith("a") + vm.replaceFile("zzz", "y") + assertEquals(listOf("a"), vm.files.toList()) + } + + @Test + fun excludedFileIsNotSelectedButStaysInList() { + val vm = viewModelWith("a", "b", "c") + vm.toggleSelected("b") + assertEquals(listOf("a", "c"), vm.selectedFiles()) + assertEquals(listOf("a", "b", "c"), vm.files.toList()) + vm.toggleSelected("b") + assertEquals(listOf("a", "b", "c"), vm.selectedFiles()) + } + + @Test + fun lastSelectedFileStaysSelected() { + val vm = viewModelWith("a", "b") + vm.toggleSelected("a") + vm.toggleSelected("b") + assertEquals(listOf("b"), vm.selectedFiles()) + assertEquals(listOf("a"), vm.unselected.toList()) + } + + @Test + fun filesAddedLaterAreSelected() { + val vm = viewModelWith("a", "b") + vm.toggleSelected("a") + vm.addFiles(listOf("c")) + assertEquals(listOf("b", "c"), vm.selectedFiles()) + } + + private fun description(uri: String, detail: String = "d") = + FileDescription(uri, "name", MediaKind.IMAGE, "image/jpeg", detail) + + @Test + fun replacedPageKeepsItsDescriptionUntilRefreshed() { + val vm = viewModelWith("a", "b") + vm.publishDescriptions(mapOf("a" to description("a"), "b" to description("b"))) + vm.replaceFile("b", "b2") + assertEquals(listOf("a", "b2"), vm.files.mapNotNull { vm.descriptionsByUri[it]?.uri }) + } + + @Test + fun staleDescriptionResultCannotWipeOutReplacement() { + val vm = viewModelWith("a", "b") + vm.publishDescriptions(mapOf("a" to description("a"), "b" to description("b"))) + vm.replaceFile("b", "b2") + // a describe run started before the replacement finishes late and only knows the old uris + vm.publishDescriptions(mapOf("a" to description("a", "late"), "b" to description("b", "late"))) + assertEquals(setOf("a", "b2"), vm.descriptionsByUri.keys) + assertEquals("b2", vm.descriptionsByUri["b2"]?.uri) + } + + @Test + fun freshDescriptionsReplaceCarriedOverOnes() { + val vm = viewModelWith("a") + vm.publishDescriptions(mapOf("a" to description("a"))) + vm.replaceFile("a", "a2") + vm.publishDescriptions(mapOf("a2" to description("a2", "fresh"))) + assertEquals("fresh", vm.descriptionsByUri["a2"]?.detail) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt index 7becb4d81d..243658ab42 100644 --- a/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModelTest.kt @@ -59,16 +59,16 @@ class FileAttachmentPreviewViewModelTest { } @Test - fun `mime types of removed files are dropped`() { + fun `mime types of replaced files are dropped`() { viewModel.setInitialFiles(listOf("content://a/1.txt", "content://a/2.txt")) viewModel.resolveMimeTypes() testScope.advanceUntilIdle() - viewModel.removeFile("content://a/1.txt") + viewModel.replaceFile("content://a/1.txt", "content://a/3.txt") viewModel.resolveMimeTypes() testScope.advanceUntilIdle() - assertEquals(setOf("content://a/2.txt"), viewModel.mimeTypesByUri.keys) + assertEquals(setOf("content://a/3.txt", "content://a/2.txt"), viewModel.mimeTypesByUri.keys) } @Test diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogicTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogicTest.kt new file mode 100644 index 0000000000..67db042366 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/ImageDetailLogicTest.kt @@ -0,0 +1,109 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import androidx.exifinterface.media.ExifInterface +import com.nextcloud.talk.utils.ImageCompressor.ImageInfo +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import java.io.File + +class ImageDetailLogicTest { + + private val describe: (ImageInfo) -> String = { "${it.width}x${it.height} ${it.sizeBytes}" } + private val cameraShot = ImageInfo(3000, 4000, 3_000_000) + private val compressed = ImageInfo(960, 1280, 153_000) + + @Test + fun quarterTurnOrientationsSwapDimensions() { + listOf( + ExifInterface.ORIENTATION_ROTATE_90, + ExifInterface.ORIENTATION_ROTATE_270, + ExifInterface.ORIENTATION_TRANSPOSE, + ExifInterface.ORIENTATION_TRANSVERSE + ).forEach { assertTrue(exifSwapsDimensions(it)) } + } + + @Test + fun otherOrientationsKeepDimensions() { + listOf( + ExifInterface.ORIENTATION_UNDEFINED, + ExifInterface.ORIENTATION_NORMAL, + ExifInterface.ORIENTATION_FLIP_HORIZONTAL, + ExifInterface.ORIENTATION_ROTATE_180, + ExifInterface.ORIENTATION_FLIP_VERTICAL + ).forEach { assertFalse(exifSwapsDimensions(it)) } + } + + @Test + fun rotatedPhotoReportsPortraitSize() { + val shown = ImageInfo(4000, 3000, 3_000_000).inDisplayOrientation(ExifInterface.ORIENTATION_ROTATE_90) + + assertEquals(ImageInfo(3000, 4000, 3_000_000), shown) + } + + @Test + fun uprightPhotoKeepsItsSize() { + assertEquals(cameraShot, cameraShot.inDisplayOrientation(ExifInterface.ORIENTATION_NORMAL)) + } + + @Test + fun standardQualityShowsCompressedResult() { + val variants = imageDetailVariants(cameraShot, compressed, compress = true, describe) + + assertEquals("960x1280 153000", variants.current) + assertEquals("3000x4000 3000000", variants.alternate) + } + + @Test + fun highQualityShowsOriginal() { + val variants = imageDetailVariants(cameraShot, compressed, compress = false, describe) + + assertEquals("3000x4000 3000000", variants.current) + assertEquals("960x1280 153000", variants.alternate) + } + + @Test + fun missingEstimateFallsBackToOriginal() { + val variants = imageDetailVariants(cameraShot, null, compress = true, describe) + + assertEquals("3000x4000 3000000", variants.current) + assertEquals(variants.current, variants.alternate) + } + + @Test + fun rotatedPhotoAndItsCompressedResultAgreeOnOrientation() { + val original = ImageInfo(4000, 3000, 3_000_000).inDisplayOrientation(ExifInterface.ORIENTATION_ROTATE_90) + + val variants = imageDetailVariants(original, compressed, compress = false, describe) + + assertEquals("3000x4000 3000000", variants.current) + assertEquals("960x1280 153000", variants.alternate) + } + + @Test + fun gifIsNeverEstimatedSoBothCaptionsShowTheOriginal() { + var estimated = false + val info = compressedImageInfo("image/gif", File("a.gif")) { + estimated = true + compressed + } + + val variants = imageDetailVariants(cameraShot, info, compress = true, describe) + + assertFalse(estimated) + assertEquals("3000x4000 3000000", variants.current) + assertEquals(variants.current, variants.alternate) + } + + @Test + fun jpegIsEstimated() { + assertEquals(compressed, compressedImageInfo("image/jpeg", File("a.jpg")) { compressed }) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/UprightMatrixTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/UprightMatrixTest.kt new file mode 100644 index 0000000000..3fc37f6b94 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/UprightMatrixTest.kt @@ -0,0 +1,85 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import android.app.Application +import android.graphics.Matrix +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** + * Drawing happens in upright coordinates but onto the raw, un-rotated pixels, so the inverse of + * [uprightMatrix] must send an upright point to the raw pixel the EXIF orientation says it shows. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = Application::class, sdk = [33]) +class UprightMatrixTest { + + private val rawWidth = 40 + private val rawHeight = 20 + + /** (rotationDegrees, flipped) as androidx ExifInterface reports them, and upright->raw per EXIF spec. */ + private data class Orientation( + val exifValue: Int, + val degrees: Int, + val flipped: Boolean, + val uprightWidth: Int, + val uprightToRaw: (Float, Float) -> Pair + ) + + private val orientations = listOf( + Orientation(1, 0, false, 40) { x, y -> x to y }, + Orientation(2, 0, true, 40) { x, y -> (40f - x) to y }, + Orientation(3, 180, false, 40) { x, y -> (40f - x) to (20f - y) }, + Orientation(4, 180, true, 40) { x, y -> x to (20f - y) }, + Orientation(5, 270, true, 20) { x, y -> y to x }, + Orientation(6, 90, false, 20) { x, y -> y to (20f - x) }, + Orientation(7, 90, true, 20) { x, y -> (40f - y) to (20f - x) }, + Orientation(8, 270, false, 20) { x, y -> (40f - y) to x } + ) + + private fun uprightToRaw(matrix: Matrix, x: Float, y: Float): Pair { + val inverse = Matrix().also { matrix.invert(it) } + val point = floatArrayOf(x, y) + inverse.mapPoints(point) + return point[0] to point[1] + } + + @Test + fun uprightPointsLandOnTheExpectedRawPixelForAllEightOrientations() { + orientations.forEach { o -> + val matrix = uprightMatrix(o.degrees, o.flipped, rawWidth, rawHeight) + val uprightHeight = if (o.uprightWidth == rawWidth) rawHeight else rawWidth + listOf( + 0f to 0f, + o.uprightWidth.toFloat() to uprightHeight.toFloat(), + 3f to 7f, + o.uprightWidth / 2f to 1f + ).forEach { (x, y) -> + val (expectedX, expectedY) = o.uprightToRaw(x, y) + val (actualX, actualY) = uprightToRaw(matrix, x, y) + assertEquals("orientation ${o.exifValue} x for ($x,$y)", expectedX, actualX, 0.001f) + assertEquals("orientation ${o.exifValue} y for ($x,$y)", expectedY, actualY, 0.001f) + } + } + } + + @Test + fun uprightSizeSwapsOnlyForQuarterTurns() { + orientations.forEach { o -> + val matrix = uprightMatrix(o.degrees, o.flipped, rawWidth, rawHeight) + val bounds = android.graphics.RectF(0f, 0f, rawWidth.toFloat(), rawHeight.toFloat()) + .also { matrix.mapRect(it) } + assertEquals("orientation ${o.exifValue}", o.uprightWidth.toFloat(), bounds.width(), 0.001f) + assertEquals(0f, bounds.left, 0.001f) + assertEquals(0f, bounds.top, 0.001f) + } + } +} From 00529b8011bdee8cb6604105c305e0a6b42c6cca Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 12:01:02 +0000 Subject: [PATCH 09/29] feat(camera): switch between fast and maximum quality shutter A button at the top edge of the capture screen toggles ImageCapture between CAPTURE_MODE_MINIMIZE_LATENCY (default, fast) and CAPTURE_MODE_MAXIMIZE_QUALITY. The choice is kept in AppPreferences (camera_capture_mode). Each saved photo logs "capture mode=... took=... ms" under the PhotoCamera tag to compare modes. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../nextcloud/talk/camera/CaptureActivity.kt | 10 ++ .../com/nextcloud/talk/camera/PhotoCamera.kt | 51 +++++++- .../talk/camera/PhotoCaptureLogic.kt | 21 ++++ .../talk/camera/PhotoCaptureScreen.kt | 118 +++++++++++++++--- .../utils/preferences/AppPreferences.java | 4 + .../utils/preferences/AppPreferencesImpl.kt | 15 +++ .../main/res/drawable/ic_baseline_hd_24.xml | 16 +++ .../res/drawable/ic_baseline_speed_24.xml | 16 +++ app/src/main/res/values/strings.xml | 4 + .../talk/camera/CaptureModeSettingTest.kt | 49 ++++++++ 10 files changed, 279 insertions(+), 25 deletions(-) create mode 100644 app/src/main/res/drawable/ic_baseline_hd_24.xml create mode 100644 app/src/main/res/drawable/ic_baseline_speed_24.xml create mode 100644 app/src/test/java/com/nextcloud/talk/camera/CaptureModeSettingTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt b/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt index c491f08d07..00cbe863d8 100644 --- a/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/camera/CaptureActivity.kt @@ -16,17 +16,25 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.core.content.ContextCompat import androidx.core.content.FileProvider +import autodagger.AutoInjector import com.nextcloud.talk.R +import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.utils.preferences.AppPreferences import java.io.File import java.util.Date +import javax.inject.Inject /** * Takes one photo with the in-app camera and returns its content uri as the result data. There is no confirmation * step: the shutter finishes the activity. Without the camera permission the activity ends as cancelled, the caller * asks for the permission before it starts this screen. */ +@AutoInjector(NextcloudTalkApplication::class) class CaptureActivity : ComponentActivity() { + @Inject + lateinit var appPreferences: AppPreferences + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) enableEdgeToEdge() @@ -36,8 +44,10 @@ class CaptureActivity : ComponentActivity() { finish() return } + NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) setContent { PhotoCaptureScreen( + appPreferences = appPreferences, newPhotoFile = ::newPhotoFile, onCaptured = ::returnPhoto, onFailed = { diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt index 584f75acb7..fdba74ef94 100644 --- a/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt +++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt @@ -7,6 +7,7 @@ package com.nextcloud.talk.camera import android.content.Context +import android.os.SystemClock import android.util.Log import androidx.camera.core.Camera import androidx.camera.core.CameraSelector @@ -36,12 +37,16 @@ import java.io.File internal class PhotoCamera( private val context: Context, private val lifecycleOwner: LifecycleOwner, - private val previewView: PreviewView + private val previewView: PreviewView, + initialCaptureMode: CaptureModeSetting = CaptureModeSetting.DEFAULT, + private val onCaptureModeChanged: (CaptureModeSetting) -> Unit = {} ) { var lensFacing by mutableIntStateOf(CameraSelector.LENS_FACING_BACK) private set var flash by mutableStateOf(FlashSetting.OFF) private set + var captureMode by mutableStateOf(initialCaptureMode) + private set var hasFlashUnit by mutableStateOf(false) private set var canSwitchLens by mutableStateOf(false) @@ -62,10 +67,7 @@ internal class PhotoCamera( provider = readyProvider // No target rotation: the preview follows the display, the photo follows setTargetRotation(). preview = Preview.Builder().build() - imageCapture = ImageCapture.Builder() - .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY) - .setResolutionSelector(photoResolutionSelector()) - .build() + imageCapture = buildImageCapture(captureMode) canSwitchLens = readyProvider.hasCamera(cameraSelectorFor(CameraSelector.LENS_FACING_BACK)) && readyProvider.hasCamera(cameraSelectorFor(CameraSelector.LENS_FACING_FRONT)) bind(resolveLens(readyProvider, lensFacing)) @@ -81,6 +83,21 @@ internal class PhotoCamera( } } + /** + * Switches between the fast and the maximum quality shutter. The mode is fixed when [ImageCapture] is built, so + * the use case is replaced and bound again. Ignored while a photo is taken: unbinding would cancel it. + */ + fun toggleCaptureMode() { + if (provider == null || isCapturing) return + val previousMode = captureMode + val lens = lensFacing + if (!rebindWith(previousMode.toggled(), lens)) { + rebindWith(previousMode, lens) + return + } + onCaptureModeChanged(captureMode) + } + fun cycleFlash() { if (!hasFlashUnit) return flash = flash.next() @@ -104,18 +121,25 @@ internal class PhotoCamera( return } isCapturing = true + val mode = captureMode + val startedAt = SystemClock.elapsedRealtime() val options = ImageCapture.OutputFileOptions.Builder(file).build() capture.takePicture( options, ContextCompat.getMainExecutor(context), object : ImageCapture.OnImageSavedCallback { override fun onImageSaved(output: ImageCapture.OutputFileResults) { + Log.i(TAG, "capture mode=${mode.logName} took=${SystemClock.elapsedRealtime() - startedAt} ms") isCapturing = false onResult(true) } override fun onError(exception: ImageCaptureException) { - Log.w(TAG, "taking the photo failed", exception) + Log.w( + TAG, + "capture mode=${mode.logName} failed after ${SystemClock.elapsedRealtime() - startedAt} ms", + exception + ) isCapturing = false onResult(false) } @@ -128,6 +152,21 @@ internal class PhotoCamera( unbind() } + private fun buildImageCapture(mode: CaptureModeSetting): ImageCapture = + ImageCapture.Builder() + .setCaptureMode(mode.imageCaptureMode) + .setResolutionSelector(photoResolutionSelector()) + .build() + + private fun rebindWith(mode: CaptureModeSetting, lens: Int): Boolean { + // A new use case starts with the display rotation, the old one knows the rotation of the hold. + val rotation = imageCapture?.targetRotation + unbind() + imageCapture = buildImageCapture(mode).also { capture -> rotation?.let { capture.targetRotation = it } } + captureMode = mode + return bind(lens) + } + private fun bind(lens: Int): Boolean { val previewUseCase = preview val captureUseCase = imageCapture diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt index c9c15bdfa0..3f0d8c758c 100644 --- a/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt +++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureLogic.kt @@ -44,6 +44,27 @@ internal enum class FlashSetting(val imageCaptureMode: Int) { fun next(): FlashSetting = entries[(ordinal + 1) % entries.size] } +/** + * Shutter mode of the capture screen. [storageValue] is what the app preferences keep, [logName] the name in the + * capture log. [toggled] is the order of the mode button. + */ +internal enum class CaptureModeSetting(val storageValue: String, val imageCaptureMode: Int, val logName: String) { + FAST("fast", ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY, "MINIMIZE_LATENCY"), + QUALITY("quality", ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY, "MAXIMIZE_QUALITY"); + + fun toggled(): CaptureModeSetting = if (this == FAST) QUALITY else FAST + + companion object { + val DEFAULT = FAST + + /** + * The mode kept in the preferences; nothing stored or an unknown value gives [DEFAULT]. + */ + fun fromStorage(value: String?): CaptureModeSetting = + entries.firstOrNull { it.storageValue == value } ?: DEFAULT + } +} + /** * The flash mode to apply: a camera without a flash unit always gets [ImageCapture.FLASH_MODE_OFF]. */ diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt index 9b38760e55..c869c2250e 100644 --- a/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCaptureScreen.kt @@ -13,16 +13,21 @@ import androidx.camera.view.PreviewView import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.offset import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.displayCutoutPadding import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.animation.core.animateFloatAsState import androidx.compose.material3.Icon +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -47,9 +52,11 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.compose.LocalLifecycleOwner import com.nextcloud.talk.R +import com.nextcloud.talk.utils.preferences.AppPreferences import java.io.File private val ControlSize = 48.dp @@ -57,16 +64,21 @@ private val ShutterSize = 72.dp private val ShutterRingWidth = 4.dp private val ScreenPadding = 16.dp private val LensSwitchGap = 84.dp +private val ModeButtonSize = 56.dp +private val ModeButtonCorner = 16.dp +private val ModeLabelSize = 11.sp private const val DISABLED_ALPHA = 0.4f /** - * Full screen photo capture: preview, close and flash at the top edge of the device body, shutter and lens switch at - * its bottom edge. + * Full screen photo capture: preview, close, shutter mode and flash at the top edge of the device body, shutter and + * lens switch at its bottom edge. + * The shutter mode is read from and kept in [appPreferences]. * [newPhotoFile] makes the target of a photo, [onCaptured] gets the finished file, [onFailed] the target of a * photo that could not be written. */ @Composable internal fun PhotoCaptureScreen( + appPreferences: AppPreferences, newPhotoFile: () -> File?, onCaptured: (File) -> Unit, onFailed: () -> Unit, @@ -80,7 +92,14 @@ internal fun PhotoCaptureScreen( implementationMode = PreviewView.ImplementationMode.COMPATIBLE } } - val camera = remember { PhotoCamera(context, lifecycleOwner, previewView) } + val camera = remember { + PhotoCamera( + context, + lifecycleOwner, + previewView, + CaptureModeSetting.fromStorage(appPreferences.cameraCaptureMode) + ) { appPreferences.cameraCaptureMode = it.storageValue } + } var deviceOrientation by remember { mutableIntStateOf(0) } @@ -167,7 +186,8 @@ private fun rememberIconAngle(targetDegrees: Int): Float { /** * Controls tied to the device body, not to the window: the shutter stays at the natural bottom edge, close and - * flash at the natural top edge, the lens switch next to the shutter, whatever rotation the window has. + * flash at the natural top edge with the shutter mode button between them, the lens switch next to the shutter, + * whatever rotation the window has. */ @Composable private fun CaptureControls( @@ -177,7 +197,7 @@ private fun CaptureControls( onClose: () -> Unit, onShutter: () -> Unit ) { - Box(Modifier.fillMaxSize().systemBarsPadding().padding(ScreenPadding)) { + Box(Modifier.fillMaxSize().systemBarsPadding().displayCutoutPadding().padding(ScreenPadding)) { fun BoxScope.at(x: Int, y: Int): Modifier = Modifier.align(BodyPoint(x, y).inWindow(displayRotation).toAlignment()) @@ -188,6 +208,14 @@ private fun CaptureControls( modifier = at(-1, -1), onClick = onClose ) + val modeEnabled = camera.isBound && !camera.isCapturing + CaptureModeButton( + mode = camera.captureMode, + angle = iconAngle, + enabled = modeEnabled, + modifier = at(0, -1), + onClick = camera::toggleCaptureMode + ) if (camera.hasFlashUnit) { val (icon, description) = flashPresentation(camera.flash) CaptureIconButton( @@ -215,24 +243,31 @@ private fun CaptureControls( .clickable(enabled = enabled, onClick = onShutter) ) if (camera.canSwitchLens) { - // Beside the shutter along the edge, and centered on it across the edge - val beside = BodyPoint(1, 0).inWindow(displayRotation) - val inward = BodyPoint(0, -1).inWindow(displayRotation) - val centering = (ShutterSize - ControlSize) / 2 - CaptureIconButton( - icon = R.drawable.ic_baseline_flip_camera_android_24, - description = stringResource(R.string.nc_video_recording_switch_camera), - angle = iconAngle, - modifier = at(0, 1).offset( - LensSwitchGap * beside.x + centering * inward.x, - LensSwitchGap * beside.y + centering * inward.y - ), - onClick = camera::switchLens - ) + LensSwitchButton(camera, displayRotation, iconAngle, at(0, 1)) } } } +/** + * Beside the shutter along the edge, and centered on it across the edge. [shutterModifier] places the shutter. + */ +@Composable +private fun LensSwitchButton(camera: PhotoCamera, displayRotation: Int, iconAngle: Float, shutterModifier: Modifier) { + val beside = BodyPoint(1, 0).inWindow(displayRotation) + val inward = BodyPoint(0, -1).inWindow(displayRotation) + val centering = (ShutterSize - ControlSize) / 2 + CaptureIconButton( + icon = R.drawable.ic_baseline_flip_camera_android_24, + description = stringResource(R.string.nc_video_recording_switch_camera), + angle = iconAngle, + modifier = shutterModifier.offset( + LensSwitchGap * beside.x + centering * inward.x, + LensSwitchGap * beside.y + centering * inward.y + ), + onClick = camera::switchLens + ) +} + private fun BodyPoint.toAlignment(): Alignment = BiasAlignment(x.toFloat(), y.toFloat()) @Composable @@ -254,6 +289,51 @@ private fun CaptureIconButton(icon: Int, description: String, angle: Float, modi } } +/** + * The shutter mode button: icon over a short label, both turn with [angle]. The tile is nearly square, so a quarter + * turn keeps it inside the controls area. + */ +@Composable +private fun CaptureModeButton( + mode: CaptureModeSetting, + angle: Float, + enabled: Boolean, + modifier: Modifier, + onClick: () -> Unit +) { + val (icon, label, description) = modePresentation(mode) + val descriptionText = stringResource(description) + Column( + modifier = modifier + .size(ModeButtonSize) + .alpha(if (enabled) 1f else DISABLED_ALPHA) + .clip(RoundedCornerShape(ModeButtonCorner)) + .background(Color.Black.copy(alpha = 0.4f)) + .clickable(enabled = enabled, role = Role.Button, onClick = onClick) + .semantics { contentDescription = descriptionText } + .rotate(angle), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center + ) { + Icon(painter = painterResource(icon), contentDescription = null, tint = Color.White) + Text(text = stringResource(label), color = Color.White, fontSize = ModeLabelSize, maxLines = 1) + } +} + +private fun modePresentation(mode: CaptureModeSetting): Triple = + when (mode) { + CaptureModeSetting.FAST -> Triple( + R.drawable.ic_baseline_speed_24, + R.string.nc_photo_capture_mode_fast, + R.string.nc_photo_capture_mode_fast_description + ) + CaptureModeSetting.QUALITY -> Triple( + R.drawable.ic_baseline_hd_24, + R.string.nc_photo_capture_mode_quality, + R.string.nc_photo_capture_mode_quality_description + ) + } + private fun flashPresentation(setting: FlashSetting): Pair = when (setting) { FlashSetting.OFF -> R.drawable.ic_baseline_flash_off_24 to R.string.nc_photo_capture_flash_off 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 b9e6324e7b..7c17b5cb1d 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,6 +211,10 @@ public interface AppPreferences { long getConversationListLastUserId(); + String getCameraCaptureMode(); + + void setCameraCaptureMode(String value); + boolean getVideoRecordMode(); void setVideoRecordMode(boolean value); 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 3880a57752..95c6568ef9 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,20 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { } } + override fun getCameraCaptureMode(): String = + runBlocking { + async { readString(CAMERA_CAPTURE_MODE).first() } + }.getCompleted() + + override fun setCameraCaptureMode(value: String?) = + runBlocking { + async { + if (value != null) { + writeString(CAMERA_CAPTURE_MODE, value) + } + } + } + override fun getVideoRecordMode(): Boolean = runBlocking { async { readBoolean(VIDEO_RECORD_MODE).first() } @@ -721,6 +735,7 @@ class AppPreferencesImpl(val context: Context) : AppPreferences { 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 CAMERA_CAPTURE_MODE = "camera_capture_mode" const val MESSAGE_QUEUE = "@message_queue" const val PLAY_BACK = "_playback" const val VOICE_MESSAGE_PLAYBACK_SPEEDS = "voice_message_playback_speeds" diff --git a/app/src/main/res/drawable/ic_baseline_hd_24.xml b/app/src/main/res/drawable/ic_baseline_hd_24.xml new file mode 100644 index 0000000000..2f2bd09a7c --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_hd_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_baseline_speed_24.xml b/app/src/main/res/drawable/ic_baseline_speed_24.xml new file mode 100644 index 0000000000..8dc1f30a1b --- /dev/null +++ b/app/src/main/res/drawable/ic_baseline_speed_24.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index d9844dacc5..083f951bdc 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -851,6 +851,10 @@ How to translate with transifex: Flash off Flash automatic Flash on + Fast + Quality + Fast shutter. Tap to switch to maximum quality + Maximum quality. Tap to switch to fast shutter Taking a photo is not possible without permissions Camera permission granted. Please choose camera again. Take a video diff --git a/app/src/test/java/com/nextcloud/talk/camera/CaptureModeSettingTest.kt b/app/src/test/java/com/nextcloud/talk/camera/CaptureModeSettingTest.kt new file mode 100644 index 0000000000..b37cadba11 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/camera/CaptureModeSettingTest.kt @@ -0,0 +1,49 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.camera + +import androidx.camera.core.ImageCapture +import org.junit.Assert.assertEquals +import org.junit.Test + +class CaptureModeSettingTest { + + @Test + fun captureModeDefaultsToFast() { + assertEquals(CaptureModeSetting.FAST, CaptureModeSetting.DEFAULT) + assertEquals(CaptureModeSetting.FAST, CaptureModeSetting.fromStorage(null)) + assertEquals(CaptureModeSetting.FAST, CaptureModeSetting.fromStorage("")) + } + + @Test + fun captureModeMapsToImageCaptureMode() { + assertEquals( + ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY, + CaptureModeSetting.fromStorage("fast").imageCaptureMode + ) + assertEquals( + ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY, + CaptureModeSetting.fromStorage("quality").imageCaptureMode + ) + } + + @Test + fun captureModeUnknownStoredValueGivesDefault() { + assertEquals(CaptureModeSetting.DEFAULT, CaptureModeSetting.fromStorage("maximum")) + assertEquals(CaptureModeSetting.DEFAULT, CaptureModeSetting.fromStorage("QUALITY")) + } + + @Test + fun captureModeTogglesThereAndBack() { + assertEquals(CaptureModeSetting.QUALITY, CaptureModeSetting.FAST.toggled()) + assertEquals(CaptureModeSetting.FAST, CaptureModeSetting.QUALITY.toggled()) + CaptureModeSetting.entries.forEach { + assertEquals(it, CaptureModeSetting.fromStorage(it.storageValue)) + assertEquals(it, it.toggled().toggled()) + } + } +} From 4d9b1c16a6f580a714e31994e90efda9ba219df7 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:22:20 +0000 Subject: [PATCH 10/29] 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 b41ef08f03..817f0eded7 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 e28574e531..6a0a101ac0 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 ab89e0fa73..feeb5cff55 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 4ded289c424c29541581ed8eba51270811d9b7b1 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:22:20 +0000 Subject: [PATCH 11/29] 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 fa5c3d0404..83dcdb0863 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 ae7ba56c62..38ae81c342 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -430,6 +430,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 abb787678d..a1199ff50d 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 80df8332d13ea13200a7689193ba23deed1f8e0b Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:22:21 +0000 Subject: [PATCH 12/29] 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 5a292e2d5f..3b5f4521b4 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -35,12 +35,14 @@ import android.provider.ContactsContract import android.provider.MediaStore 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 @@ -2521,7 +2523,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( @@ -2603,15 +2610,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) { @@ -2623,7 +2680,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? = @@ -4459,6 +4516,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 38ae81c342..0f6613ab72 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -67,6 +67,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 @@ -88,6 +89,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]. */ @@ -98,13 +118,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 -> @@ -128,6 +155,7 @@ class VideoMessageRecorder(context: Context) { activityLifecycle = null previewView = null onFinished = null + onFrameAspectChanged = null } /** @@ -236,6 +264,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 0000000000..d282d78d5c --- /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 8f16349a3c..72522c2f22 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 d5288c5fdb..f6d5759d5a 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 0000000000..796d6e8060 --- /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 0000000000..3da76f6a22 --- /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 1e8b5154c8fd78036b17c90178ec7933dcbec71e Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:22:21 +0000 Subject: [PATCH 13/29] 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 3b5f4521b4..013cd96107 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2622,7 +2622,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() } @@ -2636,12 +2638,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, @@ -2650,21 +2653,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) @@ -2680,6 +2698,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 0f6613ab72..8740cd4c0b 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -83,7 +83,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 @@ -224,6 +227,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 d282d78d5c..81226fff8a 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 796d6e8060..17c91c18b0 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 3da76f6a22..c88301fb80 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 6f6bc3973a6c174b468d2d801023600382828608 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:22:21 +0000 Subject: [PATCH 14/29] 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 013cd96107..9913b8904f 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2648,7 +2648,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 83dcdb0863..b1279efd56 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 81226fff8a..6461b9e67d 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 0000000000..5215a6ff42 --- /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 ad6fd24795..a9fe0e8c62 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 0000000000..584ae09f21 --- /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 57655a65b9515f5c78af36bd126620910180380f Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:22:21 +0000 Subject: [PATCH 15/29] 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 b1279efd56..47c428b074 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 a9fe0e8c62..6753223994 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 b68804c03876e3eba384533dc220b81e0f4c20b1 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:15:47 +0000 Subject: [PATCH 16/29] 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 ee93a29af4..201a9e04cb 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 9913b8904f..859f9c5490 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -119,7 +119,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.camera.TakePhotoInApp import com.nextcloud.talk.chat.data.io.VoiceMessageMediaService import com.nextcloud.talk.chat.data.model.ChatMessage @@ -2735,23 +2737,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() { @@ -3056,7 +3042,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 b689647192..8acca06664 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 0000000000..6c30738028 --- /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 15291f152963eaa6b728eb2a9930ae47fd1702b8 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 10:05:02 +0000 Subject: [PATCH 17/29] 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 859f9c5490..ee9808ae81 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1650,6 +1650,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() @@ -2603,7 +2608,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) @@ -2611,19 +2618,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 47c428b074..7523a60f06 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 8740cd4c0b..d978e4a353 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -447,16 +447,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 083f951bdc..ed72c5505b 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 a1199ff50d..6f4e9650aa 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 fe2dcffdeac6a5e40e2de000d45a33117b19f5c0 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 10:34:40 +0000 Subject: [PATCH 18/29] fix(chat): open the in-app camera after the camera permission is granted takePhotoWithInAppCamera() requested the permission and the grant handler only showed a snackbar, so the user had to open the sheet and tap the camera again. The request is now remembered and the camera opens from the grant branch. The flag is cleared by the other path which asks for the same permission (system video capture). Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/chat/ChatActivity.kt | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 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 ee9808ae81..f0ea1d7507 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -2727,6 +2727,10 @@ class ChatActivity : null } + // The photo of the in-app camera was asked for and waits for the camera permission. Kept in the activity only: if + // the activity is recreated while the dialog is open, the user taps the camera again. + private var pendingInAppPhoto = false + private fun requestCameraPermissions() { requestPermissions( arrayOf( @@ -3090,10 +3094,16 @@ class ChatActivity : ).show() } } else if (requestCode == REQUEST_CAMERA_PERMISSION) { + val openInAppCamera = pendingInAppPhoto + pendingInAppPhoto = false if (grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED) { - Snackbar - .make(binding.root, context.getString(R.string.camera_permission_granted), Snackbar.LENGTH_LONG) - .show() + if (openInAppCamera) { + takePhotoInApp.launch(Unit) + } else { + Snackbar + .make(binding.root, context.getString(R.string.camera_permission_granted), Snackbar.LENGTH_LONG) + .show() + } } else { Snackbar .make(binding.root, context.getString(R.string.take_photo_permission), Snackbar.LENGTH_LONG) @@ -4390,6 +4400,7 @@ class ChatActivity : private fun takePhotoWithInAppCamera() { if (!permissionUtil.isCameraPermissionGranted()) { + pendingInAppPhoto = true requestCameraPermissions() } else { takePhotoInApp.launch(Unit) @@ -4398,6 +4409,7 @@ class ChatActivity : private fun sendVideoFromCamIntent() { if (!permissionUtil.isCameraPermissionGranted()) { + pendingInAppPhoto = false requestCameraPermissions() } else { Intent(MediaStore.ACTION_VIDEO_CAPTURE).also { takeVideoIntent -> From 23b121d28348999c6ee7ecc92b67479beeb81902 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 11:28:39 +0000 Subject: [PATCH 19/29] fix(chat): hide the photo entry of the attachment sheet without a camera The photo entry opens the in-app capture screen, which stays black on a device without camera hardware. It now needs hasCamera like the video entry. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../com/nextcloud/talk/attachmentsheet/AttachmentAction.kt | 2 +- .../nextcloud/talk/attachmentsheet/AttachmentActionTest.kt | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt index e81ebd475c..236cb28ab0 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentsheet/AttachmentAction.kt @@ -38,11 +38,11 @@ data class AttachmentVisibilityInput( 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.PICTURE_FROM_CAM, AttachmentAction.VIDEO_FROM_CAM -> !input.isRemoteConversation && input.hasCamera AttachmentAction.GALLERY -> true diff --git a/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt index 5e0fe503d4..265e5cab2d 100644 --- a/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt +++ b/app/src/test/java/com/nextcloud/talk/attachmentsheet/AttachmentActionTest.kt @@ -8,7 +8,6 @@ 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 { @@ -38,10 +37,10 @@ class AttachmentActionTest { } @Test - fun missingCameraHidesOnlyVideo() { + fun missingCameraHidesPhotoAndVideo() { val actions = resolveAttachmentActions(everything.copy(hasCamera = false)) assertFalse(AttachmentAction.VIDEO_FROM_CAM in actions) - assertTrue(AttachmentAction.PICTURE_FROM_CAM in actions) + assertFalse(AttachmentAction.PICTURE_FROM_CAM in actions) } @Test From 0721680ced6095df5dd2095686320d37d2ce20a9 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 11:28:39 +0000 Subject: [PATCH 20/29] fix(camera): keep the photo rotation until ImageCapture exists setTargetRotation() dropped a rotation which arrived before start() had built the ImageCapture, so a later photo used the display rotation. The latest rotation is now stored and applied to every ImageCapture that is built. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../main/java/com/nextcloud/talk/camera/PhotoCamera.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt b/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt index fdba74ef94..87add85615 100644 --- a/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt +++ b/app/src/main/java/com/nextcloud/talk/camera/PhotoCamera.kt @@ -61,6 +61,9 @@ internal class PhotoCamera( private var imageCapture: ImageCapture? = null private var released = false + // The latest rotation of the hold; it may arrive before the ImageCapture exists and is applied to every new one. + private var photoRotation: Int? = null + fun start() { whenCameraProviderReady(context) { readyProvider -> if (released || readyProvider == null) return@whenCameraProviderReady @@ -108,6 +111,7 @@ internal class PhotoCamera( * Sets the rotation of the next photo, so the picture is upright however the phone is held. */ fun setTargetRotation(rotation: Int) { + photoRotation = rotation imageCapture?.targetRotation = rotation } @@ -156,13 +160,13 @@ internal class PhotoCamera( ImageCapture.Builder() .setCaptureMode(mode.imageCaptureMode) .setResolutionSelector(photoResolutionSelector()) + .apply { photoRotation?.let { setTargetRotation(it) } } .build() private fun rebindWith(mode: CaptureModeSetting, lens: Int): Boolean { - // A new use case starts with the display rotation, the old one knows the rotation of the hold. - val rotation = imageCapture?.targetRotation unbind() - imageCapture = buildImageCapture(mode).also { capture -> rotation?.let { capture.targetRotation = it } } + // the new use case gets the rotation of the hold from buildImageCapture() + imageCapture = buildImageCapture(mode) captureMode = mode return bind(lens) } From 3c98f3b867dd39f5b678d11d37841d8b6c8974ac Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 13:13:19 +0000 Subject: [PATCH 21/29] 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 c5721217cd..5eb800d5df 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 201a9e04cb..49f84db94e 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 0000000000..b2a3e81866 --- /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 51dd9062af86c46119c7329b5124f9e522d6f72f Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 13:25:47 +0000 Subject: [PATCH 22/29] fix(attachment-preview): reserve a unique file for every edit output Two edits in the same millisecond got the same path, so the second result replaced nothing and the old file was deleted. The output is now reserved with createNewFile() and gets a numeric suffix when the name is taken. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../attachmentpreview/AttachmentEditing.kt | 26 ++++++++-- .../talk/attachmentpreview/EditNameClaim.kt | 18 +++++++ .../attachmentpreview/EditNameClaimTest.kt | 52 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/attachmentpreview/EditNameClaim.kt create mode 100644 app/src/test/java/com/nextcloud/talk/attachmentpreview/EditNameClaimTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt index 4f79633816..3673adb2a6 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/AttachmentEditing.kt @@ -42,14 +42,34 @@ private const val EDIT_PNG_QUALITY = 100 private const val CROP_MAX_BITMAP_PX = 4096 /** - * Creates the (not yet existing) output file of an edit in the cache dir shared through the app's - * FileProvider; null when that directory is unavailable (anything else would be unshareable). + * Reserves the (empty) output file of an edit in the cache dir shared through the app's FileProvider. The name is + * built from the current millisecond; when it is taken (two edits in the same millisecond), a numeric suffix is + * added, so an output never overwrites a file of another edit. The caller owns the file and deletes it on failure. + * Null when the directory is unavailable (anything else would be unshareable) or the file can't be created. */ internal fun createEditOutputFile(context: Context, sourceName: String, sourceMimeType: String?): File? { val directory = FileUtils.getSharedAttachmentsDirectory(context.cacheDir) ?: return null val stamp = SimpleDateFormat(EDIT_FILE_STAMP_PATTERN, Locale.ROOT).format(Date()) val extension = if (editOutputIsPng(sourceMimeType)) "png" else "jpg" - return File(directory, editedFileName(sourceName, stamp, extension)) + return try { + claimFirstFree( + maxAttempts = EDIT_NAME_MAX_ATTEMPTS, + candidate = { attempt -> + val unique = if (attempt == 0) stamp else "$stamp-$attempt" + File(directory, editedFileName(sourceName, unique, extension)) + }, + claim = { it.createNewFile() } + ) + } catch (e: IOException) { + logEditFileFailure(e) + } catch (e: SecurityException) { + logEditFileFailure(e) + } +} + +private fun logEditFileFailure(error: Exception): File? { + NextcloudTalkApplication.sharedApplication?.logger?.w(TAG, "Failed to reserve an edit output file", error) + return null } /** The same URI form the camera capture uses for files in the shared attachments cache; null if not shareable. */ diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/EditNameClaim.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/EditNameClaim.kt new file mode 100644 index 0000000000..6a36a2e361 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/EditNameClaim.kt @@ -0,0 +1,18 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +/** How many names (the plain one plus numbered ones) are tried when an edit output name is already taken. */ +internal const val EDIT_NAME_MAX_ATTEMPTS = 100 + +/** + * Claims the first free name: tries [candidate] for attempt 0 (the plain name), then 1, 2, ... up to + * [maxAttempts] attempts in total. [claim] must reserve the candidate atomically and return false when it is taken. + * Null when every attempt was taken. + */ +internal fun claimFirstFree(maxAttempts: Int, candidate: (Int) -> T, claim: (T) -> Boolean): T? = + (0 until maxAttempts).asSequence().map(candidate).firstOrNull(claim) diff --git a/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditNameClaimTest.kt b/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditNameClaimTest.kt new file mode 100644 index 0000000000..a0b9f1e429 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/attachmentpreview/EditNameClaimTest.kt @@ -0,0 +1,52 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.attachmentpreview + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class EditNameClaimTest { + + @Test + fun claimFirstFreeKeepsThePlainNameWhenItIsFree() { + assertEquals("a", claimFirstFree(3, { if (it == 0) "a" else "a-$it" }, { true })) + } + + @Test + fun claimFirstFreeAddsNumbersWhileNamesAreTaken() { + val taken = setOf("a", "a-1") + val claimed = mutableListOf() + val result = claimFirstFree( + maxAttempts = 5, + candidate = { if (it == 0) "a" else "a-$it" }, + claim = { name -> (name !in taken).also { if (it) claimed.add(name) } } + ) + assertEquals("a-2", result) + assertEquals(listOf("a-2"), claimed) + } + + @Test + fun claimFirstFreeGivesUpAfterTheLastAttempt() { + var tried = 0 + val result = claimFirstFree(3, { it }) { + tried++ + false + } + assertNull(result) + assertEquals(3, tried) + } + + @Test + fun numberedEditNamesStillCountAsOwnEdits() { + // the numeric suffix goes into the stamp, which the edit-name recognition accepts + val numbered = editedFileName("photo.jpg", "20261004-115100-123-2", "jpg") + assertTrue(isEditedFileName(numbered)) + assertEquals("photo_edited_20261004-120000-001.jpg", editedFileName(numbered, "20261004-120000-001", "jpg")) + } +} From d4fafe8958f7bfb75c507bfe676883057e4c5bfc Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 13:48:23 +0000 Subject: [PATCH 23/29] 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 5eb800d5df..6820db600f 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 { From ecd5a445b93cab8c15ddfa5a4496c0cb8bf038d9 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 12 Oct 2026 10:46:16 +0000 Subject: [PATCH 24/29] fix(attachment-preview): do not ask the content provider for the type during composition The preview called ContentResolver.getType for every file on the main thread in the body of the composable, so each recomposition (for example switching the file permission) made a binder call into the provider. A file shared from the Nextcloud Files app answers slowly, which froze the screen into an ANR. The type now comes from the file descriptions, which are read on the IO dispatcher. Verified on a device (Android 12): sharing a file from the Nextcloud Files app and switching the permission repeatedly no longer freezes the preview. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/attachmentpreview/FileAttachmentPreviewScreen.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index 73d1032280..dfdb5bed6e 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -69,7 +69,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.core.net.toUri import com.nextcloud.talk.R -import com.nextcloud.talk.utils.FileUtils import com.yalantis.ucrop.UCrop import kotlinx.coroutines.launch import java.io.File @@ -213,7 +212,9 @@ internal fun FileAttachmentPreviewContent( onHighQualityChange = { highQuality -> compressImages = !highQuality }, onAllowUpdateChange = { allowUpdate = it }, sendEnabled = viewModel.selectedFiles().isNotEmpty() && !viewModel.isEditing, - onSend = { onSend(viewModel.selectedFiles(), caption, compressImages, allowUpdate) } + onSend = { + onSend(viewModel.selectedFiles(), caption, compressImages && hasCompressibleMedia, allowUpdate) + } ) } From c4c2e6cfd619f154375648334a9327092bd95401 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 18 Oct 2026 12:08:27 +0000 Subject: [PATCH 25/29] fix(attachment-preview): send with the compression option as chosen Sending no longer waits for the file descriptions: the MIME type is resolved apart from them and the worker compresses only images and videos, so the option is passed on as is. This replaces "enable send only after the files are described", which the base branch no longer does. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-sonnet-5-5 --- .../talk/attachmentpreview/FileAttachmentPreviewScreen.kt | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index dfdb5bed6e..55f061cfe7 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -212,9 +212,7 @@ internal fun FileAttachmentPreviewContent( onHighQualityChange = { highQuality -> compressImages = !highQuality }, onAllowUpdateChange = { allowUpdate = it }, sendEnabled = viewModel.selectedFiles().isNotEmpty() && !viewModel.isEditing, - onSend = { - onSend(viewModel.selectedFiles(), caption, compressImages && hasCompressibleMedia, allowUpdate) - } + onSend = { onSend(viewModel.selectedFiles(), caption, compressImages, allowUpdate) } ) } From 4710065e19ad7d714529c63afcdd2d7108092bd6 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:33:28 +0000 Subject: [PATCH 26/29] feat(mediaviewer): show the position, add forward and draw actions to the media viewer - top bar: centered "N of M" counter counted from the newest item, so it stays put when older items are paged in ("M+" while more may load) - menu: save to gallery, show all media, show in chat, reply, share, delete (with confirmation); buttons: forward (file share to another conversation) and draw (photos only) - visibility comes from the chat's buildMessageActionsState (new fields showForwardFile, canSendToConversation); hidden when the message is not in the local database - reply/delete/forward/draw run in ChatActivity through its own handlers, handed over by intent extras and re-checked there - the file of a forward is not passed in an intent extra (the conversation list is exported): ChatActivity keeps it in PendingFileForward and hands over a random key; the entry is taken only after the confirmation - draw opens the upload preview on the cached file with the drawing editor started; edits write copies - SaveToStorageDialogFragment now finds files in the per-file-id cache - ShareOperationWorker tells the user when a share is refused Depends on #6820 (drawing editor of the attachment preview). Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../FileAttachmentPreviewFragment.kt | 9 +- .../FileAttachmentPreviewScreen.kt | 23 ++ .../FileAttachmentPreviewViewModel.kt | 16 +- .../com/nextcloud/talk/chat/ChatActivity.kt | 236 ++++++++++++++---- .../talk/chat/ui/MessageActionsBottomSheet.kt | 18 +- .../talk/components/StandardAppBar.kt | 111 ++++---- .../ConversationInfoActivity.kt | 21 +- .../ConversationsListActivity.kt | 46 +++- .../talk/jobs/ShareOperationWorker.kt | 21 +- .../activities/MediaViewerActivity.kt | 97 ++++++- .../activities/MediaViewerScreen.kt | 133 ++++++++-- .../mediaviewer/model/MediaViewerActions.kt | 131 ++++++++++ .../mediaviewer/model/PendingFileForward.kt | 34 +++ .../viewmodels/MediaViewerViewModel.kt | 85 ++++++- .../activities/SharedItemsActivity.kt | 21 ++ .../adapters/SharedItemsAdapter.kt | 9 +- .../ui/dialog/SaveToStorageDialogFragment.kt | 18 +- .../nextcloud/talk/utils/bundle/BundleKeys.kt | 1 + app/src/main/res/values/strings.xml | 5 + .../talk/chat/ui/MessageActionsStateTest.kt | 107 +++++++- .../model/MediaViewerActionsTest.kt | 149 +++++++++++ .../model/MediaViewerChatRequestTest.kt | 143 +++++++++++ 22 files changed, 1280 insertions(+), 154 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActions.kt create mode 100644 app/src/main/java/com/nextcloud/talk/mediaviewer/model/PendingFileForward.kt create mode 100644 app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActionsTest.kt create mode 100644 app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerChatRequestTest.kt diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt index 18f21a092f..80e6c8b765 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewFragment.kt @@ -33,6 +33,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { private lateinit var filesList: ArrayList private var conversationName: String = "" private var showFilePermissionsOption: Boolean = false + private var startDrawing: Boolean = false private var composeView: ComposeView? = null @Inject @@ -53,6 +54,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { filesList = it.getStringArrayList(FILES_TO_UPLOAD_ARG)!! conversationName = it.getString(CONVERSATION_NAME_ARG, "") showFilePermissionsOption = it.getBoolean(FILE_PERMISSIONS_OPTION_ARG, false) + startDrawing = it.getBoolean(START_DRAWING_ARG, false) } composeView = ComposeView(requireContext()) @@ -88,7 +90,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) - viewModel.setInitialFiles(filesList) + viewModel.setInitialFiles(filesList, startDrawing) composeView?.apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) @@ -123,6 +125,7 @@ class FileAttachmentPreviewFragment : DialogFragment() { private const val FILES_TO_UPLOAD_ARG = "FILES_TO_UPLOAD_ARG" private const val CONVERSATION_NAME_ARG = "CONVERSATION_NAME_ARG" private const val FILE_PERMISSIONS_OPTION_ARG = "FILE_PERMISSIONS_OPTION_ARG" + private const val START_DRAWING_ARG = "START_DRAWING_ARG" const val RESULT_KEY = "FILE_ATTACHMENT_PREVIEW_RESULT" const val RESULT_FILES = "RESULT_FILES" @@ -145,13 +148,15 @@ class FileAttachmentPreviewFragment : DialogFragment() { fun newInstance( filesToUpload: MutableList, conversationName: String, - showFilePermissionsOption: Boolean = false + showFilePermissionsOption: Boolean = false, + startDrawing: Boolean = false ): FileAttachmentPreviewFragment { val fileAttachmentFragment = FileAttachmentPreviewFragment() val args = Bundle() args.putStringArrayList(FILES_TO_UPLOAD_ARG, ArrayList(filesToUpload)) args.putString(CONVERSATION_NAME_ARG, conversationName) args.putBoolean(FILE_PERMISSIONS_OPTION_ARG, showFilePermissionsOption) + args.putBoolean(START_DRAWING_ARG, startDrawing) fileAttachmentFragment.arguments = args return fileAttachmentFragment } diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt index 55f061cfe7..a6db4972af 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewScreen.kt @@ -117,6 +117,11 @@ internal fun FileAttachmentPreviewContent( // while waiting for the descriptions would hold Send back until large files are copied. val hasCompressibleMedia = currentFiles.any { isCompressible(viewModel.mimeTypesByUri[it]) } + AutoStartDrawing(viewModel, fileDescriptions) { + viewModel.drawing.clear() + drawingUri = it + } + val pagerState = rememberPagerState(pageCount = { fileDescriptions.size }) val coroutineScope = rememberCoroutineScope() val currentDescription = fileDescriptions.getOrNull(pagerState.currentPage) @@ -231,6 +236,24 @@ internal fun FileAttachmentPreviewContent( } } +/** Opened from the media viewer's draw button: go straight into the drawing editor once the photo is described. */ +@Composable +private fun AutoStartDrawing( + viewModel: FileAttachmentPreviewViewModel, + descriptions: List, + onStart: (String) -> Unit +) { + val target = viewModel.autoDrawUri?.let { uri -> descriptions.firstOrNull { it.uri == uri } } + LaunchedEffect(target) { + if (target != null) { + if (target.kind == MediaKind.IMAGE && target.aspectRatio != null) { + onStart(target.uri) + } + viewModel.autoDrawUri = null + } + } +} + /** The edit tools / quality / permission pill on the left, the round send button on the right. */ @Suppress("LongParameterList") @Composable diff --git a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt index 452ffd2581..6afd99cff1 100644 --- a/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/attachmentpreview/FileAttachmentPreviewViewModel.kt @@ -60,10 +60,22 @@ internal class FileAttachmentPreviewViewModel( private var describeJob: Job? = null private var mimeTypeJob: Job? = null - /** No-op after the first call, so re-entering (e.g. after rotation) doesn't wipe edits made since. */ - fun setInitialFiles(initialFiles: List) { + /** + * The file whose drawing editor is to be opened as soon as it is described, set once by + * [setInitialFiles] and cleared by the screen once the editor is open. + */ + var autoDrawUri by mutableStateOf(null) + + /** + * No-op after the first call, so re-entering (e.g. after rotation) doesn't wipe edits made since. + * With [startDrawing] the first file opens straight in the drawing editor. + */ + fun setInitialFiles(initialFiles: List, startDrawing: Boolean = false) { if (files.isEmpty()) { files.addAll(initialFiles) + if (startDrawing) { + autoDrawUri = initialFiles.firstOrNull() + } } } 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 f0ea1d7507..15e7f72d95 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -137,6 +137,7 @@ import com.nextcloud.talk.chat.ui.ProfileModalBottomSheet import com.nextcloud.talk.chat.ui.ShowReactionsModalBottomSheet import com.nextcloud.talk.chat.ui.TempMessageActionsBottomSheet import com.nextcloud.talk.chat.ui.TypingIndicatorBanner +import com.nextcloud.talk.chat.ui.MessageActionsState import com.nextcloud.talk.chat.ui.buildMessageActionsState import com.nextcloud.talk.chat.ui.model.MessageTypeContent import com.nextcloud.talk.chat.viewmodels.ChatViewModel @@ -153,6 +154,12 @@ import com.nextcloud.talk.events.WebSocketCommunicationEvent import com.nextcloud.talk.jobs.DeleteConversationWorker import com.nextcloud.talk.jobs.DownloadFileToCacheWorker import com.nextcloud.talk.jobs.ShareOperationWorker +import com.nextcloud.talk.mediaviewer.model.MediaViewerChatAction +import com.nextcloud.talk.mediaviewer.model.MediaViewerChatRequest +import com.nextcloud.talk.mediaviewer.model.PendingFileForward +import com.nextcloud.talk.mediaviewer.model.isInsideDirectory +import com.nextcloud.talk.mediaviewer.model.isMediaActionAllowed +import com.nextcloud.talk.mediaviewer.model.remoteSharePath import com.nextcloud.talk.jobs.UploadAndShareFilesWorker import com.nextcloud.talk.location.LocationPickerActivity import com.nextcloud.talk.mediaviewer.activities.MediaViewerActivity @@ -255,6 +262,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withContext import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -445,6 +453,9 @@ class ChatActivity : private var pendingCameraUri: Uri? = null private var attachmentSheetModel by mutableStateOf(null) private var pendingTargetMessageId: Long? = null + + /** An action the media viewer handed over (reply, delete, forward, draw); runs once the room is joined. */ + private var pendingMediaRequest: MediaViewerChatRequest? = null private var pendingTargetThreadId: Long? = null private var pendingTargetSearchQuery: String? = null @@ -625,6 +636,18 @@ class ChatActivity : // and registerForActivityResult() must be called before the activity is started. initialUser = setUpBoundUserOrFinish() ?: return registerAttachmentPreviewResultListener() + // The request of the media viewer comes from the intent only on a fresh start. When the activity is restored + // (rotation, process death) the system hands over the original intent again, which must not run it twice: + // then only what was saved, i.e. not yet run, counts. + pendingMediaRequest = if (savedInstanceState == null) { + readMediaRequest(intent) + } else { + MediaViewerChatRequest.parse( + savedInstanceState.getString(MediaViewerChatRequest.KEY_ACTION), + savedInstanceState.getLong(MediaViewerChatRequest.KEY_MESSAGE_ID), + savedInstanceState.getString(MediaViewerChatRequest.KEY_LOCAL_PATH) + ) + } binding = ActivityChatBinding.inflate(layoutInflater) setContentView(binding.root) @@ -693,18 +716,7 @@ class ChatActivity : initObservers() resumeRecordingAfterRecreation() - pendingTargetMessageId?.let { messageId -> - lifecycleScope.launch { - chatViewModel.openMessageFromGlobalSearch( - messageId = messageId, - threadId = pendingTargetThreadId, - searchQuery = pendingTargetSearchQuery - ) - } - pendingTargetMessageId = null - pendingTargetThreadId = null - pendingTargetSearchQuery = null - } + openPendingTargetMessage() pickMultipleMedia = registerForActivityResult( ActivityResultContracts.PickMultipleVisualMedia(MAX_AMOUNT_MEDIA_FILE_PICKER) @@ -1050,17 +1062,7 @@ class ChatActivity : ) } else { MessageActionsBottomSheet( - actionsState = buildMessageActionsState( - message = msg, - user = conversationUser, - conversation = currentConversation, - hasChatPermission = participantPermissions?.hasChatPermission() == true, - hasReactPermission = participantPermissions?.hasReactPermission() == true, - spreedCapabilities = spreedCapabilities, - isOnline = isOnline, - dateUtils = dateUtils, - conversationThreadId = conversationThreadId - ), + actionsState = actionsStateFor(msg, isOnline), onEmojiClick = { emoji -> if (msg.reactionsSelf?.contains(emoji) == true) { chatViewModel.deleteReaction(roomToken, msg, emoji) @@ -1068,13 +1070,7 @@ class ChatActivity : chatViewModel.addReaction(roomToken, msg, emoji) } }, - onReply = { - if (msg.isThread && conversationThreadId == null) { - openThread(msg) - } else { - messageInputViewModel.reply(msg) - } - }, + onReply = { replyTo(msg) }, onReplyPrivately = { replyPrivately(msg) }, onOpenThread = { msg.threadId?.let { openThread(it) } }, onForward = { forwardMessage(msg) }, @@ -1400,11 +1396,51 @@ class ChatActivity : if (newRoomToken != roomToken) { switchToRoom(newRoomToken, startCallAfterRoomSwitch, isVoiceOnlyCall) } + } else if (isForOtherRoom(extras)) { + // e.g. back from the media viewer to a chat which is not the one underneath: open it as a new chat + intent.removeFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + startActivity(intent) } else { handleIntent(intent) + readMediaRequest(intent)?.let { pendingMediaRequest = it } + applyIntentTargetsToJoinedRoom() } } + /** Scrolls to the message the intent asked for (a search hit, "show in chat"), once. */ + private fun openPendingTargetMessage() { + pendingTargetMessageId?.let { messageId -> + lifecycleScope.launch { + chatViewModel.openMessageFromGlobalSearch( + messageId = messageId, + threadId = pendingTargetThreadId, + searchQuery = pendingTargetSearchQuery + ) + } + pendingTargetMessageId = null + pendingTargetThreadId = null + pendingTargetSearchQuery = null + } + } + + private fun isForOtherRoom(extras: Bundle?): Boolean { + val requestedToken = extras?.getString(KEY_ROOM_TOKEN) + return requestedToken != null && requestedToken != roomToken + } + + /** + * An intent for the chat which is already open: scrolls to the message it names and runs the request of the media + * viewer, but only when the conversation is already loaded. Otherwise the request stays pending and is run when + * the joined-room state arrives; the scroll target is not kept for that case. + */ + private fun applyIntentTargetsToJoinedRoom() { + if (currentConversation == null) { + return + } + openPendingTargetMessage() + runPendingMediaRequest() + } + private fun handleIntent(intent: Intent) { val extras: Bundle? = intent.extras @@ -1441,6 +1477,18 @@ class ChatActivity : pendingTargetSearchQuery = extras?.getString(BundleKeys.KEY_SEARCH_QUERY) } + /** The request of the media viewer in [source], if any; removes it from the intent so it is read only once. */ + private fun readMediaRequest(source: Intent): MediaViewerChatRequest? { + val extras = source.extras + val request = MediaViewerChatRequest.parse( + actionName = extras?.getString(MediaViewerChatRequest.KEY_ACTION), + messageId = extras?.getLong(MediaViewerChatRequest.KEY_MESSAGE_ID) ?: 0L, + localPath = extras?.getString(MediaViewerChatRequest.KEY_LOCAL_PATH) + ) + source.removeExtra(MediaViewerChatRequest.KEY_ACTION) + return request + } + override fun onStart() { super.onStart() active = true @@ -1481,6 +1529,11 @@ class ChatActivity : override fun onSaveInstanceState(outState: Bundle) { chatViewModel.handleOrientationChange() + pendingMediaRequest?.let { + outState.putString(MediaViewerChatRequest.KEY_ACTION, it.action.name) + outState.putLong(MediaViewerChatRequest.KEY_MESSAGE_ID, it.messageId) + outState.putString(MediaViewerChatRequest.KEY_LOCAL_PATH, it.localPath) + } super.onSaveInstanceState(outState) } @@ -1659,6 +1712,7 @@ class ChatActivity : onChooseFileResult(sharedFilePaths.map { it.toUri() }) sharedFilePaths = emptyList() } + runPendingMediaRequest() refreshScheduledMessages() @@ -2922,11 +2976,12 @@ class ChatActivity : } } - private fun showFileAttachmentPreview(files: MutableList) { + private fun showFileAttachmentPreview(files: MutableList, startDrawing: Boolean = false) { val newFragment = FileAttachmentPreviewFragment.newInstance( files, currentConversation?.displayName ?: "", - CapabilitiesUtil.hasConversationSubfoldersForAttachments(spreedCapabilities) + CapabilitiesUtil.hasConversationSubfoldersForAttachments(spreedCapabilities), + startDrawing ) newFragment.show(supportFragmentManager, FileAttachmentPreviewFragment.TAG) } @@ -3690,19 +3745,16 @@ class ChatActivity : } private fun showSharedItems() { - val intent = Intent(this, SharedItemsActivity::class.java) - intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) - intent.putExtra(KEY_CONVERSATION_NAME, currentConversation?.displayName) - intent.putExtra(KEY_ROOM_TOKEN, roomToken) - intent.putExtra( - SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR, - ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!) - ) - intent.putExtra( - SharedItemsActivity.KEY_IS_ONE_2_ONE, - currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + startActivity( + SharedItemsActivity.createIntent( + context = this, + userId = conversationUserId, + roomToken = roomToken, + conversationName = currentConversation?.displayName, + isOwnerOrModerator = ConversationUtils.isParticipantOwnerOrModerator(currentConversation!!), + isOneToOne = currentConversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + ) ) - startActivity(intent) } private fun startMessageSearch() { @@ -3836,6 +3888,27 @@ class ChatActivity : } } + private fun replyTo(message: ChatMessage) { + if (message.isThread && conversationThreadId == null) { + openThread(message) + } else { + messageInputViewModel.reply(message) + } + } + + private fun actionsStateFor(message: ChatMessage, isOnline: Boolean): MessageActionsState = + buildMessageActionsState( + message = message, + user = conversationUser, + conversation = currentConversation, + hasChatPermission = participantPermissionsFlow.value?.hasChatPermission() == true, + hasReactPermission = participantPermissionsFlow.value?.hasReactPermission() == true, + spreedCapabilities = spreedCapabilities, + isOnline = isOnline, + dateUtils = dateUtils, + conversationThreadId = conversationThreadId + ) + private fun openMessageActionsDialog(message: ChatMessage) { if (message.isTemporary || (hasVisibleItems(message) && !isSystemMessage(message))) { chatViewModel.showMessageActions(message.jsonMessageId.toLong()) @@ -3900,6 +3973,80 @@ class ChatActivity : finish() } + /** + * Runs the request the media viewer handed over, with the chat's own handlers. The rules of the message actions + * are checked again here, with the chat's data, so a request is never run for what the chat would not offer. + */ + private fun runPendingMediaRequest() { + val request = pendingMediaRequest ?: return + if (currentConversation == null) { + return + } + pendingMediaRequest = null + lifecycleScope.launch { + val message = loadMessageOrNull(request.messageId) + val state = message?.let { actionsStateFor(it, networkMonitor.isOnline.value) } + if (message == null || state == null || !isMediaActionAllowed(request.action, state)) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return@launch + } + when (request.action) { + MediaViewerChatAction.REPLY -> replyTo(message) + MediaViewerChatAction.DELETE -> deleteMessage(message) + MediaViewerChatAction.FORWARD -> forwardFile(message) + MediaViewerChatAction.DRAW -> startDrawingOnCopy(request.localPath) + } + } + } + + @Suppress("TooGenericExceptionCaught") + private suspend fun loadMessageOrNull(messageId: Long): ChatMessage? = + try { + withTimeoutOrNull(MESSAGE_LOAD_TIMEOUT_MS) { chatViewModel.getMessageById(messageId).first() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.e(TAG, "Could not load the message of the media viewer request", e) + null + } + + /** + * Opens the upload preview of the cached file, with the drawing editor on. The preview works on copies: drawing + * and cropping write new files, the cached file and the sent message stay as they are. + */ + private fun startDrawingOnCopy(localPath: String?) { + val directory = FileUtils.getSharedAttachmentsDirectory(cacheDir) + val file = localPath?.let { File(it) } + if (directory == null || file == null || !file.isFile || !isInsideDirectory(directory, file)) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } + val uri = FileProvider.getUriForFile(this, BuildConfig.APPLICATION_ID, file) + showFileAttachmentPreview(mutableListOf(uri.toString()), startDrawing = true) + } + + /** + * Forwards the file of [message] to a conversation to be picked. The path comes from the message in the database + * and goes to the conversation list as the key of an entry in [PendingFileForward], never as a path in an intent. + */ + private fun forwardFile(message: ChatMessage) { + val path = message.fileParameters.path + if (path.isNullOrBlank()) { + Snackbar.make(binding.root, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() + return + } + val caption = message.message?.takeIf { it != "{file}" }.orEmpty() + val key = PendingFileForward.put(PendingFileForward.Entry(conversationUserId, remoteSharePath(path), caption)) + val intent = Intent(this, ConversationsListActivity::class.java) + intent.putExtra(BundleKeys.KEY_FORWARD_MSG_FLAG, true) + intent.putExtra(BundleKeys.KEY_FORWARD_FILE_KEY, key) + intent.putExtra(BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM, roomToken) + intent.putExtra(KEY_INTERNAL_USER_ID, conversationUserId) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(intent) + finish() + } + fun remindMeLater(message: ChatMessage?) { Log.d(TAG, "remindMeLater called") @@ -4568,6 +4715,7 @@ class ChatActivity : private const val GET_ROOM_INFO_DELAY_NORMAL: Long = 30000 private const val GET_ROOM_INFO_DELAY_LOBBY: Long = 5000 private const val MILLIS_250 = 250L + private const val MESSAGE_LOAD_TIMEOUT_MS = 10_000L private const val MILLIS_150 = 150L private const val MILLIS_1000 = 1000L private const val FLOAT_100 = 100f diff --git a/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt b/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt index 5866f7e70d..67a54c1e2d 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ui/MessageActionsBottomSheet.kt @@ -125,6 +125,10 @@ data class MessageActionsState( val showReplyPrivately: Boolean, val showOpenThread: Boolean, val showForward: Boolean, + /** Sending a file message on to another conversation; offered by the media viewer, not by the sheet. */ + val showForwardFile: Boolean, + /** Whether new content (e.g. a drawn copy of a photo) may be posted to this conversation. */ + val canSendToConversation: Boolean, val showEdit: Boolean, val showCopy: Boolean, val showCopyMessageLink: Boolean, @@ -244,6 +248,14 @@ internal fun buildMessageActionsState( !(message.isDeletedCommentMessage || message.isDeleted) && isOnline && !isClassifiedRoom, + // a file of a federated conversation does not lie in the user's own storage, so it can't be shared on + showForwardFile = messageHasFileAttachment && + !message.isDeleted && + isOnline && + !isClassifiedRoom && + conversation?.remoteServer.isNullOrEmpty() && + !message.fileParameters.path.isNullOrBlank(), + canSendToConversation = hasChatPermission && !isConversationReadOnly, showEdit = isMessageEditable, showCopy = !message.isDeleted, showCopyMessageLink = !message.isDeleted && @@ -777,7 +789,7 @@ private fun EditedInfo(editedBy: String, editedAt: String) { } @Composable -private fun DeleteConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { +internal fun DeleteConfirmationDialog(onConfirm: () -> Unit, onDismiss: () -> Unit) { AlertDialog( onDismissRequest = onDismiss, icon = { @@ -834,6 +846,8 @@ private fun PreviewMessageActionsSheetContent() { showReplyPrivately = true, showOpenThread = false, showForward = true, + showForwardFile = false, + canSendToConversation = true, showEdit = true, showCopy = true, showCopyMessageLink = true, @@ -894,6 +908,8 @@ private fun PreviewMessageActionsSheetPinned() { showReplyPrivately = false, showOpenThread = false, showForward = false, + showForwardFile = false, + canSendToConversation = true, showEdit = false, showCopy = true, showCopyMessageLink = true, diff --git a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt index ebcf37f78e..7e5b54a9a2 100644 --- a/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt +++ b/app/src/main/java/com/nextcloud/talk/components/StandardAppBar.kt @@ -11,6 +11,8 @@ import androidx.activity.compose.LocalOnBackPressedDispatcherOwner import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.RowScope +import androidx.compose.material3.CenterAlignedTopAppBar import androidx.compose.material3.DropdownMenu import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api @@ -20,6 +22,7 @@ import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.material3.TopAppBarColors import androidx.compose.material3.TopAppBarDefaults @@ -28,6 +31,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.colorResource @@ -38,77 +42,100 @@ import com.nextcloud.talk.R private const val SUBTITLE_ALPHA = 0.75f +/** A button shown in the bar next to the overflow menu. */ +data class AppBarAction(val icon: ImageVector, val contentDescription: String, val onClick: () -> Unit) + +@Suppress("LongParameterList") @OptIn(ExperimentalMaterial3Api::class) @Composable fun StandardAppBar( title: String, menuItems: List Unit>>?, colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), - subtitle: String? = null + subtitle: String? = null, + centerTitle: Boolean = false, + actionButtons: List = emptyList() ) { val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher var expanded by remember { mutableStateOf(false) } - TopAppBar( - title = { StandardAppBarTitle(title = title, subtitle = subtitle) }, - colors = colors, - navigationIcon = { - IconButton( - onClick = { backDispatcher?.onBackPressed() } - ) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_back_black_24dp), - contentDescription = stringResource(R.string.back_button) - ) + val titleContent: @Composable () -> Unit = { + StandardAppBarTitle(title = title, subtitle = subtitle, centered = centerTitle) + } + val navigation: @Composable () -> Unit = { + IconButton( + onClick = { backDispatcher?.onBackPressed() } + ) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_back_black_24dp), + contentDescription = stringResource(R.string.back_button) + ) + } + } + val actions: @Composable RowScope.() -> Unit = { + actionButtons.forEach { action -> + IconButton(onClick = action.onClick) { + Icon(imageVector = action.icon, contentDescription = action.contentDescription) } - }, - actions = { - if (!menuItems.isNullOrEmpty()) { - Box { - IconButton(onClick = { expanded = true }) { - Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_more_vert_24px), - contentDescription = stringResource(R.string.nc_common_more_options) - ) - } + } + if (!menuItems.isNullOrEmpty()) { + Box { + IconButton(onClick = { expanded = true }) { + Icon( + imageVector = ImageVector.vectorResource(R.drawable.ic_more_vert_24px), + contentDescription = stringResource(R.string.nc_common_more_options) + ) + } - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - modifier = Modifier.background(color = colorResource(id = R.color.bg_default)) - ) { - menuItems?.forEach { (label, action) -> - DropdownMenuItem( - text = { Text(label) }, - onClick = { - action() - expanded = false - } - ) - } + DropdownMenu( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.background(color = colorResource(id = R.color.bg_default)) + ) { + menuItems.forEach { (label, action) -> + DropdownMenuItem( + text = { Text(label) }, + onClick = { + action() + expanded = false + } + ) } } } } - ) + } + + if (centerTitle) { + CenterAlignedTopAppBar( + title = titleContent, + colors = colors, + navigationIcon = navigation, + actions = actions + ) + } else { + TopAppBar(title = titleContent, colors = colors, navigationIcon = navigation, actions = actions) + } } @Composable -private fun StandardAppBarTitle(title: String, subtitle: String?) { +private fun StandardAppBarTitle(title: String, subtitle: String?, centered: Boolean) { + val textAlign = if (centered) TextAlign.Center else null if (subtitle != null) { - Column { - Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Column(horizontalAlignment = if (centered) Alignment.CenterHorizontally else Alignment.Start) { + Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = textAlign) Text( text = subtitle, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.bodySmall, - color = LocalContentColor.current.copy(alpha = SUBTITLE_ALPHA) + color = LocalContentColor.current.copy(alpha = SUBTITLE_ALPHA), + textAlign = textAlign ) } } else { - Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text(text = title, maxLines = 1, overflow = TextOverflow.Ellipsis, textAlign = textAlign) } } diff --git a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt index 12b8ea0a0e..8ae211158f 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationinfo/ConversationInfoActivity.kt @@ -396,19 +396,14 @@ class ConversationInfoActivity : BaseActivity() { private fun showSharedItems() { val conv = viewModel.uiState.value.conversation ?: return startActivity( - Intent(this, SharedItemsActivity::class.java).apply { - putExtra(BundleKeys.KEY_INTERNAL_USER_ID, conversationUser.id) - putExtra(BundleKeys.KEY_CONVERSATION_NAME, conv.displayName) - putExtra(KEY_ROOM_TOKEN, conversationToken) - putExtra( - SharedItemsActivity.KEY_USER_IS_OWNER_OR_MODERATOR, - ConversationUtils.isParticipantOwnerOrModerator(conv) - ) - putExtra( - SharedItemsActivity.KEY_IS_ONE_2_ONE, - conv.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL - ) - } + SharedItemsActivity.createIntent( + context = this, + userId = conversationUser.id!!, + roomToken = conversationToken, + conversationName = conv.displayName, + isOwnerOrModerator = ConversationUtils.isParticipantOwnerOrModerator(conv), + isOneToOne = conv.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + ) ) } diff --git a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt index f03d2abe10..b79c02665c 100644 --- a/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/conversationlist/ConversationsListActivity.kt @@ -41,6 +41,7 @@ import androidx.work.WorkInfo import androidx.work.WorkManager import autodagger.AutoInjector import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.gson.Gson import com.nextcloud.android.common.core.utils.ecosystem.AccountReceiverCallback import com.nextcloud.android.common.core.utils.ecosystem.EcosystemManager import com.nextcloud.talk.R @@ -72,7 +73,9 @@ import com.nextcloud.talk.jobs.ContactAddressBookWorker.Companion.run import com.nextcloud.talk.jobs.DeleteConversationWorker import com.nextcloud.talk.jobs.LeaveConversationWorker import com.nextcloud.talk.jobs.RemoteWipeSuccessWorker +import com.nextcloud.talk.jobs.ShareOperationWorker import com.nextcloud.talk.jobs.UploadAndShareFilesWorker +import com.nextcloud.talk.mediaviewer.model.PendingFileForward import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.domain.SearchMessageEntry import com.nextcloud.talk.models.json.conversations.ConversationEnums @@ -105,6 +108,7 @@ import com.nextcloud.talk.utils.bundle.BundleKeys import com.nextcloud.talk.utils.bundle.BundleKeys.ADD_ADDITIONAL_ACCOUNT import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CALL_VOICE_ONLY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_FILE_KEY import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_HIDE_SOURCE_ROOM import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_MSG_FLAG import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_FORWARD_MSG_TEXT @@ -890,7 +894,10 @@ class ConversationsListActivity : BaseActivity() { showSnackbar(getString(R.string.send_to_forbidden)) } } else if (forwardMessage) { - if (hasChatPermission && !isReadOnlyConversation(selectedConversation!!)) { + val forwardFileKey = intent.getStringExtra(KEY_FORWARD_FILE_KEY) + if (hasChatPermission && !isReadOnlyConversation(selectedConversation!!) && forwardFileKey != null) { + confirmForwardFile(forwardFileKey) + } else if (hasChatPermission && !isReadOnlyConversation(selectedConversation!!)) { openConversation(intent.getStringExtra(KEY_FORWARD_MSG_TEXT)) forwardMessageState.value = false } else { @@ -904,6 +911,43 @@ class ConversationsListActivity : BaseActivity() { } } + /** + * Forwarding a file: after the confirmation the file is shared to the chosen conversation, with the share step the + * upload of attachments ends with. Nothing is uploaded again. The file comes from [PendingFileForward] by the key + * of the intent, never from the intent itself (this activity is exported); without an entry nothing is forwarded. + */ + private fun confirmForwardFile(key: String) { + val conversation = selectedConversation ?: return + val entry = PendingFileForward.peek(key) + if (entry == null || entry.userId != currentUser.id) { + forwardMessageState.value = false + intent.removeExtra(KEY_FORWARD_FILE_KEY) + showSnackbar(getString(R.string.nc_common_error_sorry)) + return + } + val title = String.format(resources.getString(R.string.nc_upload_confirm_send_single), conversation.displayName) + val dialogBuilder = MaterialAlertDialogBuilder(this) + .setTitle(title) + .setMessage(entry.remotePath.substringAfterLast('/')) + .setPositiveButton(R.string.nc_yes) { _, _ -> + // taken only now: until then a rotation (which drops the dialog) must not lose the file + val confirmed = PendingFileForward.take(key) ?: return@setPositiveButton + val caption = confirmed.caption + val metaData = if (caption.isNotEmpty()) Gson().toJson(mapOf("caption" to caption)) else null + ShareOperationWorker.shareFile(conversation.token, currentUser, confirmed.remotePath, metaData) + intent.removeExtra(KEY_FORWARD_FILE_KEY) + forwardMessageState.value = false + openConversation() + } + .setNegativeButton(R.string.nc_no, null) + viewThemeUtils.dialog.colorMaterialAlertDialogBackground(this, dialogBuilder) + val dialog = dialogBuilder.show() + viewThemeUtils.platform.colorTextButtons( + dialog.getButton(AlertDialog.BUTTON_POSITIVE), + dialog.getButton(AlertDialog.BUTTON_NEGATIVE) + ) + } + private fun shouldShowLobby(conversation: ConversationModel): Boolean { val participantPermissions = ParticipantPermissions( currentUser.capabilities?.spreedCapability, diff --git a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt index e643dfb803..24bf8f6384 100644 --- a/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt +++ b/app/src/main/java/com/nextcloud/talk/jobs/ShareOperationWorker.kt @@ -8,7 +8,10 @@ package com.nextcloud.talk.jobs import android.content.Context +import android.os.Handler +import android.os.Looper import android.util.Log +import android.widget.Toast import androidx.work.Data import androidx.work.OneTimeWorkRequest import com.nextcloud.talk.utils.setExpeditedIfSupported @@ -16,6 +19,7 @@ import androidx.work.WorkManager import androidx.work.Worker import androidx.work.WorkerParameters import autodagger.AutoInjector +import com.nextcloud.talk.R import com.nextcloud.talk.api.NcApi import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.application.NextcloudTalkApplication.Companion.sharedApplication @@ -50,15 +54,20 @@ class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : W private val metaData: String? override fun doWork(): Result { - for (filePath in filesArray) { - tryCreateShare(filePath) + val failed = filesArray.count { !tryCreateShare(it) } + if (failed > 0) { + // The work itself is not retried (the share was refused), so the user has to be told. + Handler(Looper.getMainLooper()).post { + Toast.makeText(applicationContext, R.string.nc_common_error_sorry, Toast.LENGTH_LONG).show() + } } roomToken?.let { _shareCompletedFlow.tryEmit(it) } return Result.success() } @Suppress("TooGenericExceptionCaught") - private fun tryCreateShare(filePath: String?) { + private fun tryCreateShare(filePath: String?): Boolean { + var created = false for (attempt in 1..SHARE_MAX_ATTEMPTS) { var succeeded = false var shouldRetry = false @@ -82,9 +91,13 @@ class ShareOperationWorker(context: Context, workerParams: WorkerParameters) : W } } ) - if (succeeded || !shouldRetry) return + if (succeeded || !shouldRetry) { + created = succeeded + break + } Thread.sleep(SHARE_RETRY_DELAY_MS) } + return created } init { diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt index 13b8bb7b74..7a51f0e402 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerActivity.kt @@ -28,7 +28,15 @@ import com.nextcloud.talk.R import com.nextcloud.talk.activities.BaseActivity import com.nextcloud.talk.application.NextcloudTalkApplication import com.nextcloud.talk.extensions.getParcelableArrayListExtraProvider +import com.nextcloud.talk.chat.ChatActivity +import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.mediaviewer.model.MediaViewerChatAction +import com.nextcloud.talk.mediaviewer.model.MediaViewerChatRequest import com.nextcloud.talk.mediaviewer.model.MediaViewerItem +import com.nextcloud.talk.mediaviewer.model.isInsideDirectory +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.shareditems.activities.SharedItemsActivity +import com.nextcloud.talk.utils.ConversationUtils import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel import com.nextcloud.talk.ui.dialog.SaveToStorageDialogFragment import com.nextcloud.talk.utils.FileUtils @@ -53,12 +61,16 @@ class MediaViewerActivity : BaseActivity() { lateinit var viewModelFactory: ViewModelProvider.Factory private lateinit var viewModel: MediaViewerViewModel + private lateinit var user: User + private lateinit var roomToken: String + private var openedFromSharedItems = false private lateinit var windowInsetsController: WindowInsetsControllerCompat override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) NextcloudTalkApplication.sharedApplication!!.componentApplication.inject(this) - val user = setUpBoundUserOrFinish() ?: return + val boundUser = setUpBoundUserOrFinish() ?: return + user = boundUser val imageAuthHeader = ApiUtils.getCredentials(user.username, user.token) val roomToken = intent.getStringExtra(KEY_ROOM_TOKEN) @@ -71,6 +83,9 @@ class MediaViewerActivity : BaseActivity() { return } + this.roomToken = roomToken + openedFromSharedItems = intent.getBooleanExtra(EXTRA_FROM_SHARED_ITEMS, false) + viewModel = ViewModelProvider(this, viewModelFactory)[MediaViewerViewModel::class.java] viewModel.initialize(user, roomToken, seedItems, startMessageId) @@ -89,11 +104,16 @@ class MediaViewerActivity : BaseActivity() { CompositionLocalProvider(LocalImageAuthHeader provides imageAuthHeader) { MediaViewerScreen( viewModel = viewModel, - onShare = ::shareFile, - onSave = ::showSaveDialog, - onControlsVisibilityChanged = { visible -> - if (visible) exitImmersiveMode() else enterImmersiveMode() - } + callbacks = MediaViewerCallbacks( + onShare = ::shareFile, + onSave = ::showSaveDialog, + onShowAllMedia = ::showAllMedia, + onShowInChat = ::showInChat, + onChatAction = ::sendActionToChat, + onControlsVisibilityChanged = { visible -> + if (visible) exitImmersiveMode() else enterImmersiveMode() + } + ) ) } } @@ -125,33 +145,88 @@ class MediaViewerActivity : BaseActivity() { } private fun showSaveDialog(item: MediaViewerItem, localPath: String) { - val safeFile = FileUtils.resolveSharedAttachmentFile(cacheDir, File(localPath).name) - if (safeFile == null) { - logger.e(TAG, "Refused to save file with unsafe name: ${File(localPath).name}") + val safeFile = FileUtils.resolveSharedAttachmentFile(cacheDir, item.fileId, item.fileName) + if (safeFile == null || !safeFile.exists()) { + logger.e(TAG, "Refused to save file with unsafe name or path: ${item.fileName}") Snackbar.make(window.decorView, R.string.nc_common_error_sorry, Snackbar.LENGTH_LONG).show() return } - val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(safeFile.name) + // The viewer caches every file in its own per-file-id folder, which the dialog has to look in. + val saveFragment: DialogFragment = SaveToStorageDialogFragment.newInstance(item.fileName, item.fileId) saveFragment.show(supportFragmentManager, SaveToStorageDialogFragment.TAG) } + private fun showAllMedia() { + if (openedFromSharedItems) { + // The media grid this viewer was opened from is right underneath. + finish() + return + } + val conversation = viewModel.uiState.value.conversation + startActivity( + SharedItemsActivity.createIntent( + context = this, + userId = user.id!!, + roomToken = roomToken, + conversationName = conversation?.displayName, + isOwnerOrModerator = conversation?.let { ConversationUtils.isParticipantOwnerOrModerator(it) } == true, + isOneToOne = conversation?.type == ConversationEnums.ConversationType.ROOM_TYPE_ONE_TO_ONE_CALL + ) + ) + } + + /** Scrolls the chat to the message, through the chat's own "open message" extra (as the shared items do). */ + private fun showInChat(item: MediaViewerItem) { + startActivity(openChat(scrollToMessage = item.messageId)) + finish() + } + + /** Hands reply, delete, forward and draw to the chat, which runs its own handlers for them. */ + private fun sendActionToChat(action: MediaViewerChatAction, item: MediaViewerItem, localPath: String?) { + val attachmentsDirectory = FileUtils.getSharedAttachmentsDirectory(cacheDir) + val safeLocalPath = localPath?.takeIf { + attachmentsDirectory != null && isInsideDirectory(attachmentsDirectory, File(it)) + } + val intent = openChat().apply { + putExtra(MediaViewerChatRequest.KEY_ACTION, action.name) + putExtra(MediaViewerChatRequest.KEY_MESSAGE_ID, item.messageId) + putExtra(MediaViewerChatRequest.KEY_LOCAL_PATH, safeLocalPath) + } + startActivity(intent) + finish() + } + + /** + * Returns to this conversation's chat: the already open instance below this screen when there is one, else a + * new one. With [scrollToMessage] the chat scrolls to that message through [BundleKeys.KEY_MESSAGE_ID]. + */ + private fun openChat(scrollToMessage: Long? = null): Intent = + ChatActivity.createIntent(this, user.id!!, roomToken).apply { + scrollToMessage?.let { putExtra(BundleKeys.KEY_MESSAGE_ID, it) } + addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + } + companion object { private val TAG = MediaViewerActivity::class.java.simpleName private const val EXTRA_SEED_ITEMS = "MEDIA_VIEWER_SEED_ITEMS" private const val EXTRA_START_MESSAGE_ID = "MEDIA_VIEWER_START_MESSAGE_ID" + private const val EXTRA_FROM_SHARED_ITEMS = "MEDIA_VIEWER_FROM_SHARED_ITEMS" + @Suppress("LongParameterList") fun newIntent( context: Context, userId: Long, roomToken: String, seedItems: List, - startMessageId: Long + startMessageId: Long, + fromSharedItems: Boolean = false ): Intent = Intent(context, MediaViewerActivity::class.java).apply { putExtra(BundleKeys.KEY_INTERNAL_USER_ID, userId) putExtra(KEY_ROOM_TOKEN, roomToken) putParcelableArrayListExtra(EXTRA_SEED_ITEMS, ArrayList(seedItems)) putExtra(EXTRA_START_MESSAGE_ID, startMessageId) + putExtra(EXTRA_FROM_SHARED_ITEMS, fromSharedItems) } } } diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt index 31218c2847..6d6c257e42 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/activities/MediaViewerScreen.kt @@ -40,6 +40,8 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Brush import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon @@ -51,6 +53,7 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -62,7 +65,9 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.res.painterResource +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.res.stringResource +import androidx.compose.ui.res.vectorResource import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import androidx.core.net.toUri @@ -81,9 +86,14 @@ import coil.request.ImageRequest import coil.request.SuccessResult import com.github.chrisbanes.photoview.PhotoView import com.nextcloud.talk.R +import com.nextcloud.talk.components.AppBarAction +import com.nextcloud.talk.chat.ui.DeleteConfirmationDialog import com.nextcloud.talk.components.StandardAppBar import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup +import com.nextcloud.talk.mediaviewer.model.MediaViewerChatAction import com.nextcloud.talk.mediaviewer.model.MediaViewerItem +import com.nextcloud.talk.mediaviewer.model.MediaViewerMenuState +import com.nextcloud.talk.mediaviewer.model.mediaViewerMenuState import com.nextcloud.talk.mediaviewer.viewmodels.MediaViewerViewModel import com.nextcloud.talk.utils.DateConstants import com.nextcloud.talk.utils.DateUtils @@ -107,15 +117,21 @@ private val thumbnailSpacing = 4.dp private val thumbnailStripVerticalPadding = 12.dp private val controlsSlideDistance = 24.dp +/** What the viewer's actions do; implemented by [MediaViewerActivity], which owns the intents. */ +data class MediaViewerCallbacks( + val onShare: (MediaViewerItem, String) -> Unit, + val onSave: (MediaViewerItem, String) -> Unit, + val onShowAllMedia: () -> Unit, + val onShowInChat: (MediaViewerItem) -> Unit, + /** Reply, delete, forward and draw run in the chat; the cached copy's path is given when it exists. */ + val onChatAction: (MediaViewerChatAction, MediaViewerItem, String?) -> Unit, + val onControlsVisibilityChanged: (Boolean) -> Unit = {} +) + @Suppress("Detekt.LongMethod", "CyclomaticComplexMethod") @OptIn(ExperimentalMaterial3Api::class) @Composable -fun MediaViewerScreen( - viewModel: MediaViewerViewModel, - onShare: (MediaViewerItem, String) -> Unit, - onSave: (MediaViewerItem, String) -> Unit, - onControlsVisibilityChanged: (Boolean) -> Unit = {} -) { +fun MediaViewerScreen(viewModel: MediaViewerViewModel, callbacks: MediaViewerCallbacks) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() val items = uiState.flattenedItems if (items.isEmpty()) return @@ -175,7 +191,7 @@ fun MediaViewerScreen( // The status/nav bars toggle together with the top bar and thumbnail strip - one tap hides all // of it. LaunchedEffect(showControls) { - onControlsVisibilityChanged(showControls) + callbacks.onControlsVisibilityChanged(showControls) } // Hoisted here (rather than inside ThumbnailStrip) so its scroll position survives showControls @@ -186,6 +202,17 @@ fun MediaViewerScreen( val density = LocalDensity.current val slideOffsetPx = with(density) { controlsSlideDistance.roundToPx() } + var confirmDelete by rememberSaveable { mutableStateOf(false) } + if (confirmDelete && currentItem != null) { + DeleteConfirmationDialog( + onConfirm = { + confirmDelete = false + callbacks.onChatAction(MediaViewerChatAction.DELETE, currentItem, currentLocalPath) + }, + onDismiss = { confirmDelete = false } + ) + } + val group = uiState.currentGroup val hasThumbnailStrip = group != null && group.items.size > 1 // Reserved so the video's own ExoPlayer controller (progress bar, play/pause row) never renders @@ -227,20 +254,33 @@ fun MediaViewerScreen( navigationIconContentColor = Color.White, actionIconContentColor = Color.White ) - val menuItems = buildList { - if (currentItem != null && currentLocalPath != null) { - add(stringResource(R.string.share) to { onShare(currentItem, currentLocalPath) }) - add(stringResource(R.string.nc_save_message) to { onSave(currentItem, currentLocalPath) }) - } + val menuState = currentItem?.let { + mediaViewerMenuState( + mimeType = it.mimeType, + hasLocalFile = currentLocalPath != null, + actions = uiState.actionStates[it.messageId] + ) } + val menuItems = buildMenuItems(currentItem, currentLocalPath, menuState, callbacks) { confirmDelete = true } + val actionButtons = buildActionButtons(currentItem, currentLocalPath, menuState, callbacks) val sentDateTime = currentItem?.let { dateUtils.getLocalDateTimeStringFromTimestamp(it.timestamp * DateConstants.SECOND_DIVIDER) } + val counter = uiState.counter + val counterText = when { + counter == null -> "" + counter.totalIsLowerBound -> + stringResource(R.string.media_viewer_counter_more, counter.position, counter.total) + else -> stringResource(R.string.media_viewer_counter, counter.position, counter.total) + } + val sender = currentItem?.actorDisplayName.orEmpty() StandardAppBar( - title = currentItem?.actorDisplayName.orEmpty(), + title = counterText, menuItems = menuItems, colors = toolbarColors, - subtitle = sentDateTime + subtitle = listOf(sender, sentDateTime.orEmpty()).filter { it.isNotBlank() }.joinToString(", "), + centerTitle = true, + actionButtons = actionButtons ) } @@ -279,6 +319,71 @@ fun MediaViewerScreen( } } +@Composable +private fun buildMenuItems( + item: MediaViewerItem?, + localPath: String?, + state: MediaViewerMenuState?, + callbacks: MediaViewerCallbacks, + onDelete: () -> Unit +): List Unit>> { + if (item == null || state == null) return emptyList() + return buildList { + if (state.saveToGallery && localPath != null) { + add(stringResource(R.string.media_viewer_save_to_gallery) to { callbacks.onSave(item, localPath) }) + } + if (state.showAllMedia) { + add(stringResource(R.string.media_viewer_show_all_media) to { callbacks.onShowAllMedia() }) + } + if (state.showInChat) { + add(stringResource(R.string.media_viewer_show_in_chat) to { callbacks.onShowInChat(item) }) + } + if (state.reply) { + add( + stringResource(R.string.nc_reply) to { + callbacks.onChatAction(MediaViewerChatAction.REPLY, item, localPath) + } + ) + } + if (state.share && localPath != null) { + add(stringResource(R.string.share) to { callbacks.onShare(item, localPath) }) + } + if (state.delete) { + add(stringResource(R.string.nc_delete) to onDelete) + } + } +} + +@Composable +private fun buildActionButtons( + item: MediaViewerItem?, + localPath: String?, + state: MediaViewerMenuState?, + callbacks: MediaViewerCallbacks +): List { + if (item == null || state == null) return emptyList() + return buildList { + if (state.forward) { + add( + AppBarAction( + icon = ImageVector.vectorResource(R.drawable.forward_24), + contentDescription = stringResource(R.string.nc_forward_message), + onClick = { callbacks.onChatAction(MediaViewerChatAction.FORWARD, item, localPath) } + ) + ) + } + if (state.draw) { + add( + AppBarAction( + icon = Icons.Outlined.Brush, + contentDescription = stringResource(R.string.nc_attachment_draw), + onClick = { callbacks.onChatAction(MediaViewerChatAction.DRAW, item, localPath) } + ) + ) + } + } +} + @OptIn(UnstableApi::class) @Composable private fun rememberViewerExoPlayer(): ExoPlayer { diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActions.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActions.kt new file mode 100644 index 0000000000..bf2311e7eb --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActions.kt @@ -0,0 +1,131 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.model + +import com.nextcloud.talk.chat.ui.MessageActionsState +import com.nextcloud.talk.utils.Mimetype +import com.nextcloud.talk.utils.MimetypeUtils +import java.io.File +import java.io.IOException + +/** + * What the counter in the viewer's top bar shows. [position] counts from the oldest loaded item, so the + * newest item is "M of M", as in other messengers. When older items are paged in, both [position] and + * [total] grow by their number while the viewer stays on the same item. + * [totalIsLowerBound] is true while older items may still be loaded, shown as "N of M+". + */ +data class MediaViewerCounter(val position: Int, val total: Int, val totalIsLowerBound: Boolean) + +/** + * [index] is the pager position (0 = oldest loaded item), [total] the number of loaded items and + * [hasMoreOlder] whether older history may still be fetched. Null when there is nothing to count. + */ +fun mediaViewerCounter(index: Int, total: Int, hasMoreOlder: Boolean): MediaViewerCounter? = + if (total <= 0 || index !in 0 until total) { + null + } else { + MediaViewerCounter(position = index + 1, total = total, totalIsLowerBound = hasMoreOlder) + } + +/** Photos only: GIFs would lose their animation and SVG cannot be edited by the crop/draw tools. */ +fun isDrawableImage(mimeType: String): Boolean = + mimeType.startsWith(Mimetype.IMAGE_PREFIX) && + !MimetypeUtils.isGif(mimeType) && + !mimeType.startsWith(SVG_MIME_PREFIX) + +private const val SVG_MIME_PREFIX = "image/svg" + +/** Which of the viewer's menu entries and top bar buttons are offered for one item. */ +data class MediaViewerMenuState( + val saveToGallery: Boolean, + val share: Boolean, + val showAllMedia: Boolean, + val showInChat: Boolean, + val reply: Boolean, + val delete: Boolean, + val forward: Boolean, + val draw: Boolean +) + +/** + * Visibility of the viewer's actions. The rules that depend on the chat (reply, delete, forward, drawing into + * the conversation) come from [actions], built by the chat's own `buildMessageActionsState`; when the message + * is not in the local database ([actions] null) none of them is offered, as their permissions can't be checked. + * Sharing and saving only act on the local copy, so they just need the file to be downloaded. + */ +fun mediaViewerMenuState(mimeType: String, hasLocalFile: Boolean, actions: MessageActionsState?): MediaViewerMenuState = + MediaViewerMenuState( + saveToGallery = hasLocalFile && (actions?.showSave ?: true), + share = hasLocalFile && (actions?.showShare ?: true), + showAllMedia = true, + showInChat = true, + reply = actions?.showReply == true, + delete = actions?.showDelete == true, + forward = actions?.showForwardFile == true, + draw = hasLocalFile && isDrawableImage(mimeType) && actions?.canSendToConversation == true + ) + +/** An action the viewer hands over to the chat, which owns the message handlers it needs. */ +enum class MediaViewerChatAction { + REPLY, + DELETE, + FORWARD, + DRAW +} + +/** + * The request the viewer sends to ChatActivity through intent extras (the KEY_ constants below). + * [localPath] is the cached copy (drawing). Nothing else is trusted from the intent: what is forwarded is read from the + * message in the database. + */ +data class MediaViewerChatRequest( + val action: MediaViewerChatAction, + val messageId: Long, + val localPath: String? = null +) { + companion object { + const val KEY_ACTION = "MEDIA_VIEWER_CHAT_ACTION" + const val KEY_MESSAGE_ID = "MEDIA_VIEWER_CHAT_MESSAGE_ID" + const val KEY_LOCAL_PATH = "MEDIA_VIEWER_CHAT_LOCAL_PATH" + + /** Null for a missing or unknown action, a missing message id, or an action without the data it needs. */ + fun parse(actionName: String?, messageId: Long, localPath: String?): MediaViewerChatRequest? { + val action = MediaViewerChatAction.entries.firstOrNull { it.name == actionName } + val complete = when (action) { + null -> false + MediaViewerChatAction.DRAW -> !localPath.isNullOrBlank() + else -> true + } + return if (action != null && complete && messageId > 0L) { + MediaViewerChatRequest(action, messageId, localPath) + } else { + null + } + } + } +} + +/** The chat's own rule for [action] (see `buildMessageActionsState`), for the request received from the viewer. */ +fun isMediaActionAllowed(action: MediaViewerChatAction, state: MessageActionsState): Boolean = + when (action) { + MediaViewerChatAction.REPLY -> state.showReply + MediaViewerChatAction.DELETE -> state.showDelete + MediaViewerChatAction.FORWARD -> state.showForwardFile + MediaViewerChatAction.DRAW -> state.canSendToConversation + } + +/** The path of a file in the user's own storage as the share API expects it: with exactly one leading slash. */ +fun remoteSharePath(path: String): String = "/" + path.trimStart('/') + +/** True when [file] lies below [directory] (not [directory] itself), after resolving "..", symlinks and the like. */ +fun isInsideDirectory(directory: File, file: File): Boolean = + try { + val base = directory.canonicalFile + file.canonicalFile.path.startsWith(base.path + File.separator) + } catch (_: IOException) { + false + } diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/model/PendingFileForward.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/PendingFileForward.kt new file mode 100644 index 0000000000..f0c176ba16 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/model/PendingFileForward.kt @@ -0,0 +1,34 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.model + +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** + * A file the user is forwarding, kept in the memory of this process while the conversation is picked. The conversation + * list is exported, so the path of the file must never come in through an intent: the intent only carries the random + * key of an entry made here. A key without an entry (foreign intent, dead process) forwards nothing. + */ +object PendingFileForward { + + data class Entry(val userId: Long, val remotePath: String, val caption: String) + + private val entries = ConcurrentHashMap() + + fun put(entry: Entry): String { + val key = UUID.randomUUID().toString() + entries[key] = entry + return key + } + + /** Reads the entry without removing it, e.g. to show the confirmation (a rotation must not lose it). */ + fun peek(key: String?): Entry? = key?.let { entries[it] } + + /** Reads the entry and removes it, so that a key works once. */ + fun take(key: String?): Entry? = key?.let { entries.remove(it) } +} diff --git a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt index 694c6f7e54..7a7c16c388 100644 --- a/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt +++ b/app/src/main/java/com/nextcloud/talk/mediaviewer/viewmodels/MediaViewerViewModel.kt @@ -6,6 +6,7 @@ */ package com.nextcloud.talk.mediaviewer.viewmodels +import android.content.Context import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.asFlow @@ -17,6 +18,17 @@ import com.nextcloud.talk.utils.setExpeditedIfSupported import androidx.work.WorkInfo import androidx.work.WorkManager import com.nextcloud.talk.application.NextcloudTalkApplication +import com.nextcloud.talk.chat.ui.MessageActionsState +import com.nextcloud.talk.chat.ui.buildMessageActionsState +import com.nextcloud.talk.conversationlist.data.OfflineConversationsRepository +import com.nextcloud.talk.data.database.dao.ChatMessagesDao +import com.nextcloud.talk.data.database.mappers.toDomainModel +import com.nextcloud.talk.data.network.NetworkMonitor +import com.nextcloud.talk.mediaviewer.model.MediaViewerCounter +import com.nextcloud.talk.mediaviewer.model.mediaViewerCounter +import com.nextcloud.talk.models.domain.ConversationModel +import com.nextcloud.talk.utils.DateUtils +import com.nextcloud.talk.utils.ParticipantPermissions import com.nextcloud.talk.data.user.model.User import com.nextcloud.talk.jobs.DownloadFileToCacheWorker import com.nextcloud.talk.mediaviewer.model.MediaViewerGroup @@ -34,6 +46,7 @@ import io.reactivex.Observable import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.suspendCancellableCoroutine @@ -49,8 +62,14 @@ import kotlin.coroutines.resumeWithException * why "older" does). "Older" navigation pages further back through conversation history via * [SharedItemsRepository] once the locally seeded items run out. */ -class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository: SharedItemsRepository) : - ViewModel() { +@Suppress("LongParameterList") +class MediaViewerViewModel @Inject constructor( + private val sharedItemsRepository: SharedItemsRepository, + private val chatMessagesDao: ChatMessagesDao, + private val conversationsRepository: OfflineConversationsRepository, + private val networkMonitor: NetworkMonitor, + private val context: Context +) : ViewModel() { data class PendingShift(val id: Long, val amount: Int) @@ -61,8 +80,14 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository val canLoadOlder: Boolean = true, val cachedFilePaths: Map = emptyMap(), val downloadingMessageIds: Set = emptySet(), - val pendingShift: PendingShift? = null + val pendingShift: PendingShift? = null, + /** Per message id: what the chat allows for it. A message missing here is not in the local database. */ + val actionStates: Map = emptyMap(), + val conversation: ConversationModel? = null ) { + val counter: MediaViewerCounter? + get() = mediaViewerCounter(currentGlobalIndex, flattenedItems.size, canLoadOlder) + val flattenedItems: List get() = groups.flatMap { it.items } val currentItem: MediaViewerItem? get() = flattenedItems.getOrNull(currentGlobalIndex) val currentGroup: MediaViewerGroup? @@ -95,11 +120,65 @@ class MediaViewerViewModel @Inject constructor(private val sharedItemsRepository _uiState.value = UiState(groups = groups, currentGlobalIndex = startIndex) ensureCachedAround(startIndex) + loadConversation(roomToken) + observeOnlineState() + } + + /** What the chat offers depends on being online (forwarding, deleting), so the states are worked out again. */ + private fun observeOnlineState() { + viewModelScope.launch { + networkMonitor.isOnline.drop(1).collect { + _uiState.update { state -> state.copy(actionStates = emptyMap()) } + _uiState.value.currentItem?.let(::loadActionState) + } + } + } + + private fun loadConversation(roomToken: String) { + viewModelScope.launch { + @Suppress("DEPRECATION") + val conversation = conversationsRepository.getLocallyStoredConversation(user, roomToken) + _uiState.update { it.copy(conversation = conversation) } + // Items may have been settled before the conversation was known. + _uiState.value.currentItem?.let(::loadActionState) + } + } + + /** + * Works out what the chat allows for [item] with the chat's own rules (`buildMessageActionsState`), from the + * message and the conversation in the local database. Nothing is stored when the message is not there (an item + * paged in from the network), so the actions that need permissions stay hidden. + */ + private fun loadActionState(item: MediaViewerItem) { + val state = _uiState.value + val conversation = state.conversation + if (conversation == null || state.actionStates.containsKey(item.messageId)) return + viewModelScope.launch { + val message = chatMessagesDao + .getChatMessageEntity("${user.id}@${conversation.token}", item.messageId) + ?.toDomainModel() + ?: return@launch + val capabilities = user.capabilities?.spreedCapability ?: return@launch + val permissions = ParticipantPermissions(capabilities, conversation) + val actions = buildMessageActionsState( + message = message, + user = user, + conversation = conversation, + hasChatPermission = permissions.hasChatPermission(), + hasReactPermission = permissions.hasReactPermission(), + spreedCapabilities = capabilities, + isOnline = networkMonitor.isOnline.value, + dateUtils = DateUtils(context), + conversationThreadId = null + ) + _uiState.update { it.copy(actionStates = it.actionStates + (item.messageId to actions)) } + } } fun onPageSettled(globalIndex: Int) { _uiState.update { it.copy(currentGlobalIndex = globalIndex) } ensureCachedAround(globalIndex) + _uiState.value.currentItem?.let(::loadActionState) if (globalIndex <= EDGE_LOAD_THRESHOLD) { loadOlderGroups() } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt index ac080d75cc..4c8870cec5 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/activities/SharedItemsActivity.kt @@ -9,6 +9,8 @@ */ package com.nextcloud.talk.shareditems.activities +import android.content.Context +import android.content.Intent import android.os.Bundle import android.util.Log import android.view.MenuItem @@ -32,6 +34,7 @@ import com.nextcloud.talk.shareditems.adapters.SharedItemsAdapter import com.nextcloud.talk.shareditems.model.SharedItemType import com.nextcloud.talk.shareditems.viewmodels.SharedItemsViewModel import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_CONVERSATION_NAME +import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_INTERNAL_USER_ID import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_ROOM_TOKEN import com.nextcloud.talk.utils.bundle.BundleKeys.KEY_THREAD_ID import javax.inject.Inject @@ -297,5 +300,23 @@ class SharedItemsActivity : BaseActivity() { const val SPAN_COUNT: Int = 4 const val KEY_USER_IS_OWNER_OR_MODERATOR = "userIsOwnerOrModerator" const val KEY_IS_ONE_2_ONE = "KEY_IS_ONE_2_ONE" + + /** The media tab is the first one, so it is what such an intent shows. */ + @Suppress("LongParameterList") + fun createIntent( + context: Context, + userId: Long, + roomToken: String, + conversationName: String?, + isOwnerOrModerator: Boolean, + isOneToOne: Boolean + ): Intent = + Intent(context, SharedItemsActivity::class.java).apply { + putExtra(KEY_INTERNAL_USER_ID, userId) + putExtra(KEY_CONVERSATION_NAME, conversationName) + putExtra(KEY_ROOM_TOKEN, roomToken) + putExtra(KEY_USER_IS_OWNER_OR_MODERATOR, isOwnerOrModerator) + putExtra(KEY_IS_ONE_2_ONE, isOneToOne) + } } } diff --git a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt index fa360a8d62..badf205b3d 100644 --- a/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt +++ b/app/src/main/java/com/nextcloud/talk/shareditems/adapters/SharedItemsAdapter.kt @@ -125,7 +125,14 @@ class SharedItemsAdapter( .map { it.toMediaViewerItem() } .capSeedAroundMessage(item.messageId) context.startActivity( - MediaViewerActivity.newIntent(context, user.id!!, roomToken, seedItems, item.messageId) + MediaViewerActivity.newIntent( + context, + user.id!!, + roomToken, + seedItems, + item.messageId, + fromSharedItems = true + ) ) } diff --git a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt index e68d91fedc..b8a20d744c 100644 --- a/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt +++ b/app/src/main/java/com/nextcloud/talk/ui/dialog/SaveToStorageDialogFragment.kt @@ -37,12 +37,14 @@ class SaveToStorageDialogFragment : DialogFragment() { @Inject lateinit var viewThemeUtils: ViewThemeUtils lateinit var fileName: String + private var fileId: String? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) sharedApplication!!.componentApplication.inject(this) viewThemeUtils = hostViewThemeUtils(activity, viewThemeUtils) fileName = arguments?.getString(KEY_FILE_NAME)!! + fileId = arguments?.getString(KEY_FILE_ID) } override fun onCreateDialog(savedInstanceState: Bundle?): Dialog { val dialogText = StringBuilder() @@ -74,11 +76,15 @@ class SaveToStorageDialogFragment : DialogFragment() { @SuppressLint("LongLogTag") private fun saveImageToStorage(fileName: String) { - val sourceDirectory = FileUtils.getSharedAttachmentsDirectory(requireContext().cacheDir) ?: run { - Log.e(TAG, "Failed to resolve shared attachments directory") + // Files cached by the media viewer live in a folder named after their file id, chat attachments directly in + // the shared attachments directory. + val sourceFile = fileId?.let { FileUtils.resolveSharedAttachmentFile(requireContext().cacheDir, it, fileName) } + ?: FileUtils.resolveSharedAttachmentFile(requireContext().cacheDir, fileName) + if (sourceFile == null) { + Log.e(TAG, "Failed to resolve the file to save") return } - val workerTag = SAVE_TO_STORAGE_WORKER_PREFIX + fileName + val workerTag = SAVE_TO_STORAGE_WORKER_PREFIX + sourceFile.absolutePath val workers = WorkManager.getInstance(requireContext()).getWorkInfosByTag(workerTag) try { @@ -95,7 +101,7 @@ class SaveToStorageDialogFragment : DialogFragment() { val data: Data = Data.Builder() .putString(SaveFileToStorageWorker.KEY_FILE_NAME, fileName) - .putString(SaveFileToStorageWorker.KEY_SOURCE_FILE_PATH, "$sourceDirectory/$fileName") + .putString(SaveFileToStorageWorker.KEY_SOURCE_FILE_PATH, sourceFile.absolutePath) .build() val saveWorker: OneTimeWorkRequest = OneTimeWorkRequest.Builder(SaveFileToStorageWorker::class.java) @@ -110,11 +116,13 @@ class SaveToStorageDialogFragment : DialogFragment() { companion object { val TAG = SaveToStorageDialogFragment::class.java.simpleName private const val KEY_FILE_NAME = "keyFileName" + private const val KEY_FILE_ID = "keyFileId" private const val SAVE_TO_STORAGE_WORKER_PREFIX = "saveToStorage_" - fun newInstance(fileName: String): SaveToStorageDialogFragment { + fun newInstance(fileName: String, fileId: String? = null): SaveToStorageDialogFragment { val args = Bundle() args.putString(KEY_FILE_NAME, fileName) + fileId?.let { args.putString(KEY_FILE_ID, it) } val fragment = SaveToStorageDialogFragment() fragment.arguments = args return fragment diff --git a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt index 4041f5218a..72b15b3016 100644 --- a/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt +++ b/app/src/main/java/com/nextcloud/talk/utils/bundle/BundleKeys.kt @@ -61,6 +61,7 @@ object BundleKeys { const val KEY_FORWARD_MSG_FLAG = "KEY_FORWARD_MSG_FLAG" const val KEY_FORWARD_MSG_TEXT = "KEY_FORWARD_MSG_TEXT" const val KEY_FORWARD_HIDE_SOURCE_ROOM = "KEY_FORWARD_HIDE_SOURCE_ROOM" + const val KEY_FORWARD_FILE_KEY = "KEY_FORWARD_FILE_KEY" const val KEY_SYSTEM_NOTIFICATION_ID = "KEY_SYSTEM_NOTIFICATION_ID" const val KEY_MESSAGE_ID = "KEY_MESSAGE_ID" const val KEY_MIME_TYPE_FILTER = "KEY_MIME_TYPE_FILTER" diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ed72c5505b..8b6cb4fe8c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1032,6 +1032,11 @@ How to translate with transifex: Include this file Crop and rotate Draw + %1$d of %2$d + %1$d of %2$d+ + Save to gallery + Show all media + Show in chat Undo Add photos or video Not included in the message diff --git a/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt b/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt index 2e329e1c7a..ce2555cf67 100644 --- a/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt +++ b/app/src/test/java/com/nextcloud/talk/chat/ui/MessageActionsStateTest.kt @@ -8,7 +8,10 @@ package com.nextcloud.talk.chat.ui import com.nextcloud.talk.chat.data.model.ChatMessage import com.nextcloud.talk.data.user.model.User +import com.nextcloud.talk.models.domain.ConversationModel import com.nextcloud.talk.models.json.capabilities.SpreedCapabilityDto +import com.nextcloud.talk.models.json.conversations.ConversationEnums +import com.nextcloud.talk.models.json.participants.ParticipantDto import com.nextcloud.talk.utils.DateUtils import org.junit.Assert import org.junit.Test @@ -18,19 +21,51 @@ class MessageActionsStateTest { private val dateUtils = Mockito.mock(DateUtils::class.java) - private fun buildStateFor(message: ChatMessage) = - buildMessageActionsState( - message = message, - user = User().apply { userId = "alice" }, - conversation = null, - hasChatPermission = true, - hasReactPermission = true, - spreedCapabilities = SpreedCapabilityDto(), - isOnline = true, - dateUtils = dateUtils, - conversationThreadId = null + private fun conversation(remoteServer: String? = null) = + ConversationModel( + internalId = "1@abc", + accountId = 1L, + token = "abc", + name = "abc", + displayName = "abc", + description = "", + type = ConversationEnums.ConversationType.ROOM_GROUP_CALL, + participantType = ParticipantDto.ParticipantType.USER, + sessionId = "0", + actorId = "alice", + actorType = "users", + objectType = ConversationEnums.ObjectType.DEFAULT, + notificationLevel = ConversationEnums.NotificationLevel.DEFAULT, + conversationReadOnlyState = ConversationEnums.ConversationReadOnlyState.CONVERSATION_READ_WRITE, + lobbyState = ConversationEnums.LobbyState.LOBBY_STATE_ALL_PARTICIPANTS, + lobbyTimer = 0L, + canLeaveConversation = true, + canDeleteConversation = false, + unreadMentionDirect = false, + notificationCalls = 0, + avatarVersion = "", + hasCustomAvatar = false, + callStartTime = 0L, + remoteServer = remoteServer ) + private fun buildStateFor( + message: ChatMessage, + isOnline: Boolean = true, + hasChatPermission: Boolean = true, + conversation: ConversationModel? = null + ) = buildMessageActionsState( + message = message, + user = User().apply { userId = "alice" }, + conversation = conversation, + hasChatPermission = hasChatPermission, + hasReactPermission = true, + spreedCapabilities = SpreedCapabilityDto(), + isOnline = isOnline, + dateUtils = dateUtils, + conversationThreadId = null + ) + @Test fun showCopyMessageLink_trueForRegularMessage() { val msg = ChatMessage().apply { @@ -59,4 +94,54 @@ class MessageActionsStateTest { ) Assert.assertFalse(buildStateFor(msg).showCopyMessageLink) } + + private fun fileMessage() = + ChatMessage( + jsonMessageId = 7, + message = "{file}", + messageParameters = hashMapOf("file" to hashMapOf("id" to "1", "name" to "a.jpg", "path" to "Talk/a.jpg")) + ) + + @Test + fun showForwardFile_trueForOnlineFileMessage_butTheTextForwardStaysOff() { + val state = buildStateFor(fileMessage()) + Assert.assertTrue(state.showForwardFile) + Assert.assertFalse(state.showForward) + } + + @Test + fun showForwardFile_falseOfflineDeletedOrForText() { + Assert.assertFalse(buildStateFor(fileMessage(), isOnline = false).showForwardFile) + Assert.assertFalse(buildStateFor(fileMessage().apply { isDeleted = true }).showForwardFile) + Assert.assertFalse(buildStateFor(ChatMessage(jsonMessageId = 8, message = "hi")).showForwardFile) + } + + @Test + fun showForwardFile_falseWithoutThePathOfTheFile() { + val noPath = ChatMessage( + jsonMessageId = 9, + message = "{file}", + messageParameters = hashMapOf("file" to hashMapOf("id" to "1", "name" to "a.jpg")) + ) + val withPath = ChatMessage( + jsonMessageId = 9, + message = "{file}", + messageParameters = hashMapOf("file" to hashMapOf("id" to "1", "name" to "a.jpg", "path" to "Talk/a.jpg")) + ) + Assert.assertFalse(buildStateFor(noPath).showForwardFile) + Assert.assertTrue(buildStateFor(withPath).showForwardFile) + } + + @Test + fun showForwardFile_falseInFederatedConversation() { + val federated = conversation(remoteServer = "https://other.example") + Assert.assertFalse(buildStateFor(fileMessage(), conversation = federated).showForwardFile) + Assert.assertTrue(buildStateFor(fileMessage(), conversation = conversation()).showForwardFile) + } + + @Test + fun canSendToConversation_followsTheChatPermission() { + Assert.assertTrue(buildStateFor(fileMessage()).canSendToConversation) + Assert.assertFalse(buildStateFor(fileMessage(), hasChatPermission = false).canSendToConversation) + } } diff --git a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActionsTest.kt b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActionsTest.kt new file mode 100644 index 0000000000..828ee52d8f --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerActionsTest.kt @@ -0,0 +1,149 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.model + +import com.nextcloud.talk.chat.ui.MessageActionsState +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 MediaViewerActionsTest { + + private fun actions( + reply: Boolean = true, + delete: Boolean = true, + forwardFile: Boolean = true, + canSend: Boolean = true + ) = allowedActions().copy( + showReply = reply, + showDelete = delete, + showForwardFile = forwardFile, + canSendToConversation = canSend + ) + + private fun allowedActions() = + MessageActionsState( + showEmojiBar = false, + selfReactions = emptySet(), + showEditInfo = false, + lastEditedBy = "", + lastEditedAt = "", + showReply = true, + showReplyPrivately = false, + showOpenThread = false, + showForward = false, + showForwardFile = true, + canSendToConversation = true, + showEdit = false, + showCopy = false, + showCopyMessageLink = false, + showMarkAsUnread = false, + showRemind = false, + showPin = false, + isPinned = false, + showTranslate = false, + showShareToNote = false, + showShare = true, + showSave = true, + showOpenInFiles = false, + showDelete = true + ) + + // counter + + @Test + fun counter_newestItemIsTheLast() { + // pager order is oldest first: the newest item (index 4 of 5) = 5 of 5, the oldest = 1 of 5 + assertEquals(MediaViewerCounter(5, 5, false), mediaViewerCounter(4, 5, false)) + assertEquals(MediaViewerCounter(1, 5, false), mediaViewerCounter(0, 5, false)) + } + + @Test + fun counter_positionMovesWithTheItemWhenOlderItemsArePagedIn() { + val newest = mediaViewerCounter(index = 4, total = 5, hasMoreOlder = true)!! + assertEquals(MediaViewerCounter(5, 5, true), newest) + // 50 older items are loaded: the pager index of the viewed item moves by 50, it is still the newest + val after = mediaViewerCounter(index = 54, total = 55, hasMoreOlder = true)!! + assertEquals(MediaViewerCounter(55, 55, true), after) + + val middle = mediaViewerCounter(index = 2, total = 5, hasMoreOlder = true)!! + val middleAfter = mediaViewerCounter(index = 52, total = 55, hasMoreOlder = false)!! + assertEquals(3, middle.position) + assertEquals(middle.position + 50, middleAfter.position) + assertFalse(middleAfter.totalIsLowerBound) + } + + @Test + fun counter_totalIsExactOnceNothingOlderIsLeft() { + assertFalse(mediaViewerCounter(0, 3, hasMoreOlder = false)!!.totalIsLowerBound) + } + + @Test + fun counter_nullWithoutItemsOrForIndexOutOfRange() { + assertNull(mediaViewerCounter(0, 0, false)) + assertNull(mediaViewerCounter(-1, 3, false)) + assertNull(mediaViewerCounter(3, 3, false)) + } + + // media kind + + @Test + fun drawing_isOnlyForPhotos() { + assertTrue(isDrawableImage("image/jpeg")) + assertTrue(isDrawableImage("image/png")) + assertFalse(isDrawableImage("image/gif")) + assertFalse(isDrawableImage("image/svg+xml")) + assertFalse(isDrawableImage("video/mp4")) + assertFalse(isDrawableImage("application/pdf")) + } + + // menu + + @Test + fun menu_allEntriesForAnOwnPhotoWithAllRights() { + val menu = mediaViewerMenuState("image/jpeg", hasLocalFile = true, actions = actions()) + assertEquals(MediaViewerMenuState(true, true, true, true, true, true, true, true), menu) + } + + @Test + fun menu_videoGetsEverythingButDrawing() { + val menu = mediaViewerMenuState("video/mp4", hasLocalFile = true, actions = actions()) + assertFalse(menu.draw) + assertTrue(menu.forward && menu.reply && menu.delete && menu.share && menu.saveToGallery) + } + + @Test + fun menu_withoutTheMessageInTheDatabaseOnlyWhatNeedsNoRightsRemains() { + val menu = mediaViewerMenuState("image/jpeg", hasLocalFile = true, actions = null) + assertEquals(MediaViewerMenuState(true, true, true, true, false, false, false, false), menu) + } + + @Test + fun menu_followsTheChatRules() { + val menu = mediaViewerMenuState( + "image/jpeg", + hasLocalFile = true, + actions = actions(reply = false, delete = false, forwardFile = false, canSend = false) + ) + assertFalse(menu.reply) + assertFalse(menu.delete) + assertFalse(menu.forward) + assertFalse(menu.draw) + assertTrue(menu.showInChat && menu.showAllMedia) + } + + @Test + fun menu_shareSaveAndDrawNeedTheDownloadedFile_forwardDoesNot() { + val menu = mediaViewerMenuState("image/jpeg", hasLocalFile = false, actions = actions()) + assertFalse(menu.share) + assertFalse(menu.saveToGallery) + assertFalse(menu.draw) + assertTrue(menu.forward) + } +} diff --git a/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerChatRequestTest.kt b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerChatRequestTest.kt new file mode 100644 index 0000000000..fe2c503cb3 --- /dev/null +++ b/app/src/test/java/com/nextcloud/talk/mediaviewer/model/MediaViewerChatRequestTest.kt @@ -0,0 +1,143 @@ +/* + * Nextcloud Talk - Android Client + * + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ +package com.nextcloud.talk.mediaviewer.model + +import com.nextcloud.talk.chat.ui.MessageActionsState +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +class MediaViewerChatRequestTest { + + @get:Rule + val temp = TemporaryFolder() + + private fun state(allowed: Boolean) = + MessageActionsState( + showEmojiBar = false, + selfReactions = emptySet(), + showEditInfo = false, + lastEditedBy = "", + lastEditedAt = "", + showReply = allowed, + showReplyPrivately = false, + showOpenThread = false, + showForward = false, + showForwardFile = allowed, + canSendToConversation = allowed, + showEdit = false, + showCopy = false, + showCopyMessageLink = false, + showMarkAsUnread = false, + showRemind = false, + showPin = false, + isPinned = false, + showTranslate = false, + showShareToNote = false, + showShare = false, + showSave = false, + showOpenInFiles = false, + showDelete = allowed + ) + + // request + + @Test + fun request_parsesWhatTheViewerSends() { + assertEquals( + MediaViewerChatRequest(MediaViewerChatAction.REPLY, 12L), + MediaViewerChatRequest.parse("REPLY", 12L, null) + ) + assertEquals( + MediaViewerChatRequest(MediaViewerChatAction.DRAW, 12L, localPath = "/c/a.jpg"), + MediaViewerChatRequest.parse("DRAW", 12L, "/c/a.jpg") + ) + assertEquals( + MediaViewerChatRequest(MediaViewerChatAction.FORWARD, 12L), + MediaViewerChatRequest.parse("FORWARD", 12L, null) + ) + } + + @Test + fun request_rejectsIncompleteOrUnknown() { + assertNull(MediaViewerChatRequest.parse(null, 12L, null)) + assertNull(MediaViewerChatRequest.parse("EXPLODE", 12L, null)) + assertNull(MediaViewerChatRequest.parse("REPLY", 0L, null)) + assertNull(MediaViewerChatRequest.parse("DRAW", 12L, null)) + } + + @Test + fun chatRecheck_eachActionFollowsExactlyItsOwnFlag() { + val none = state(false) + val only = mapOf( + MediaViewerChatAction.REPLY to none.copy(showReply = true), + MediaViewerChatAction.DELETE to none.copy(showDelete = true), + MediaViewerChatAction.FORWARD to none.copy(showForwardFile = true), + MediaViewerChatAction.DRAW to none.copy(canSendToConversation = true) + ) + MediaViewerChatAction.entries.forEach { action -> + assertFalse(action.name, isMediaActionAllowed(action, none)) + MediaViewerChatAction.entries.forEach { flagOf -> + val allowed = isMediaActionAllowed(action, only.getValue(flagOf)) + assertEquals("$action with only the flag of $flagOf", action == flagOf, allowed) + } + } + } + + // forward entries + + @Test + fun pendingForward_worksOnceAndNeedsTheKey() { + val entry = PendingFileForward.Entry(1L, "/Talk/a.jpg", "caption") + val key = PendingFileForward.put(entry) + assertEquals(entry, PendingFileForward.take(key)) + assertNull(PendingFileForward.take(key)) + assertNull(PendingFileForward.take(null)) + assertNull(PendingFileForward.take("not-a-key")) + } + + @Test + fun pendingForward_peekDoesNotConsumeTheEntry() { + val entry = PendingFileForward.Entry(1L, "/Talk/a.jpg", "") + val key = PendingFileForward.put(entry) + assertEquals(entry, PendingFileForward.peek(key)) + assertEquals(entry, PendingFileForward.peek(key)) + assertEquals(entry, PendingFileForward.take(key)) + assertNull(PendingFileForward.peek(key)) + assertNull(PendingFileForward.peek(null)) + } + + // paths + + @Test + fun remoteSharePath_hasExactlyOneLeadingSlash() { + assertEquals("/Talk/a.jpg", remoteSharePath("Talk/a.jpg")) + assertEquals("/Talk/a.jpg", remoteSharePath("/Talk/a.jpg")) + assertEquals("/Talk/a.jpg", remoteSharePath("//Talk/a.jpg")) + } + + @Test + fun insideDirectory_acceptsNestedFiles() { + val dir = temp.newFolder("shared_attachments") + val nested = File(File(dir, "42").also { it.mkdirs() }, "a.jpg").also { it.writeText("x") } + assertTrue(isInsideDirectory(dir, nested)) + } + + @Test + fun insideDirectory_rejectsTraversalAndOutsideFiles() { + val dir = temp.newFolder("shared_attachments") + val outside = temp.newFile("secret.txt") + assertFalse(isInsideDirectory(dir, outside)) + assertFalse(isInsideDirectory(dir, File(dir, "../secret.txt"))) + assertFalse(isInsideDirectory(dir, dir)) + } +} From 5300d7225ddab9fa0590e8523cda1aa4bd9c85c7 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 11:28:48 +0000 Subject: [PATCH 27/29] fix(chat): run the media viewer request only once the capabilities are loaded The conversation can be set before the capabilities, and the request reads spreedCapabilities through actionsStateFor(), which threw UninitializedPropertyAccessException. The request now stays pending and runs from the initial capabilities state. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 15e7f72d95..5719265a61 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -3979,7 +3979,8 @@ class ChatActivity : */ private fun runPendingMediaRequest() { val request = pendingMediaRequest ?: return - if (currentConversation == null) { + // the conversation can arrive before the capabilities; the request waits for the initial-load state then + if (currentConversation == null || !::spreedCapabilities.isInitialized) { return } pendingMediaRequest = null From 089833910b596d42ac3dd7fbf500baf704ceaa5f Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 13:14:23 +0000 Subject: [PATCH 28/29] fix(chat): open the "show in chat" target when the conversation loads later For an already open chat whose conversation was not loaded yet, the target message stayed pending and was never opened. The joined-room branch now calls openPendingTargetMessage() next to runPendingMediaRequest(). Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 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 5719265a61..69906939f1 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/ChatActivity.kt @@ -1430,8 +1430,8 @@ class ChatActivity : /** * An intent for the chat which is already open: scrolls to the message it names and runs the request of the media - * viewer, but only when the conversation is already loaded. Otherwise the request stays pending and is run when - * the joined-room state arrives; the scroll target is not kept for that case. + * viewer, but only when the conversation is already loaded. Otherwise both stay pending and are run when the + * joined-room state arrives. */ private fun applyIntentTargetsToJoinedRoom() { if (currentConversation == null) { @@ -1712,6 +1712,8 @@ class ChatActivity : onChooseFileResult(sharedFilePaths.map { it.toUri() }) sharedFilePaths = emptyList() } + // "show in chat" for a chat which was open before its conversation was loaded + openPendingTargetMessage() runPendingMediaRequest() refreshScheduledMessages() From a0231f18cc807069561bb9abb63e390b81960974 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 18 Oct 2026 15:52:26 +0000 Subject: [PATCH 29/29] fix(chat): wait for the music of another app to stop before a video message records sound The recording asked for the audio focus and opened the microphone in the same call. The player of another app pauses some time after it lost the focus, so the first moments of a video message contained the end of the music from the speaker. The camera and the preview are bound at once, but the recording starts only when AudioManager.isMusicActive is false, for at most one second; without music there is no delay. Stopping in the meantime ends the start as before. MusicSilenceGate is the same file as in the voice message fix. Assisted-by: Claude-Code:claude-sonnet-5-5 Signed-off-by: Krainov Gleb (cherry picked from commit 86493e146ff7e496e391c40287d80ed19f380e63) --- .../talk/chat/VideoMessageRecorder.kt | 26 ++++++--- .../talk/chat/data/io/MusicSilenceGate.kt | 56 +++++++++++++++++++ 2 files changed, 75 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt 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 d978e4a353..f7c2f5ef54 100644 --- a/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt +++ b/app/src/main/java/com/nextcloud/talk/chat/VideoMessageRecorder.kt @@ -9,6 +9,7 @@ package com.nextcloud.talk.chat import android.annotation.SuppressLint import android.content.Context +import android.media.AudioManager import android.util.Log import android.view.OrientationEventListener import android.view.Surface @@ -31,6 +32,7 @@ import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleRegistry import com.nextcloud.talk.camera.rotationForDeviceOrientation +import com.nextcloud.talk.chat.data.io.MusicSilenceGate import java.io.File /** @@ -83,6 +85,9 @@ class VideoMessageRecorder(context: Context) { private var outputFile: File? = null private var lensFacing = CameraSelector.LENS_FACING_FRONT private var session = 0 + private val musicSilenceGate = MusicSilenceGate( + (context.getSystemService(Context.AUDIO_SERVICE) as AudioManager)::isMusicActive + ) /** The [Surface] rotation the video is recorded in. */ var videoRotation = Surface.ROTATION_0 @@ -269,7 +274,6 @@ class VideoMessageRecorder(context: Context) { return } startRecording() - onFrameAspectChanged?.invoke() } private fun bindUseCases(provider: ProcessCameraProvider): Boolean { @@ -289,12 +293,19 @@ class VideoMessageRecorder(context: Context) { 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 + // The microphone is opened when the music of another app is silent, which has lost the audio focus a moment + // ago. Stopping meanwhile ends the start: it takes the session away, see stop(). + val startedSession = session + musicSilenceGate.whenSilent { + if (startedSession != session || state != State.STARTING) return@whenSilent + recording = videoUseCase.output + .prepareRecording(context, options) + .withAudioEnabled() + .asPersistentRecording() + .start(ContextCompat.getMainExecutor(context), ::onRecordEvent) + state = State.RECORDING + onFrameAspectChanged?.invoke() + } } private fun onRecordEvent(event: VideoRecordEvent) { @@ -320,6 +331,7 @@ class VideoMessageRecorder(context: Context) { private fun finish(outcome: Outcome, fileToSend: File?) { val file = outputFile + musicSilenceGate.cancel() releaseCamera() recording = null outputFile = null diff --git a/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt b/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.kt new file mode 100644 index 0000000000..b9db99ab77 --- /dev/null +++ b/app/src/main/java/com/nextcloud/talk/chat/data/io/MusicSilenceGate.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.chat.data.io + +import android.os.Handler +import android.os.Looper +import android.os.SystemClock + +private const val MUSIC_SILENCE_POLL_MS = 50L +private const val MUSIC_SILENCE_MAX_WAIT_MS = 1000L + +/** + * Whether the start of a capture still has to wait for the music of another app to stop. The wait is limited: a + * player that does not react to the lost audio focus must not keep the recording from starting. + */ +fun shouldWaitForMusicToStop(isMusicActive: Boolean, waitedMs: Long, maxWaitMs: Long = MUSIC_SILENCE_MAX_WAIT_MS) = + isMusicActive && waitedMs < maxWaitMs + +/** + * Delays an action until no music is playing any more. A player pauses some time after it lost the audio focus, and + * a microphone that is opened in between records that music from the speaker. Without music the action runs at once. + */ +class MusicSilenceGate( + private val isMusicActive: () -> Boolean, + private val handler: Handler = Handler(Looper.getMainLooper()), + private val elapsedRealtime: () -> Long = SystemClock::elapsedRealtime +) { + private var pending: Runnable? = null + + fun whenSilent(onSilent: () -> Unit) { + cancel() + val startedAt = elapsedRealtime() + object : Runnable { + override fun run() { + pending = null + if (shouldWaitForMusicToStop(isMusicActive(), elapsedRealtime() - startedAt)) { + pending = this + handler.postDelayed(this, MUSIC_SILENCE_POLL_MS) + } else { + onSilent() + } + } + }.run() + } + + /** Drops the action that still waits. */ + fun cancel() { + pending?.let { handler.removeCallbacks(it) } + pending = null + } +}