From d1cd072ada697f8b8ce28fd55febd2d984e6632b Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:13:28 +0000 Subject: [PATCH 1/7] feat(chat): Record video messages from the voice message button Let the user switch the record button of the chat input between voice and video messages. Tapping the button toggles the mode (the choice is stored in the user defaults), holding it records, like for voice messages: slide to cancel, slide up to lock the recording, 60 seconds at most. Too short videos are dropped. The front and the back camera can be switched while recording. The video is recorded as H.264/AAC MP4 (1280x720 preset, portrait) with AVCaptureVideoDataOutput and AVCaptureAudioDataOutput feeding an AVAssetWriter. AVCaptureMovieFileOutput is not used: removing the video input to switch the camera tears down its connection and ends the recording, the writer is not part of the capture graph. The audio session is restored afterwards. The recording is uploaded as an ordinary file. The upload path of voice messages is shared (shareRecording), but without a temporary message and without the voice message type. File names do not clash within one second. The camera helpers (camera lookup, input replacement, rotation of the output, live preview, audio session restore) live in CameraCaptureHelpers.swift. The usage descriptions of camera and microphone mention the video recording. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../BaseChatViewController+VideoMessage.swift | 271 ++++++++++++ .../Chat/BaseChatViewController.swift | 164 ++++--- .../Chat/Camera/CameraCaptureHelpers.swift | 148 +++++++ .../ExpandedVoiceMessageRecordingView.swift | 5 + .../Chat views/VideoMessageRecorder.swift | 404 ++++++++++++++++++ NextcloudTalk/Chat/ChatViewController.swift | 3 +- NextcloudTalk/Info.plist | 4 +- NextcloudTalk/Settings/NCUserDefaults.swift | 9 + NextcloudTalk/en.lproj/InfoPlist.strings | 4 +- NextcloudTalk/en.lproj/Localizable.strings | 21 + 10 files changed, 975 insertions(+), 58 deletions(-) create mode 100644 NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift create mode 100644 NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift create mode 100644 NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift diff --git a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift new file mode 100644 index 000000000..a9199aba6 --- /dev/null +++ b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift @@ -0,0 +1,271 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import Toast +import UIKit + +/// What the record button next to an empty input field records +enum RecordButtonMode: String { + case voice + case video +} + +/// Video messages: the record button records a short video instead of a voice message when it is in +/// video mode. The gestures are the ones of voice messages, see `handleLongPressInVoiceMessageRecordButton`. +extension BaseChatViewController { + + // MARK: - Availability + + var isVoiceMessageRecordingAvailable: Bool { + return NCDatabaseManager.sharedInstance().roomHasTalkCapability(.voiceMessage, for: self.room) && !self.room.isFederated + } + + /// Videos are sent as files, which is not possible in federated conversations (just like the attachment menu), + /// and the camera is in use during a call. + var isVideoMessageRecordingAvailable: Bool { + return !self.room.isFederated + && UIImagePickerController.isSourceTypeAvailable(.camera) + && NCRoomsManager.shared.callViewController == nil + } + + /// The mode that is used for recording, as the chosen mode might not be available in this conversation + var effectiveRecordMode: RecordButtonMode { + switch (self.isVoiceMessageRecordingAvailable, self.isVideoMessageRecordingAvailable) { + case (true, false): + return .voice + case (false, true): + return .video + default: + return self.recordButtonMode + } + } + + /// With scheduled messages the button is a clock that opens them, so it can not switch the mode + private var canSwitchRecordButtonMode: Bool { + return self.isVoiceMessageRecordingAvailable && self.isVideoMessageRecordingAvailable && !self.room.hasScheduledMessages + } + + // MARK: - Mode switch + + func handleTapOnRecordButton() { + if self.canSwitchRecordButtonMode { + self.recordButtonMode = self.effectiveRecordMode == .voice ? .video : .voice + self.showVoiceMessageRecordButton() + } + + self.showRecordButtonHint() + } + + private func showRecordButtonHint() { + let hint: String + + if self.canSwitchRecordButtonMode { + if self.effectiveRecordMode == .video { + hint = NSLocalizedString("Tap and hold to record a video. Tap to switch to voice messages.", comment: "") + } else { + hint = NSLocalizedString("Tap and hold to record a voice message. Tap to switch to video messages.", comment: "") + } + } else if self.effectiveRecordMode == .video { + hint = NSLocalizedString("Tap and hold to record a video, release the button to send it.", comment: "") + } else { + self.showVoiceMessageRecordHint() + return + } + + let toastPosition = CGPoint(x: self.textInputbar.center.x, y: self.textInputbar.center.y - self.textInputbar.frame.size.height) + self.view.makeToast(hint, duration: 3, point: toastPosition, title: nil, image: nil, completion: nil) + } + + // MARK: - Gesture + + /// Starts recording in the current mode when the record button is pressed and held + func startRecordingForGesture() { + // The mode is fixed for the whole gesture + self.isVideoGestureActive = self.effectiveRecordMode == .video + + if self.isVideoGestureActive { + self.checkPermissionsAndRecordVideoMessage() + } else { + self.checkPermissionAndRecordVoiceMessage() + } + + self.setInputbarImage(UIImage(systemName: self.isVideoGestureActive ? "video" : "mic"), for: self.rightButton) + } + + /// Stops what `startRecordingForGesture` started. A voice message is handled by its recorder, which + /// sends it when it is finished, so `send` only applies to videos. + func stopRecordingForGesture(send: Bool) { + if self.isVideoGestureActive { + self.finishVideoMessageRecording(send: send) + } else { + self.stopRecordingVoiceMessage() + } + } + + // MARK: - Permissions + + func checkPermissionsAndRecordVideoMessage() { + let cameraStatus = AVCaptureDevice.authorizationStatus(for: .video) + let microphoneStatus = AVCaptureDevice.authorizationStatus(for: .audio) + + if cameraStatus == .authorized, microphoneStatus == .authorized { + self.startRecordingVideoMessage() + return + } + + if cameraStatus == .denied || cameraStatus == .restricted { + self.presentCaptureAccessDeniedAlert(title: NSLocalizedString("Could not access camera", comment: ""), + message: NSLocalizedString("Camera access is not allowed. Check your settings.", comment: "")) + return + } + + if microphoneStatus == .denied || microphoneStatus == .restricted { + self.presentCaptureAccessDeniedAlert(title: NSLocalizedString("Could not access microphone", comment: ""), + message: NSLocalizedString("Microphone access is not allowed. Check your settings.", comment: "")) + return + } + + // Like for voice messages, the recording does not start while the system asks for the permission + BaseChatViewController.requestCaptureAccessIfNeeded(for: .video) { + BaseChatViewController.requestCaptureAccessIfNeeded(for: .audio) {} + } + } + + fileprivate static func requestCaptureAccessIfNeeded(for mediaType: AVMediaType, completion: @escaping () -> Void) { + guard AVCaptureDevice.authorizationStatus(for: mediaType) == .notDetermined else { + completion() + return + } + + AVCaptureDevice.requestAccess(for: mediaType) { granted in + NSLog("Capture permission for %@ granted: %@", mediaType.rawValue, granted ? "YES" : "NO") + completion() + } + } + + private func presentCaptureAccessDeniedAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default)) + NCUserInterfaceController.sharedInstance().presentAlertViewController(alert) + } + + // MARK: - Recording + + private func startRecordingVideoMessage() { + // A recording that is still being stopped has its own recorder, so this only guards against a second start + guard self.videoMessageRecorder == nil else { return } + + // Playing a voice message and recording do not go together + self.pauseVoiceMessagePlayer() + + let interfaceOrientation = self.view.window?.windowScene?.interfaceOrientation ?? .portrait + let recorder = VideoMessageRecorder(interfaceOrientation: interfaceOrientation == .unknown ? .portrait : interfaceOrientation) + recorder.onFailure = { [weak self, weak recorder] in + guard let self, let recorder, self.videoMessageRecorder === recorder else { return } + + NCLog.log("Video message recording failed or was interrupted") + self.finishVideoMessageRecording(send: false) + self.presentVideoMessageDiscardedHint() + } + + self.videoMessageRecorder = recorder + + self.showVoiceMessageRecordingView(iconName: "video.fill") + self.showVideoMessagePreview(for: recorder) + + recorder.start { [weak self, weak recorder] success in + guard let self, let recorder, self.videoMessageRecorder === recorder else { return } + + guard success else { + NCLog.log("Could not start recording a video message") + self.finishVideoMessageRecording(send: false) + self.presentVideoMessageDiscardedHint() + return + } + + // The recording stops and is sent when the maximum duration is reached + let timer = Timer(timeInterval: VideoMessageRecorder.maxDuration, repeats: false) { [weak self] _ in + self?.finishVideoMessageRecording(send: true) + } + + RunLoop.main.add(timer, forMode: .common) + self.videoMessageLimitTimer = timer + } + } + + /// Stops recording, and sends the video unless it is cancelled or shorter than a second + func finishVideoMessageRecording(send: Bool) { + guard let recorder = self.videoMessageRecorder else { return } + + self.videoMessageRecorder = nil + self.videoMessageLimitTimer?.invalidate() + self.videoMessageLimitTimer = nil + + let isLongEnough = recorder.elapsed >= 1 + + self.videoMessagePreviewView?.removeFromSuperview() + self.videoMessagePreviewView = nil + + self.hideVoiceMessageRecordingView() + self.handleCollapseVoiceRecording() + self.resetVoiceRecordingLockButton() + self.shouldLockInterfaceOrientation(lock: false) + + // Brings back the icon of the mode, or the clock when there are scheduled messages + self.showVoiceMessageRecordButton() + + recorder.stop(keepFile: send && isLongEnough) { [weak self] fileURL in + guard let fileURL else { return } + + self?.shareVideoMessage(fileURL: fileURL) + } + } + + /// A recording that could not go on is dropped, which would otherwise be invisible + private func presentVideoMessageDiscardedHint() { + let toastPosition = CGPoint(x: self.textInputbar.center.x, y: self.textInputbar.center.y - self.textInputbar.frame.size.height) + self.view.makeToast(NSLocalizedString("Video message could not be recorded", comment: ""), duration: 3, point: toastPosition, title: nil, image: nil, completion: nil) + } + + private func showVideoMessagePreview(for recorder: VideoMessageRecorder) { + let previewView = VideoMessagePreviewView(session: recorder.session, + interfaceOrientation: recorder.interfaceOrientation, + showsSwitchCameraButton: recorder.canSwitchCamera) + previewView.translatesAutoresizingMaskIntoConstraints = false + previewView.onSwitchCamera = { [weak recorder] in + recorder?.switchCamera() + } + + self.view.addSubview(previewView) + self.videoMessagePreviewView = previewView + + NSLayoutConstraint.activate([ + previewView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + // Leaves room for the buttons of a locked recording + previewView.bottomAnchor.constraint(equalTo: self.textInputbar.topAnchor, constant: -96) + ]) + + if recorder.isLandscape { + NSLayoutConstraint.activate([ + previewView.widthAnchor.constraint(equalTo: self.view.widthAnchor, multiplier: 0.5), + previewView.heightAnchor.constraint(equalTo: previewView.widthAnchor, multiplier: 9.0 / 16.0) + ]) + } else { + NSLayoutConstraint.activate([ + previewView.heightAnchor.constraint(equalTo: self.view.heightAnchor, multiplier: 0.42), + previewView.widthAnchor.constraint(equalTo: previewView.heightAnchor, multiplier: 9.0 / 16.0) + ]) + } + } + + // MARK: - Sending + + /// Uploads the video like a voice message, without a confirmation, but as an ordinary file: it has no + /// message type, so everyone sees a regular video. + private func shareVideoMessage(fileURL: URL) { + self.shareRecording(fromPath: fileURL.path, namePrefix: "Talk video from", fileExtension: "mp4", isVoiceMessage: false) + } +} diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 5b2316df0..3a07a92c7 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -101,6 +101,16 @@ import Toast private var longPressStartingPoint: CGPoint? private var recordCancelled: Bool = false + // Video messages, see BaseChatViewController+VideoMessage.swift. The mode lives here and not in the + // button, as SlackTextViewController replaces the button state whenever the text changes. + internal var recordButtonMode = RecordButtonMode(rawValue: NCUserDefaults.preferredRecordButtonMode() ?? "") ?? .voice { + didSet { NCUserDefaults.setPreferredRecordButtonMode(recordButtonMode.rawValue) } + } + internal var isVideoGestureActive = false + internal var videoMessageRecorder: VideoMessageRecorder? + internal var videoMessagePreviewView: VideoMessagePreviewView? + internal var videoMessageLimitTimer: Timer? + private var animationDispatchGroup = DispatchGroup() private var animationDispatchQueue = DispatchQueue(label: "\(groupIdentifier).animationQueue") @@ -460,6 +470,9 @@ import Toast self.isVisible = false + // The camera is not available anymore without the view + self.finishVideoMessageRecording(send: false) + if !self.textInputbar.isHidden { self.savePendingMessage() } @@ -739,15 +752,23 @@ import Toast func showVoiceMessageRecordButton() { self.rightButton.setTitle("", for: .normal) + let isVideoMode = self.effectiveRecordMode == .video + if self.room.hasScheduledMessages { self.setInputbarImage(UIImage(systemName: "clock"), for: self.rightButton) } else { - self.setInputbarImage(UIImage(systemName: "mic"), for: self.rightButton) + self.setInputbarImage(UIImage(systemName: isVideoMode ? "video" : "mic"), for: self.rightButton) } self.rightButton.tag = sendButtonTagVoice - self.rightButton.accessibilityLabel = NSLocalizedString("Record voice message", comment: "") - self.rightButton.accessibilityHint = NSLocalizedString("Tap and hold to record a voice message", comment: "") + + if isVideoMode { + self.rightButton.accessibilityLabel = NSLocalizedString("Record video message", comment: "") + self.rightButton.accessibilityHint = NSLocalizedString("Tap and hold to record a video message", comment: "") + } else { + self.rightButton.accessibilityLabel = NSLocalizedString("Record voice message", comment: "") + self.rightButton.accessibilityHint = NSLocalizedString("Tap and hold to record a voice message", comment: "") + } self.addGestureRecognizerToRightButton() } @@ -858,7 +879,7 @@ import Toast let scheduledViewController = ScheduledMessagesChatViewController(forRoom: self.room, withAccount: self.account)! self.presentWithNavigation(scheduledViewController, animated: true) } else { - self.showVoiceMessageRecordHint() + self.handleTapOnRecordButton() } default: break @@ -869,6 +890,11 @@ import Toast // Remove a potential menu so it does not interfere with the long gesture recognizer self.rightButton.menu = nil + // Changing the mode of the record button does not replace the button, so avoid adding a second recognizer + if let voiceMessageLongPressGesture, self.rightButton.gestureRecognizers?.contains(voiceMessageLongPressGesture) == true { + return + } + // Add long press gesture recognizer for voice message recording button self.voiceMessageLongPressGesture = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPressInVoiceMessageRecordButton(gestureRecognizer:))) @@ -2017,11 +2043,13 @@ import Toast self.view.makeToast(NSLocalizedString("Tap and hold to record a voice message, release the button to send it.", comment: ""), duration: 3, point: toastPosition, title: nil, image: nil, completion: nil) } - func showVoiceMessageRecordingView() { + func showVoiceMessageRecordingView(iconName: String = "mic.fill") { self.voiceMessageRecordingView = VoiceMessageRecordingView() guard let voiceMessageRecordingView = self.voiceMessageRecordingView else { return } + voiceMessageRecordingView.recordingImageView.image = UIImage(systemName: iconName) + voiceMessageRecordingView.translatesAutoresizingMaskIntoConstraints = false self.textInputbar.addSubview(voiceMessageRecordingView) @@ -2072,9 +2100,9 @@ import Toast // MARK: - Expanded voice message recording - func showExpandedVoiceMessageRecordingView(offset: Int) { + func showExpandedVoiceMessageRecordingView(offset: Int, allowsPause: Bool = true) { let expandedView = ExpandedVoiceMessageRecordingView( - deleteFunc: handleDelete, sendFunc: handleSend, recordFunc: handleRecord(isRecording:), timeElapsed: offset + deleteFunc: handleDelete, sendFunc: handleSend, recordFunc: handleRecord(isRecording:), allowsPause: allowsPause, timeElapsed: offset ) let hostingController = UIHostingController(rootView: expandedView) @@ -2094,6 +2122,11 @@ import Toast } func handleDelete() { + if self.videoMessageRecorder != nil { + self.finishVideoMessageRecording(send: false) + return + } + self.recordCancelled = true self.stopRecordingVoiceMessage() handleCollapseVoiceRecording() @@ -2101,6 +2134,11 @@ import Toast } func handleSend() { + if self.videoMessageRecorder != nil { + self.finishVideoMessageRecording(send: true) + return + } + if let recorder = self.recorder, recorder.isRecording { self.recordCancelled = false self.stopRecordingVoiceMessage() @@ -2205,6 +2243,12 @@ import Toast self.showVoiceMessageRecordButton() guard let recorder = self.recorder else { return } + self.shareRecording(fromPath: recorder.url.path, namePrefix: "Talk recording from", fileExtension: "mp3", isVoiceMessage: true) + } + + /// Uploads a recording without a confirmation. Voice messages get a temporary message and the voice message + /// type, other recordings (videos) are ordinary files without both. + internal func shareRecording(fromPath sourcePath: String, namePrefix: String, fileExtension: String, isVoiceMessage: Bool) { let dateFormatter = DateFormatter() dateFormatter.dateFormat = "yyyy-MM-dd HH-mm-ss" let dateString = dateFormatter.string(from: Date()) @@ -2218,18 +2262,26 @@ import Toast roomString = regex.stringByReplacingMatches(in: roomString, range: .init(location: 0, length: roomString.count), withTemplate: " ") } - var audioFileName = "Talk recording from \(dateString) (\(roomString))" + var baseFileName = "\(namePrefix) \(dateString) (\(roomString))" // Trim the file name if too long - if audioFileName.count > 146 { - audioFileName = String(audioFileName.prefix(146)) + if baseFileName.count > 146 { + baseFileName = String(baseFileName.prefix(146)) } - audioFileName += ".mp3" - let chatFileController = NCChatFileController(account: self.account) let tempDirectoryURL = URL(fileURLWithPath: chatFileController.tempDirectoryPath) - let destinationFilePath = tempDirectoryURL.appendingPathComponent(audioFileName).path + + // Never replace or lose a recording made in the same second, as the temporary directory is shared + var fileName = "\(baseFileName).\(fileExtension)" + var duplicateCounter = 1 + + while FileManager.default.fileExists(atPath: tempDirectoryURL.appendingPathComponent(fileName).path) { + duplicateCounter += 1 + fileName = "\(baseFileName) (\(duplicateCounter)).\(fileExtension)" + } + + let destinationFilePath = tempDirectoryURL.appendingPathComponent(fileName).path var replyToMessage: NCChatMessage? if let replyMessageView, replyMessageView.isVisible { @@ -2237,48 +2289,55 @@ import Toast replyMessageView.dismiss() } - if let temporaryMessage = self.createTemporaryMessage( - message: audioFileName, - replyTo: replyToMessage, - messageParameters: "\(destinationFilePath)", - silently: false, - isVoiceMessage: true - ) { - let movedFileToTemporaryDirectory = chatFileController.moveFileToTemporaryDirectory( - fromSourcePath: recorder.url.path, - destinationPath: destinationFilePath + var temporaryMessage: NCChatMessage? + + if isVoiceMessage { + temporaryMessage = self.createTemporaryMessage( + message: fileName, + replyTo: replyToMessage, + messageParameters: "\(destinationFilePath)", + silently: false, + isVoiceMessage: true ) - if !movedFileToTemporaryDirectory { - print("Failed to move voice-message to temporary directory.") + if temporaryMessage == nil { + print("Temporary message could not be created") return } + } - if movedFileToTemporaryDirectory, NCDatabaseManager.sharedInstance().roomHasTalkCapability(.chatReferenceId, for: room) { - self.appendTemporaryMessage(temporaryMessage: temporaryMessage) - } + let movedFileToTemporaryDirectory = chatFileController.moveFileToTemporaryDirectory( + fromSourcePath: sourcePath, + destinationPath: destinationFilePath + ) - var metaData = ChatFileUploadMetadata() - metaData.isVoiceMessage = true - metaData.replyTo = replyToMessage?.messageId - metaData.threadId = self.thread?.threadId + if !movedFileToTemporaryDirectory { + print("Failed to move recording to temporary directory.") + return + } - // A parent living in another conversation means this is a private reply - if let replyToToken = replyToMessage?.token, replyToToken != self.room.token { - metaData.replyToToken = replyToToken - } + if let temporaryMessage, NCDatabaseManager.sharedInstance().roomHasTalkCapability(.chatReferenceId, for: room) { + self.appendTemporaryMessage(temporaryMessage: temporaryMessage) + } - var upload = ChatFileUpload(localPath: destinationFilePath, - fileName: audioFileName, - room: self.room, - account: self.account) - upload.metadata = metaData - upload.referenceId = temporaryMessage.referenceId + var metaData = ChatFileUploadMetadata() + metaData.isVoiceMessage = isVoiceMessage + metaData.replyTo = replyToMessage?.messageId + metaData.threadId = self.thread?.threadId - self.upload(upload) - } else { - print("Temporary message could not be created") + // A parent living in another conversation means this is a private reply + if let replyToToken = replyToMessage?.token, replyToToken != self.room.token { + metaData.replyToToken = replyToToken } + + var upload = ChatFileUpload(localPath: destinationFilePath, + fileName: fileName, + room: self.room, + account: self.account) + upload.metadata = metaData + upload.referenceId = temporaryMessage?.referenceId + + self.upload(upload) } func upload(_ upload: ChatFileUpload) { @@ -2496,22 +2555,23 @@ import Toast // 'Pop' feedback (strong boom) AudioServicesPlaySystemSound(1520) - self.checkPermissionAndRecordVoiceMessage() + self.startRecordingForGesture() self.shouldLockInterfaceOrientation(lock: true) self.recordCancelled = false self.longPressStartingPoint = point self.voiceRecordingLockButton.alpha = 1 - self.setInputbarImage(UIImage(systemName: "mic"), for: self.rightButton) } else if gestureRecognizer.state == .ended { self.shouldLockInterfaceOrientation(lock: false) self.resetVoiceRecordingLockButton() if !isVoiceRecordingLocked { - if let recordingTime = self.recorder?.currentTime { + if let recordingTime = self.recorder?.currentTime, !self.isVideoGestureActive { // Mark record as cancelled if audio message is no longer than one second self.recordCancelled = recordingTime < 1 } - self.stopRecordingVoiceMessage() + + // Too short videos are dropped while finishing, like short voice messages + self.stopRecordingForGesture(send: !self.recordCancelled) print("Stop recording audio message") } } else if gestureRecognizer.state == .changed { @@ -2538,7 +2598,7 @@ import Toast // 'Cancelled' feedback (three sequential weak booms) AudioServicesPlaySystemSound(1521) self.recordCancelled = true - self.stopRecordingVoiceMessage() + self.stopRecordingForGesture(send: false) self.resetVoiceRecordingLockButton() } } @@ -2550,7 +2610,7 @@ import Toast self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock"), for: .normal) let offset = self.voiceMessageRecordingView?.getTimeCounted() let intOffset = Int(offset!.magnitude) - showExpandedVoiceMessageRecordingView(offset: intOffset) + showExpandedVoiceMessageRecordingView(offset: intOffset, allowsPause: !self.isVideoGestureActive) print("LOCKED") isVoiceRecordingLocked = true } @@ -2561,7 +2621,7 @@ import Toast self.shouldLockInterfaceOrientation(lock: false) self.recordCancelled = false self.resetVoiceRecordingLockButton() - self.stopRecordingVoiceMessage() + self.stopRecordingForGesture(send: false) } } diff --git a/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift b/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift new file mode 100644 index 000000000..2c76797c9 --- /dev/null +++ b/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift @@ -0,0 +1,148 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import UIKit + +/// Code that is shared by everything in the app that captures with the camera: video messages and the in-app camera +enum CameraCaptureHelpers { + + /// The camera that is used for a position, if the device has one + static func camera(at position: AVCaptureDevice.Position) -> AVCaptureDevice? { + return AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: position) + } + + /// Replaces the video input of a session with another one, the session needs to be in a configuration block. + /// Returns the input that is part of the session afterwards, which is the old one when the new one can not be added. + static func replaceVideoInput(_ oldInput: AVCaptureDeviceInput?, with newInput: AVCaptureDeviceInput, in session: AVCaptureSession) -> AVCaptureDeviceInput? { + if let oldInput { + session.removeInput(oldInput) + } + + if session.canAddInput(newInput) { + session.addInput(newInput) + return newInput + } + + if let oldInput, session.canAddInput(oldInput) { + session.addInput(oldInput) + } + + return oldInput + } + + /// Rotates the frames of a connection so they are upright in the given orientation of the interface + static func apply(_ orientation: UIInterfaceOrientation, to connection: AVCaptureConnection) { + if #available(iOS 17.0, *) { + let angle: CGFloat + + switch orientation { + case .landscapeRight: angle = 0 + case .landscapeLeft: angle = 180 + case .portraitUpsideDown: angle = 270 + default: angle = 90 + } + + if connection.isVideoRotationAngleSupported(angle) { + connection.videoRotationAngle = angle + } + } else if connection.isVideoOrientationSupported, + let videoOrientation = AVCaptureVideoOrientation(rawValue: orientation == .unknown ? UIInterfaceOrientation.portrait.rawValue : orientation.rawValue) { + connection.videoOrientation = videoOrientation + } + } +} + +/// Live preview of a capture session, which follows the orientation of the interface +class CameraPreviewView: UIView { + + let previewLayer: AVCaptureVideoPreviewLayer + + /// The orientation the frames of the preview are rotated to + var interfaceOrientation: UIInterfaceOrientation { + didSet { + if oldValue != self.interfaceOrientation { + self.setNeedsLayout() + } + } + } + + init(session: AVCaptureSession, interfaceOrientation: UIInterfaceOrientation) { + self.interfaceOrientation = interfaceOrientation + self.previewLayer = AVCaptureVideoPreviewLayer(session: session) + + super.init(frame: .zero) + + self.backgroundColor = .black + self.clipsToBounds = true + + self.previewLayer.videoGravity = .resizeAspectFill + self.layer.addSublayer(self.previewLayer) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.previewLayer.frame = self.bounds + + if let connection = self.previewLayer.connection { + CameraCaptureHelpers.apply(self.interfaceOrientation, to: connection) + } + } +} + +/// A capture session changes the shared audio session when a microphone is part of it. This remembers +/// the audio session from before, so voice messages, audio playback and calls are not affected afterwards. +final class CaptureAudioSessionRestorer { + + private var previousCategory: AVAudioSession.Category? + private var previousMode: AVAudioSession.Mode? + private var previousOptions: AVAudioSession.CategoryOptions? + + /// Set when another app took the audio device, the audio session is not ours to touch then + var audioDeviceTakenByAnotherClient = false + + /// Needs to be called before the capture session starts + func remember() { + let audioSession = AVAudioSession.sharedInstance() + self.previousCategory = audioSession.category + self.previousMode = audioSession.mode + self.previousOptions = audioSession.categoryOptions + } + + /// Deactivates the audio session and brings back the category from before + func restore() { + // During a call the audio session belongs to the call, and so it does when another app took the device + guard !self.audioSessionIsInUseElsewhere else { + self.previousMode = nil + self.previousOptions = nil + return + } + + let audioSession = AVAudioSession.sharedInstance() + + try? audioSession.setActive(false, options: .notifyOthersOnDeactivation) + + if let category = self.previousCategory, + let mode = self.previousMode, + let options = self.previousOptions { + try? audioSession.setCategory(category, mode: mode, options: options) + } + + self.previousCategory = nil + self.previousMode = nil + self.previousOptions = nil + } + + private var audioSessionIsInUseElsewhere: Bool { + return self.audioDeviceTakenByAnotherClient + || NCRoomsManager.shared.callViewController != nil + || !CallKitManager.sharedInstance().calls.isEmpty + } +} diff --git a/NextcloudTalk/Chat/Chat views/ExpandedVoiceMessageRecordingView.swift b/NextcloudTalk/Chat/Chat views/ExpandedVoiceMessageRecordingView.swift index 760a64bfb..e511e22f7 100644 --- a/NextcloudTalk/Chat/Chat views/ExpandedVoiceMessageRecordingView.swift +++ b/NextcloudTalk/Chat/Chat views/ExpandedVoiceMessageRecordingView.swift @@ -18,6 +18,9 @@ struct ExpandedVoiceMessageRecordingView: View { var sendFunc: () -> Void var recordFunc: (Bool) -> Void + /// A video can not be paused, so the stop/restart button is not usable for it + var allowsPause = true + @State var isRecording = true @State var timeElapsed: Int @State var timeFormatted = "" @@ -58,6 +61,8 @@ struct ExpandedVoiceMessageRecordingView: View { }, label: { Label("", systemImage: isRecording ? "square.fill" : "arrow.clockwise.square").font(.title2) }) + .opacity(allowsPause ? 1 : 0) + .disabled(!allowsPause) Spacer() Button(action: { // Send Recording self.sendFunc() diff --git a/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift b/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift new file mode 100644 index 000000000..5e6f0a5de --- /dev/null +++ b/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift @@ -0,0 +1,404 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import UIKit + +/// Records a short video with sound into an MP4 file (H.264 / AAC), in the orientation of the interface it started in. +/// +/// This deliberately does not use `AVCaptureMovieFileOutput`: removing the video input of the session to +/// switch between the front and the back camera tears down the connection of that output, which ends +/// the recording. With `AVCaptureVideoDataOutput` and `AVCaptureAudioDataOutput` feeding an +/// `AVAssetWriter`, the writer is not part of the capture graph and keeps running while the camera is swapped. +final class VideoMessageRecorder: NSObject, AVCaptureVideoDataOutputSampleBufferDelegate, AVCaptureAudioDataOutputSampleBufferDelegate { + + /// A video message ends automatically after this time + static let maxDuration: TimeInterval = 60 + + /// Pixel size of the longer and the shorter side of the video, from the 1280x720 capture preset + private static let videoLongSide = 1280 + private static let videoShortSide = 720 + + /// Orientation of the interface when the recording started, it is fixed for the whole recording + let interfaceOrientation: UIInterfaceOrientation + + var isLandscape: Bool { + return self.interfaceOrientation.isLandscape + } + + let session = AVCaptureSession() + + /// Called on the main queue when the recording can not go on (session interrupted, runtime error) + var onFailure: (() -> Void)? + + /// Time of the start of the recording, only set on the main queue + private(set) var startDate: Date? + + /// Position of the camera in use, only changed on the main queue + private(set) var cameraPosition: AVCaptureDevice.Position = .front + + var elapsed: TimeInterval { + guard let startDate else { return 0 } + return Date().timeIntervalSince(startDate) + } + + let outputURL: URL + + // Session configuration and start/stop can block, so they are kept off the main queue + private let sessionQueue = DispatchQueue(label: "\(groupIdentifier).videoMessage.session") + + // Sample buffers of both outputs arrive on this queue, as does all access to the writer + private let sampleQueue = DispatchQueue(label: "\(groupIdentifier).videoMessage.samples") + + private let videoOutput = AVCaptureVideoDataOutput() + private let audioOutput = AVCaptureAudioDataOutput() + private var videoDeviceInput: AVCaptureDeviceInput? + + private var writer: AVAssetWriter? + private var writerVideoInput: AVAssetWriterInput? + private var writerAudioInput: AVAssetWriterInput? + private var sessionStartTime: CMTime? + private var isWriting = false + + // Brings back the audio session, as the capture session changes it + private let audioSessionRestorer = CaptureAudioSessionRestorer() + + init(interfaceOrientation: UIInterfaceOrientation) { + self.interfaceOrientation = interfaceOrientation + + // Every recorder has a file of its own, as the file of a previous one might still be finished + self.outputURL = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("video-message-recording-\(UUID().uuidString).mp4") + + super.init() + + NotificationCenter.default.addObserver(self, selector: #selector(sessionWasInterrupted), name: AVCaptureSession.wasInterruptedNotification, object: self.session) + NotificationCenter.default.addObserver(self, selector: #selector(sessionRuntimeError), name: AVCaptureSession.runtimeErrorNotification, object: self.session) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Start and stop + + /// Starts the camera and the recording. `completion` is called on the main queue. + func start(completion: @escaping (Bool) -> Void) { + self.audioSessionRestorer.remember() + + let initialPosition = self.cameraPosition + + self.sessionQueue.async { + guard self.configureSession(position: initialPosition), + self.sampleQueue.sync(execute: { self.prepareWriter() }) + else { + DispatchQueue.main.async { + self.audioSessionRestorer.restore() + completion(false) + } + return + } + + self.session.startRunning() + + DispatchQueue.main.async { + self.startDate = Date() + completion(true) + } + } + } + + /// Stops the camera and finishes the file. `completion` is called on the main queue, with the + /// file when `keepFile` is set and the file is complete. + func stop(keepFile: Bool, completion: ((URL?) -> Void)? = nil) { + self.sessionQueue.async { + self.session.stopRunning() + + self.sampleQueue.async { + self.isWriting = false + + let finish: (URL?) -> Void = { url in + DispatchQueue.main.async { + self.audioSessionRestorer.restore() + completion?(url) + } + } + + guard let writer = self.writer else { + finish(nil) + return + } + + self.writer = nil + + guard keepFile, writer.status == .writing else { + // Cancelling is only allowed once the writer has started, which needs a first frame + if writer.status == .writing { + writer.cancelWriting() + } + + try? FileManager.default.removeItem(at: self.outputURL) + finish(nil) + return + } + + self.writerVideoInput?.markAsFinished() + self.writerAudioInput?.markAsFinished() + + writer.finishWriting { + if writer.status == .completed { + finish(self.outputURL) + } else { + try? FileManager.default.removeItem(at: self.outputURL) + finish(nil) + } + } + } + } + } + + // MARK: - Camera switch + + /// Switches between the front and the back camera, without interrupting the recording + func switchCamera() { + let newPosition: AVCaptureDevice.Position = self.cameraPosition == .front ? .back : .front + + guard CameraCaptureHelpers.camera(at: newPosition) != nil else { return } + + self.cameraPosition = newPosition + + self.sessionQueue.async { + guard let newDevice = CameraCaptureHelpers.camera(at: newPosition), + let newInput = try? AVCaptureDeviceInput(device: newDevice) + else { return } + + self.session.beginConfiguration() + + self.videoDeviceInput = CameraCaptureHelpers.replaceVideoInput(self.videoDeviceInput, with: newInput, in: self.session) + + // The connection of the output is created again with the new input + self.configureVideoConnection() + + self.session.commitConfiguration() + } + } + + var canSwitchCamera: Bool { + return CameraCaptureHelpers.camera(at: .front) != nil && CameraCaptureHelpers.camera(at: .back) != nil + } + + // MARK: - Configuration + + private func configureSession(position: AVCaptureDevice.Position) -> Bool { + guard let camera = CameraCaptureHelpers.camera(at: position) ?? CameraCaptureHelpers.camera(at: position == .front ? .back : .front), + let microphone = AVCaptureDevice.default(for: .audio), + let cameraInput = try? AVCaptureDeviceInput(device: camera), + let microphoneInput = try? AVCaptureDeviceInput(device: microphone) + else { return false } + + self.session.beginConfiguration() + + if self.session.canSetSessionPreset(.hd1280x720) { + self.session.sessionPreset = .hd1280x720 + } + + // Keeps the camera available while the app is shown next to another one on an iPad + if self.session.isMultitaskingCameraAccessSupported { + self.session.isMultitaskingCameraAccessEnabled = true + } + + self.videoOutput.alwaysDiscardsLateVideoFrames = true + self.videoOutput.setSampleBufferDelegate(self, queue: self.sampleQueue) + self.audioOutput.setSampleBufferDelegate(self, queue: self.sampleQueue) + + guard self.session.canAddInput(cameraInput), self.session.canAddInput(microphoneInput), + self.session.canAddOutput(self.videoOutput), self.session.canAddOutput(self.audioOutput) + else { + self.session.commitConfiguration() + return false + } + + self.session.addInput(cameraInput) + self.session.addInput(microphoneInput) + self.session.addOutput(self.videoOutput) + self.session.addOutput(self.audioOutput) + self.videoDeviceInput = cameraInput + + self.configureVideoConnection() + + self.session.commitConfiguration() + + return true + } + + /// The connection is reset by the session whenever the input changes, so this needs to run after every + /// change of the camera. The recording is not mirrored, like in the system camera, while the preview of + /// the front camera is. + private func configureVideoConnection() { + guard let connection = self.videoOutput.connection(with: .video) else { return } + + CameraCaptureHelpers.apply(self.interfaceOrientation, to: connection) + + if connection.isVideoMirroringSupported { + connection.automaticallyAdjustsVideoMirroring = false + connection.isVideoMirrored = false + } + } + + /// Needs to be called on the sample queue + private func prepareWriter() -> Bool { + try? FileManager.default.removeItem(at: self.outputURL) + + guard let writer = try? AVAssetWriter(outputURL: self.outputURL, fileType: .mp4) else { return false } + + // Moves the metadata to the front of the file, so it can be played while it is being downloaded + writer.shouldOptimizeForNetworkUse = true + + let videoWidth = self.isLandscape ? Self.videoLongSide : Self.videoShortSide + let videoHeight = self.isLandscape ? Self.videoShortSide : Self.videoLongSide + + let videoSettings: [String: Any] = [ + AVVideoCodecKey: AVVideoCodecType.h264.rawValue, + AVVideoWidthKey: videoWidth, + AVVideoHeightKey: videoHeight, + AVVideoCompressionPropertiesKey: [ + AVVideoAverageBitRateKey: 2_500_000, + AVVideoProfileLevelKey: AVVideoProfileLevelH264HighAutoLevel, + AVVideoMaxKeyFrameIntervalKey: 60 + ] as [String: Any] + ] + + let audioSettings: [String: Any] = [ + AVFormatIDKey: Int(kAudioFormatMPEG4AAC), + AVSampleRateKey: 44100, + AVNumberOfChannelsKey: 1, + AVEncoderBitRateKey: 64000 + ] + + let videoInput = AVAssetWriterInput(mediaType: .video, outputSettings: videoSettings) + videoInput.expectsMediaDataInRealTime = true + + let audioInput = AVAssetWriterInput(mediaType: .audio, outputSettings: audioSettings) + audioInput.expectsMediaDataInRealTime = true + + guard writer.canAdd(videoInput), writer.canAdd(audioInput) else { return false } + + writer.add(videoInput) + writer.add(audioInput) + + self.writer = writer + self.writerVideoInput = videoInput + self.writerAudioInput = audioInput + self.sessionStartTime = nil + self.isWriting = true + + return true + } + + // MARK: - Sample buffers + + func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) { + guard self.isWriting, let writer = self.writer, CMSampleBufferDataIsReady(sampleBuffer) else { return } + + let timestamp = CMSampleBufferGetPresentationTimeStamp(sampleBuffer) + let isVideo = output === self.videoOutput + + if writer.status == .unknown { + // The file starts with the first video frame, audio before it is dropped + guard isVideo else { return } + + guard writer.startWriting() else { + self.isWriting = false + DispatchQueue.main.async { self.onFailure?() } + return + } + + writer.startSession(atSourceTime: timestamp) + self.sessionStartTime = timestamp + } + + guard writer.status == .writing, let sessionStartTime = self.sessionStartTime else { + if writer.status == .failed { + self.isWriting = false + DispatchQueue.main.async { self.onFailure?() } + } + + return + } + + if isVideo { + if let input = self.writerVideoInput, input.isReadyForMoreMediaData { + input.append(sampleBuffer) + } + } else if CMTimeCompare(timestamp, sessionStartTime) >= 0, + let input = self.writerAudioInput, input.isReadyForMoreMediaData { + input.append(sampleBuffer) + } + } + + // MARK: - Notifications + + @objc private func sessionWasInterrupted(_ notification: Notification) { + if let reasonValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int, + AVCaptureSession.InterruptionReason(rawValue: reasonValue) == .audioDeviceInUseByAnotherClient { + self.audioSessionRestorer.audioDeviceTakenByAnotherClient = true + } + + DispatchQueue.main.async { self.onFailure?() } + } + + @objc private func sessionRuntimeError(_ notification: Notification) { + DispatchQueue.main.async { self.onFailure?() } + } +} + +/// Live preview of the camera while recording a video message, with a button to switch the camera +final class VideoMessagePreviewView: CameraPreviewView { + + var onSwitchCamera: (() -> Void)? + + private lazy var switchCameraButton: UIButton = { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: "arrow.triangle.2.circlepath.camera"), for: .normal) + button.tintColor = .white + button.backgroundColor = UIColor.black.withAlphaComponent(0.4) + button.layer.cornerRadius = 18 + button.accessibilityLabel = NSLocalizedString("Switch camera", comment: "") + button.addAction(UIAction { [weak self] _ in self?.onSwitchCamera?() }, for: .touchUpInside) + + return button + }() + + init(session: AVCaptureSession, interfaceOrientation: UIInterfaceOrientation, showsSwitchCameraButton: Bool) { + super.init(session: session, interfaceOrientation: interfaceOrientation) + + self.layer.cornerRadius = 16 + self.layer.cornerCurve = .continuous + + self.addSubview(self.switchCameraButton) + self.switchCameraButton.isHidden = !showsSwitchCameraButton + + NSLayoutConstraint.activate([ + self.switchCameraButton.widthAnchor.constraint(equalToConstant: 36), + self.switchCameraButton.heightAnchor.constraint(equalToConstant: 36), + self.switchCameraButton.topAnchor.constraint(equalTo: self.topAnchor, constant: 8), + self.switchCameraButton.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -8) + ]) + + // Tapping the preview switches the camera as well, as the button can not be reached while the + // finger is still holding the record button + if showsSwitchCameraButton { + self.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(previewTapped))) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + @objc private func previewTapped() { + self.onSwitchCamera?() + } +} diff --git a/NextcloudTalk/Chat/ChatViewController.swift b/NextcloudTalk/Chat/ChatViewController.swift index 4f10a514b..186632c8d 100644 --- a/NextcloudTalk/Chat/ChatViewController.swift +++ b/NextcloudTalk/Chat/ChatViewController.swift @@ -1212,8 +1212,7 @@ import SwiftUI // If in offline mode, we don't want to show the voice button if !offlineMode, !canPress, !presentedInCall, - NCDatabaseManager.sharedInstance().roomHasTalkCapability(.voiceMessage, for: room), - !room.isFederated { + self.isVoiceMessageRecordingAvailable || self.isVideoMessageRecordingAvailable { self.showVoiceMessageRecordButton() return true diff --git a/NextcloudTalk/Info.plist b/NextcloudTalk/Info.plist index d3f03ab8e..2987f9827 100644 --- a/NextcloudTalk/Info.plist +++ b/NextcloudTalk/Info.plist @@ -53,13 +53,13 @@ LSSupportsOpeningDocumentsInPlace NSCameraUsageDescription - Camera is used in video calls + Camera is used in video calls and to record videos to share in chats NSContactsUsageDescription Contacts access is needed to match your address book contacts with contacts in your server NSLocationWhenInUseUsageDescription It is needed to share your location with others NSMicrophoneUsageDescription - Microphone is used in voice and video calls + Microphone is used in voice and video calls and to record videos to share in chats NSPhotoLibraryUsageDescription Photo Library access is needed to share pictures and videos from your device NSSpeechRecognitionUsageDescription diff --git a/NextcloudTalk/Settings/NCUserDefaults.swift b/NextcloudTalk/Settings/NCUserDefaults.swift index 275aadb42..27d7ee9c7 100644 --- a/NextcloudTalk/Settings/NCUserDefaults.swift +++ b/NextcloudTalk/Settings/NCUserDefaults.swift @@ -12,6 +12,7 @@ public class NCUserDefaults: NSObject { private static let includeCallsInRecentsKey = "ncIncludeCallsInRecents" private static let preferredCallViewModeKey = "ncPreferredCallViewMode" private static let speakerViewStripeHiddenKey = "ncSpeakerViewStripeHidden" + private static let preferredRecordButtonModeKey = "ncPreferredRecordButtonMode" public static func setPreferredCameraFlashMode(_ flashMode: Int) { UserDefaults.standard.set(flashMode, forKey: preferredCameraFlashModeKey) @@ -52,6 +53,14 @@ public class NCUserDefaults: NSObject { return UserDefaults.standard.string(forKey: preferredCallViewModeKey) } + public static func setPreferredRecordButtonMode(_ mode: String) { + UserDefaults.standard.set(mode, forKey: preferredRecordButtonModeKey) + } + + public static func preferredRecordButtonMode() -> String? { + return UserDefaults.standard.string(forKey: preferredRecordButtonModeKey) + } + public static func setSpeakerViewStripeHidden(_ hidden: Bool) { UserDefaults.standard.set(hidden, forKey: speakerViewStripeHiddenKey) } diff --git a/NextcloudTalk/en.lproj/InfoPlist.strings b/NextcloudTalk/en.lproj/InfoPlist.strings index 7b9d59999..971a1491a 100644 --- a/NextcloudTalk/en.lproj/InfoPlist.strings +++ b/NextcloudTalk/en.lproj/InfoPlist.strings @@ -1,5 +1,5 @@ -NSCameraUsageDescription = "Camera is used in video calls"; -NSMicrophoneUsageDescription = "Microphone is used in voice and video calls"; +NSCameraUsageDescription = "Camera is used in video calls and to record videos to share in chats"; +NSMicrophoneUsageDescription = "Microphone is used in voice and video calls and to record videos to share in chats"; NSPhotoLibraryUsageDescription = "Photo Library access is needed to share pictures and videos from your device"; NSContactsUsageDescription = "Contacts access is needed to match your address book contacts with contacts in your server"; NSLocationWhenInUseUsageDescription = "It is needed to share your location with others"; diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 41de0393c..bed2050be 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -2007,6 +2007,9 @@ /* No comment provided by engineer. */ "Recent threads" = "Recent threads"; +/* No comment provided by engineer. */ +"Record video message" = "Record video message"; + /* No comment provided by engineer. */ "Record voice message" = "Record voice message"; @@ -2377,6 +2380,9 @@ /* No comment provided by engineer. */ "Switch account" = "Switch account"; +/* No comment provided by engineer. */ +"Switch camera" = "Switch camera"; + /* No comment provided by engineer. */ "Switch to voice-only call" = "Switch to voice-only call"; @@ -2395,12 +2401,24 @@ /* 'Talk to Alice' - start a conversation with a user */ "Talk to %@" = "Talk to %@"; +/* No comment provided by engineer. */ +"Tap and hold to record a video message" = "Tap and hold to record a video message"; + +/* No comment provided by engineer. */ +"Tap and hold to record a video, release the button to send it." = "Tap and hold to record a video, release the button to send it."; + +/* No comment provided by engineer. */ +"Tap and hold to record a video. Tap to switch to voice messages." = "Tap and hold to record a video. Tap to switch to voice messages."; + /* No comment provided by engineer. */ "Tap and hold to record a voice message" = "Tap and hold to record a voice message"; /* No comment provided by engineer. */ "Tap and hold to record a voice message, release the button to send it." = "Tap and hold to record a voice message, release the button to send it."; +/* No comment provided by engineer. */ +"Tap and hold to record a voice message. Tap to switch to video messages." = "Tap and hold to record a voice message. Tap to switch to video messages."; + /* No comment provided by engineer. */ "Test failed" = "Test failed"; @@ -2653,6 +2671,9 @@ /* Join a call in video call mode */ "Video call" = "Video call"; +/* No comment provided by engineer. */ +"Video message could not be recorded" = "Video message could not be recorded"; + /* No comment provided by engineer. */ "Video quality" = "Video quality"; From 0fa43bbb94b3c98acba1d040bb952071a795ea2a Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:15:27 +0000 Subject: [PATCH 2/7] feat(chat): Add full screen in-app camera and media preview with editing Replace UIImagePickerController in the chat with an in-app camera, and show the photos and videos to share in a full screen preview that can be edited. Camera (NextcloudTalk/Chat/Camera): - Full screen camera with photo (tap) and video (hold) from one shutter button, flash modes (the last one is remembered), front/back camera switch. - Photos are taken at the full size of the camera and the microphone is only used while a video is recorded. - The controls follow the orientation of the device and the shutter stays at the bottom edge of the device, also on an iPad and when the device lies flat (the last held orientation is used then). - The camera is not offered during a call or when none is available. - The result is uploaded through the same confirmation as before, with the text of the input field as the caption. All of UIImagePickerController is removed from the chat. Preview (ShareExtension): - The confirmation screen shows the items in a full screen preview with a caption field, a counter, "Add more" (camera, photo library, files), crop and rotate, and drawing (QuickLook markup). - At most 10 items can be added at once. - An edited photo is stored as JPEG (PNG stays PNG), its longest side is limited to 4096 pixels. Unit tests cover the flash modes, file names, durations and the orientation logic of the camera. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../BaseChatViewController+VideoMessage.swift | 2 +- .../Chat/BaseChatViewController.swift | 66 +- .../Chat/Camera/CameraCaptureHelpers.swift | 18 + .../Camera/InAppCameraCaptureSession.swift | 447 +++++++++ .../Chat/Camera/InAppCameraSupport.swift | 183 ++++ .../Camera/InAppCameraViewController.swift | 925 ++++++++++++++++++ NextcloudTalk/Info.plist | 2 +- NextcloudTalk/en.lproj/InfoPlist.strings | 2 +- NextcloudTalk/en.lproj/Localizable.strings | 48 + .../en.lproj/Localizable.stringsdict | 32 + .../Unit/Chat/UnitInAppCameraTest.swift | 188 ++++ ShareExtension/MediaPreviewViews.swift | 136 +++ .../ShareConfirmationViewController.swift | 310 +++--- ShareExtension/ShareItemController.m | 40 +- 14 files changed, 2221 insertions(+), 178 deletions(-) create mode 100644 NextcloudTalk/Chat/Camera/InAppCameraCaptureSession.swift create mode 100644 NextcloudTalk/Chat/Camera/InAppCameraSupport.swift create mode 100644 NextcloudTalk/Chat/Camera/InAppCameraViewController.swift create mode 100644 NextcloudTalkTests/Unit/Chat/UnitInAppCameraTest.swift create mode 100644 ShareExtension/MediaPreviewViews.swift diff --git a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift index a9199aba6..f85e7930a 100644 --- a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift +++ b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift @@ -27,7 +27,7 @@ extension BaseChatViewController { /// and the camera is in use during a call. var isVideoMessageRecordingAvailable: Bool { return !self.room.isFederated - && UIImagePickerController.isSourceTypeAvailable(.camera) + && InAppCameraViewController.isCameraAvailable && NCRoomsManager.shared.callViewController == nil } diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 3a07a92c7..57c5192bb 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -16,10 +16,9 @@ import Toast @objcMembers public class BaseChatViewController: InputbarViewController, UITextFieldDelegate, - UIImagePickerControllerDelegate, UIAdaptivePresentationControllerDelegate, PHPickerViewControllerDelegate, - UINavigationControllerDelegate, + InAppCameraViewControllerDelegate, ShareLocationViewControllerDelegate, GiphyPickerViewControllerDelegate, CNContactPickerDelegate, @@ -90,8 +89,6 @@ import Toast private var isVoiceRecordingLocked = false - private var imagePicker: UIImagePickerController? - private var stopTypingTimer: Timer? private var typingTimer: Timer? private var voiceMessageLongPressGesture: UILongPressGestureRecognizer? @@ -1171,23 +1168,19 @@ import Toast func presentCamera() { DispatchQueue.main.async { - self.imagePicker = UIImagePickerController() - - if let imagePicker = self.imagePicker, - let sourceType = UIImagePickerController.availableMediaTypes(for: imagePicker.sourceType) { - imagePicker.sourceType = .camera - imagePicker.cameraFlashMode = UIImagePickerController.CameraFlashMode(rawValue: NCUserDefaults.preferredCameraFlashMode()) ?? .off - imagePicker.mediaTypes = sourceType - imagePicker.delegate = self - self.present(imagePicker, animated: true) - } + guard InAppCameraViewController.isCameraAvailable else { return } + + let camera = InAppCameraViewController() + camera.delegate = self + camera.modalPresentationStyle = .fullScreen + self.present(camera, animated: true) } } func presentPhotoLibrary() { DispatchQueue.main.async { var pickerConfig = PHPickerConfiguration() - pickerConfig.selectionLimit = 20 + pickerConfig.selectionLimit = kShareConfirmationMaxItems pickerConfig.filter = PHPickerFilter.any(of: [.images, .videos]) self.photoPicker = PHPickerViewController(configuration: pickerConfig) @@ -1923,46 +1916,27 @@ import Toast } } - // MARK: - UIImagePickerController delegate + // MARK: - InAppCameraViewController delegate - public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - self.saveImagePickerSettings(picker) - - guard let (shareConfirmationVC, navigationController) = self.createShareConfirmationViewController(), - let mediaType = info[.mediaType] as? String - else { return } + func inAppCameraViewController(_ controller: InAppCameraViewController, didCaptureMediaAt fileURL: URL) { + guard let (shareConfirmationVC, navigationController) = self.createShareConfirmationViewController() else { + try? FileManager.default.removeItem(at: fileURL) + controller.dismiss(animated: true) + return + } shareConfirmationVC.setChatMessage(self.textView.text) self.setChatMessage("") - if mediaType == "public.image" { - guard let image = info[.originalImage] as? UIImage else { return } - - self.dismiss(animated: true) { - self.present(navigationController, animated: true) { - shareConfirmationVC.shareItemController.addItem(with: image) - } - } - } else if mediaType == "public.movie" { - guard let imageUrl = info[.mediaURL] as? URL else { return } - - self.dismiss(animated: true) { - self.present(navigationController, animated: true) { - shareConfirmationVC.shareItemController.addItem(with: imageUrl) - } + controller.dismiss(animated: true) { + self.present(navigationController, animated: true) { + shareConfirmationVC.shareItemController.addItem(with: fileURL) } } } - public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - self.saveImagePickerSettings(picker) - self.dismiss(animated: true) - } - - public func saveImagePickerSettings(_ picker: UIImagePickerController) { - if picker.sourceType == .camera && picker.cameraCaptureMode == .photo { - NCUserDefaults.setPreferredCameraFlashMode(picker.cameraFlashMode.rawValue) - } + func inAppCameraViewControllerDidCancel(_ controller: InAppCameraViewController) { + controller.dismiss(animated: true) } // MARK: - UIDocumentPickerViewController Delegate diff --git a/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift b/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift index 2c76797c9..42727ee66 100644 --- a/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift +++ b/NextcloudTalk/Chat/Camera/CameraCaptureHelpers.swift @@ -53,6 +53,13 @@ enum CameraCaptureHelpers { connection.videoOrientation = videoOrientation } } + + /// The orientation of the interface a view is shown in, portrait as long as that is not known + static func interfaceOrientation(of view: UIView?) -> UIInterfaceOrientation { + let orientation = view?.window?.windowScene?.interfaceOrientation ?? .portrait + + return orientation == .unknown ? .portrait : orientation + } } /// Live preview of a capture session, which follows the orientation of the interface @@ -80,6 +87,17 @@ class CameraPreviewView: UIView { self.previewLayer.videoGravity = .resizeAspectFill self.layer.addSublayer(self.previewLayer) + + // The session is set up on another queue, so the layer has no connection to rotate before it ran + NotificationCenter.default.addObserver(self, selector: #selector(sessionDidStartRunning), name: AVCaptureSession.didStartRunningNotification, object: session) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + @objc private func sessionDidStartRunning() { + DispatchQueue.main.async { self.setNeedsLayout() } } required init?(coder: NSCoder) { diff --git a/NextcloudTalk/Chat/Camera/InAppCameraCaptureSession.swift b/NextcloudTalk/Chat/Camera/InAppCameraCaptureSession.swift new file mode 100644 index 000000000..35696fa67 --- /dev/null +++ b/NextcloudTalk/Chat/Camera/InAppCameraCaptureSession.swift @@ -0,0 +1,447 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import UIKit + +enum InAppCameraError: Error { + case noCamera + case captureFailed +} + +/// The capture side of the in-app camera: one session for photos and videos. The microphone is only part of the +/// session while a video is recorded, so the audio session of the app is not touched by just showing the camera. +/// The sound is left out when there is no access to the microphone. Every callback is called on the main queue. +final class InAppCameraCaptureSession: NSObject, AVCapturePhotoCaptureDelegate, AVCaptureFileOutputRecordingDelegate { + + let session = AVCaptureSession() + + /// Photos are taken with the shortest shutter lag, like the camera of the Android app + static let photoQualityPrioritization: AVCapturePhotoOutput.QualityPrioritization = .speed + + var onPhotoCaptured: ((Result) -> Void)? + var onRecordingStarted: (() -> Void)? + var onRecordingFinished: ((Result) -> Void)? + var onInterruptionChanged: ((_ isInterrupted: Bool) -> Void)? + var onRuntimeError: (() -> Void)? + + /// Called when the camera or the abilities of the camera changed, like the flash + var onConfigurationChanged: (() -> Void)? + + /// Position of the camera in use, only changed on the main queue + private(set) var cameraPosition: AVCaptureDevice.Position = .back + + var canSwitchCamera: Bool { + return CameraCaptureHelpers.camera(at: .front) != nil && CameraCaptureHelpers.camera(at: .back) != nil + } + + /// Whether the camera in use has a flash for photos, or a torch for videos. Read on the session queue, + /// and handed over to the main queue together with `onConfigurationChanged`. + private(set) var supportsFlash = false + + private let sessionQueue = DispatchQueue(label: "\(groupIdentifier).inAppCamera.session") + private let photoOutput = AVCapturePhotoOutput() + private let movieOutput = AVCaptureMovieFileOutput() + private var videoDeviceInput: AVCaptureDeviceInput? + private var audioDeviceInput: AVCaptureDeviceInput? + private var isConfigured = false + private var torchWasEnabled = false + private let audioSessionRestorer = CaptureAudioSessionRestorer() + + /// The audio session was remembered for a recording and needs to be brought back. Only used on the main queue. + private var audioSessionNeedsRestore = false + + /// Photos should not get bigger than what the sensors of the common devices give (12 MP, 4:3), like the + /// system camera does by default. Devices with a bigger sensor would otherwise make files of 48 MP. + private static let maxPhotoPixelCount = 12_700_000 + + override init() { + super.init() + + NotificationCenter.default.addObserver(self, selector: #selector(sessionWasInterrupted), name: AVCaptureSession.wasInterruptedNotification, object: self.session) + NotificationCenter.default.addObserver(self, selector: #selector(sessionInterruptionEnded), name: AVCaptureSession.interruptionEndedNotification, object: self.session) + NotificationCenter.default.addObserver(self, selector: #selector(sessionRuntimeError), name: AVCaptureSession.runtimeErrorNotification, object: self.session) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Start and stop + + /// Configures the session on the first call and starts it. `completion` is called on the main queue. + func start(completion: @escaping (Bool) -> Void) { + let position = self.cameraPosition + + self.sessionQueue.async { + if !self.isConfigured { + self.isConfigured = self.configureSession(position: position) + } + + guard self.isConfigured else { + DispatchQueue.main.async { completion(false) } + return + } + + if !self.session.isRunning { + self.session.startRunning() + } + + let supportsFlash = self.readSupportsFlash() + + DispatchQueue.main.async { + self.supportsFlash = supportsFlash + self.onConfigurationChanged?() + completion(true) + } + } + } + + /// Stops the session, a recording that is going on is finished and its file is delivered + func stop() { + self.sessionQueue.async { + if self.movieOutput.isRecording { + self.movieOutput.stopRecording() + } + + self.setTorch(enabled: false) + self.removeMicrophone() + self.session.stopRunning() + + DispatchQueue.main.async { + self.restoreAudioSessionIfNeeded() + } + } + } + + // MARK: - Camera switch + + /// Switches between the front and the back camera, not possible while recording + func switchCamera() { + let newPosition: AVCaptureDevice.Position = self.cameraPosition == .front ? .back : .front + + guard CameraCaptureHelpers.camera(at: newPosition) != nil else { return } + + self.cameraPosition = newPosition + + self.sessionQueue.async { + guard !self.movieOutput.isRecording, + let newDevice = CameraCaptureHelpers.camera(at: newPosition), + let newInput = try? AVCaptureDeviceInput(device: newDevice) + else { return } + + self.session.beginConfiguration() + self.videoDeviceInput = CameraCaptureHelpers.replaceVideoInput(self.videoDeviceInput, with: newInput, in: self.session) + self.session.commitConfiguration() + + // The sizes of photos depend on the camera + self.useLargestPhotoDimensions() + + let supportsFlash = self.readSupportsFlash() + + DispatchQueue.main.async { + self.supportsFlash = supportsFlash + self.onConfigurationChanged?() + } + } + } + + // MARK: - Configuration + + private func configureSession(position: AVCaptureDevice.Position) -> Bool { + guard let camera = CameraCaptureHelpers.camera(at: position) ?? CameraCaptureHelpers.camera(at: position == .front ? .back : .front), + let cameraInput = try? AVCaptureDeviceInput(device: camera) + else { return false } + + self.session.beginConfiguration() + + // Needed to add the outputs, the preset for photos is set once they are part of the session + if self.session.canSetSessionPreset(.high) { + self.session.sessionPreset = .high + } + + // Keeps the camera available while the app is shown next to another one on an iPad + if self.session.isMultitaskingCameraAccessSupported { + self.session.isMultitaskingCameraAccessEnabled = true + } + + self.movieOutput.maxRecordedDuration = CMTime(seconds: InAppCameraSupport.maxVideoDuration, preferredTimescale: 600) + + guard self.session.canAddInput(cameraInput), self.session.canAddOutput(self.photoOutput), self.session.canAddOutput(self.movieOutput) else { + self.session.commitConfiguration() + return false + } + + self.session.addInput(cameraInput) + self.session.addOutput(self.photoOutput) + self.session.addOutput(self.movieOutput) + self.videoDeviceInput = cameraInput + + self.session.commitConfiguration() + + // The preset for photos gives the full 4:3 picture of the sensor, the one for videos is set while recording + self.setPreset(.photo) + + DispatchQueue.main.async { + self.cameraPosition = cameraInput.device.position + } + + return true + } + + /// Needs to be called on the session queue + private func setPreset(_ preset: AVCaptureSession.Preset) { + guard self.session.sessionPreset != preset, self.session.canSetSessionPreset(preset) else { return } + + self.session.beginConfiguration() + self.session.sessionPreset = preset + self.session.commitConfiguration() + + if preset == .photo { + self.useLargestPhotoDimensions() + } + } + + /// Lets the photo output deliver the biggest photos the active format of the camera can do, up to + /// `maxPhotoPixelCount`. Needs to be called on the session queue. + private func useLargestPhotoDimensions() { + guard let supported = self.videoDeviceInput?.device.activeFormat.supportedMaxPhotoDimensions, !supported.isEmpty else { return } + + func pixelCount(_ dimensions: CMVideoDimensions) -> Int { + return Int(dimensions.width) * Int(dimensions.height) + } + + let fitting = supported.filter { pixelCount($0) <= Self.maxPhotoPixelCount } + let wanted = fitting.max { pixelCount($0) < pixelCount($1) } ?? supported.min { pixelCount($0) < pixelCount($1) } + + guard let wanted, wanted.width != self.photoOutput.maxPhotoDimensions.width || wanted.height != self.photoOutput.maxPhotoDimensions.height else { return } + + self.session.beginConfiguration() + self.photoOutput.maxPhotoDimensions = wanted + self.session.commitConfiguration() + } + + /// Needs to be called on the session queue + private func readSupportsFlash() -> Bool { + return !self.photoOutput.supportedFlashModes.isEmpty || (self.videoDeviceInput?.device.hasTorch ?? false) + } + + /// Needs to be called on the session queue + private func addMicrophoneIfAllowed() -> Bool { + guard self.audioDeviceInput == nil, + AVCaptureDevice.authorizationStatus(for: .audio) == .authorized, + let microphone = AVCaptureDevice.default(for: .audio), + let microphoneInput = try? AVCaptureDeviceInput(device: microphone) + else { return false } + + self.session.beginConfiguration() + defer { self.session.commitConfiguration() } + + guard self.session.canAddInput(microphoneInput) else { return false } + + self.session.addInput(microphoneInput) + self.audioDeviceInput = microphoneInput + + return true + } + + /// Takes the microphone out of the session again, so the session leaves the audio session alone. + /// Needs to be called on the session queue. + private func removeMicrophone() { + guard let microphoneInput = self.audioDeviceInput else { return } + + self.session.beginConfiguration() + self.session.removeInput(microphoneInput) + self.session.commitConfiguration() + + self.audioDeviceInput = nil + } + + /// Needs to be called on the main queue + private func restoreAudioSessionIfNeeded() { + guard self.audioSessionNeedsRestore else { return } + + self.audioSessionNeedsRestore = false + self.audioSessionRestorer.restore() + } + + /// The connection is reset by the session whenever the input changes, so the rotation and the mirroring + /// are set for every capture. Like in the system camera, the picture of the front camera is not mirrored, + /// while the preview of it is. + private func prepare(_ connection: AVCaptureConnection?, orientation: UIInterfaceOrientation) { + guard let connection else { return } + + CameraCaptureHelpers.apply(orientation, to: connection) + + if connection.isVideoMirroringSupported { + connection.automaticallyAdjustsVideoMirroring = false + connection.isVideoMirrored = false + } + } + + // MARK: - Photo + + func capturePhoto(flashMode: InAppCameraFlashMode, orientation: UIInterfaceOrientation) { + self.sessionQueue.async { + guard self.session.isRunning else { + DispatchQueue.main.async { self.onPhotoCaptured?(.failure(InAppCameraError.captureFailed)) } + return + } + + let settings: AVCapturePhotoSettings + + if self.photoOutput.availablePhotoCodecTypes.contains(.jpeg) { + settings = AVCapturePhotoSettings(format: [AVVideoCodecKey: AVVideoCodecType.jpeg]) + } else { + settings = AVCapturePhotoSettings() + } + + if self.photoOutput.supportedFlashModes.contains(flashMode.captureFlashMode) { + settings.flashMode = flashMode.captureFlashMode + } + + if self.photoOutput.maxPhotoDimensions.width > 0 { + settings.maxPhotoDimensions = self.photoOutput.maxPhotoDimensions + } + + if Self.photoQualityPrioritization.rawValue <= self.photoOutput.maxPhotoQualityPrioritization.rawValue { + settings.photoQualityPrioritization = Self.photoQualityPrioritization + } + + self.prepare(self.photoOutput.connection(with: .video), orientation: orientation) + self.photoOutput.capturePhoto(with: settings, delegate: self) + } + } + + func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { + // The data is written with the orientation of the connection as EXIF, so the photo is upright everywhere + guard error == nil, let data = photo.fileDataRepresentation() else { + DispatchQueue.main.async { self.onPhotoCaptured?(.failure(error ?? InAppCameraError.captureFailed)) } + return + } + + let isJPEG = self.photoOutput.availablePhotoCodecTypes.contains(.jpeg) + let url = InAppCameraSupport.makeFileURL(for: .photo(isJPEG: isJPEG)) + + do { + try data.write(to: url, options: .atomic) + DispatchQueue.main.async { self.onPhotoCaptured?(.success(url)) } + } catch { + DispatchQueue.main.async { self.onPhotoCaptured?(.failure(error)) } + } + } + + // MARK: - Video + + /// Records with sound if the access to the microphone was allowed, a video without sound is better than no video + func startRecording(flashMode: InAppCameraFlashMode, orientation: UIInterfaceOrientation) { + let withSound = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized + + if withSound, !self.audioSessionNeedsRestore { + self.audioSessionRestorer.remember() + self.audioSessionNeedsRestore = true + } + + self.sessionQueue.async { + guard self.session.isRunning, !self.movieOutput.isRecording else { + DispatchQueue.main.async { + self.restoreAudioSessionIfNeeded() + self.onRecordingFinished?(.failure(InAppCameraError.captureFailed)) + } + return + } + + // The preset for photos can not record videos + self.setPreset(.high) + + if withSound { + _ = self.addMicrophoneIfAllowed() + } + + self.prepare(self.movieOutput.connection(with: .video), orientation: orientation) + self.setTorch(enabled: flashMode == .on) + + self.movieOutput.startRecording(to: InAppCameraSupport.makeFileURL(for: .video), recordingDelegate: self) + } + } + + func stopRecording() { + self.sessionQueue.async { + if self.movieOutput.isRecording { + self.movieOutput.stopRecording() + } + } + } + + /// Needs to be called on the session queue + private func setTorch(enabled: Bool) { + if !enabled && !self.torchWasEnabled { + return + } + + guard let device = self.videoDeviceInput?.device, device.hasTorch, (try? device.lockForConfiguration()) != nil else { return } + + device.torchMode = enabled ? .on : .off + device.unlockForConfiguration() + + self.torchWasEnabled = enabled + } + + func fileOutput(_ output: AVCaptureFileOutput, didStartRecordingTo fileURL: URL, from connections: [AVCaptureConnection]) { + DispatchQueue.main.async { self.onRecordingStarted?() } + } + + func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { + // The recording can end with an error although the file is complete, like when the time is up + let finishedSuccessfully = (error as NSError?)?.userInfo[AVErrorRecordingSuccessfullyFinishedKey] as? Bool ?? (error == nil) + + self.sessionQueue.async { + self.setTorch(enabled: false) + self.removeMicrophone() + self.setPreset(.photo) + + DispatchQueue.main.async { + self.restoreAudioSessionIfNeeded() + } + } + + if finishedSuccessfully { + DispatchQueue.main.async { self.onRecordingFinished?(.success(outputFileURL)) } + } else { + try? FileManager.default.removeItem(at: outputFileURL) + DispatchQueue.main.async { self.onRecordingFinished?(.failure(error ?? InAppCameraError.captureFailed)) } + } + } + + // MARK: - Notifications + + @objc private func sessionWasInterrupted(_ notification: Notification) { + if let reasonValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int, + AVCaptureSession.InterruptionReason(rawValue: reasonValue) == .audioDeviceInUseByAnotherClient { + self.audioSessionRestorer.audioDeviceTakenByAnotherClient = true + } + + DispatchQueue.main.async { self.onInterruptionChanged?(true) } + } + + @objc private func sessionInterruptionEnded(_ notification: Notification) { + DispatchQueue.main.async { self.onInterruptionChanged?(false) } + } + + @objc private func sessionRuntimeError(_ notification: Notification) { + let error = notification.userInfo?[AVCaptureSessionErrorKey] as? AVError + + // After a reset of the media services the session needs to be started again + if error?.code == .mediaServicesWereReset { + self.sessionQueue.async { + if !self.session.isRunning { + self.session.startRunning() + } + } + } + + DispatchQueue.main.async { self.onRuntimeError?() } + } +} diff --git a/NextcloudTalk/Chat/Camera/InAppCameraSupport.swift b/NextcloudTalk/Chat/Camera/InAppCameraSupport.swift new file mode 100644 index 000000000..0be752478 --- /dev/null +++ b/NextcloudTalk/Chat/Camera/InAppCameraSupport.swift @@ -0,0 +1,183 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import Foundation +import UIKit + +/// The flash mode of the in-app camera. The raw values are the ones of `UIImagePickerController.CameraFlashMode`, +/// so the mode that is stored in `NCUserDefaults` is shared with the camera of the system. +enum InAppCameraFlashMode: Int, CaseIterable { + case off = -1 + case auto = 0 + case on = 1 + + /// The stored value, a value that is not known switches the flash off like the system camera does here + init(storedValue: Int) { + self = InAppCameraFlashMode(rawValue: storedValue) ?? .off + } + + /// The mode a tap on the flash button switches to + var next: InAppCameraFlashMode { + switch self { + case .off: return .auto + case .auto: return .on + case .on: return .off + } + } + + var captureFlashMode: AVCaptureDevice.FlashMode { + switch self { + case .off: return .off + case .auto: return .auto + case .on: return .on + } + } + + var symbolName: String { + switch self { + case .off: return "bolt.slash.fill" + case .auto: return "bolt.badge.a.fill" + case .on: return "bolt.fill" + } + } + + var accessibilityLabel: String { + switch self { + case .off: return NSLocalizedString("Flash off", comment: "") + case .auto: return NSLocalizedString("Flash auto", comment: "") + case .on: return NSLocalizedString("Flash on", comment: "") + } + } +} + +enum InAppCameraMediaKind { + case photo(isJPEG: Bool) + case video + + var fileExtension: String { + switch self { + case .photo(let isJPEG): return isJPEG ? "jpg" : "heic" + case .video: return "mov" + } + } +} + +/// An edge of the body of the device or of the screen, in clockwise order +enum InAppCameraEdge: Int { + case top = 0 + case right = 1 + case bottom = 2 + case left = 3 + + /// The direction that points out of the screen over this edge, as steps in x and y (y grows downwards) + var outwardDirection: (dx: Int, dy: Int) { + switch self { + case .top: return (0, -1) + case .right: return (1, 0) + case .bottom: return (0, 1) + case .left: return (-1, 0) + } + } +} + +enum InAppCameraSupport { + + /// A recording ends by itself after this time + static let maxVideoDuration: TimeInterval = 300 + + /// A new file in the temporary directory. Every capture has a file of its own, the owner of the file is + /// whoever receives it from the camera. + static func makeFileURL(for kind: InAppCameraMediaKind, directory: String = NSTemporaryDirectory(), uuid: UUID = UUID()) -> URL { + return URL(fileURLWithPath: directory) + .appendingPathComponent("in-app-camera-\(uuid.uuidString).\(kind.fileExtension)") + } + + /// The orientation to capture in. It is the one the device is held in, so a photo is upright even when the + /// rotation of the interface is locked. Without a usable device orientation (flat on a table) it is the one + /// of the interface. + static func captureOrientation(device: UIDeviceOrientation, interface: UIInterfaceOrientation) -> UIInterfaceOrientation { + switch device { + case .portrait: return .portrait + case .portraitUpsideDown: return .portraitUpsideDown + // The device and the interface are rotated the other way round in landscape + case .landscapeLeft: return .landscapeRight + case .landscapeRight: return .landscapeLeft + default: return interface == .unknown ? .portrait : interface + } + } + + /// The orientation the device is held in: the current one, or the last one that was usable while the device + /// lies flat or has no orientation. The interface can not stand in for it when it is locked to portrait. + static func heldOrientation(current: UIDeviceOrientation, last: UIDeviceOrientation) -> UIDeviceOrientation { + switch current { + case .portrait, .portraitUpsideDown, .landscapeLeft, .landscapeRight: return current + default: return last + } + } + + /// How often the body of the device is turned clockwise against the interface: 0 in portrait, 1 with the home + /// button on the left (`.landscapeLeft`), 2 upside down, 3 with the home button on the right + static func interfaceQuarterTurns(_ interface: UIInterfaceOrientation) -> Int { + switch interface { + case .landscapeLeft: return 1 + case .portraitUpsideDown: return 2 + case .landscapeRight: return 3 + default: return 0 + } + } + + /// The edge of the screen an edge of the body of the device (as in portrait, with the home button at the bottom) + /// is at, when the interface has the given orientation + static func screenEdge(ofBodyEdge edge: InAppCameraEdge, interface: UIInterfaceOrientation) -> InAppCameraEdge { + return InAppCameraEdge(rawValue: (edge.rawValue + self.interfaceQuarterTurns(interface)) % 4) ?? edge + } + + /// The angle in degrees (0, 90, 180 or 270, clockwise) the icons of the camera are rotated by, so they are upright + /// for the person who holds the device. It is the rotation of the device against the interface. + /// Nil when the device has no usable orientation (flat on a table), the icons stay as they are then. + static func iconRotationDegrees(device: UIDeviceOrientation, interface: UIInterfaceOrientation) -> Int? { + let deviceTurns: Int + + switch device { + case .portrait: deviceTurns = 0 + // The home button is on the left, so the device is turned clockwise + case .landscapeRight: deviceTurns = 1 + case .portraitUpsideDown: deviceTurns = 2 + case .landscapeLeft: deviceTurns = 3 + default: return nil + } + + return (((self.interfaceQuarterTurns(interface) - deviceTurns) % 4) + 4) % 4 * 90 + } + + /// The angle to animate the icons to, an angle that looks the same as the target but is the shortest way from the + /// current one. So 0 to 270 goes through -90 and not around the full circle. + static func shortestRotationTarget(current: Double, target: Int) -> Double { + var delta = (Double(target) - current).truncatingRemainder(dividingBy: 360) + + if delta > 180 { + delta -= 360 + } else if delta <= -180 { + delta += 360 + } + + return current + delta + } + + /// The time of a recording like "0:07" or "12:34", or "1:02:03" after an hour + static func formattedDuration(_ duration: TimeInterval) -> String { + let total = max(0, Int(duration)) + let hours = total / 3600 + let minutes = (total % 3600) / 60 + let seconds = total % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + + return String(format: "%d:%02d", minutes, seconds) + } +} diff --git a/NextcloudTalk/Chat/Camera/InAppCameraViewController.swift b/NextcloudTalk/Chat/Camera/InAppCameraViewController.swift new file mode 100644 index 000000000..4bd1955d6 --- /dev/null +++ b/NextcloudTalk/Chat/Camera/InAppCameraViewController.swift @@ -0,0 +1,925 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import Toast +import UIKit + +@MainActor +protocol InAppCameraViewControllerDelegate: AnyObject { + /// A photo (.jpg or .heic) or a video (.mov) was captured. The file is in the temporary directory and belongs to the delegate. + func inAppCameraViewController(_ controller: InAppCameraViewController, didCaptureMediaAt fileURL: URL) + + /// The camera was closed without capturing anything + func inAppCameraViewControllerDidCancel(_ controller: InAppCameraViewController) +} + +/// Full screen camera: a tap on the shutter button takes a photo, holding it records a video with sound. +/// The microphone is only asked for and used when the first video is recorded. +/// The camera does not close itself, that is up to the delegate. +final class InAppCameraViewController: UIViewController { + + weak var delegate: InAppCameraViewControllerDelegate? + + /// Whether the device has a camera + static var isCameraAvailable: Bool { + return AVCaptureDevice.default(for: .video) != nil + } + + // How long the shutter button needs to be held to record a video instead of taking a photo + private static let holdDuration: TimeInterval = 0.35 + + private let captureSession = InAppCameraCaptureSession() + + private var flashMode = InAppCameraFlashMode(storedValue: NCUserDefaults.preferredCameraFlashMode()) + + // State of the shutter button + private var holdWorkItem: DispatchWorkItem? + private var isRecordingRequested = false + private var isCapturingPhoto = false + private var discardsRecording = false + private var recordingStartDate: Date? + private var recordingTimer: Timer? + + private var isSessionRunning = false + private var isSessionStarted = false + private var isCaptureAvailable = false + private var isShown = false + private var isClosing = false + + // The layout of the buttons follows the body of the device, see `updateBodyLayout` + private var bodyConstraints: [NSLayoutConstraint] = [] + private var layoutOrientation: UIInterfaceOrientation? + + // The angle the icons are rotated to, it is not limited to a circle so the animation takes the short way + private var iconAngle: Double = 0 + + // The last orientation the device was held in, for when it lies flat. Interface is portrait on the phone, so + // the interface can not tell how the device is held. + private var lastHeldOrientation: UIDeviceOrientation = .unknown + + // Set while the interface rotates, the icons are turned when it is done + private var isInterfaceTransitioning = false + private var iconRotationWorkItem: DispatchWorkItem? + + // MARK: - Views + + private lazy var previewView: CameraPreviewView = CameraPreviewView(session: self.captureSession.session, interfaceOrientation: .portrait) + + private lazy var closeButton: UIButton = self.makeCircleButton(symbolName: "xmark", accessibilityLabel: NSLocalizedString("Close", comment: "")) { [weak self] in + self?.closeTapped() + } + + private lazy var flashButton: UIButton = self.makeCircleButton(symbolName: self.flashMode.symbolName, accessibilityLabel: self.flashMode.accessibilityLabel) { [weak self] in + self?.flashTapped() + } + + private lazy var switchCameraButton: UIButton = self.makeCircleButton(symbolName: "arrow.triangle.2.circlepath.camera", accessibilityLabel: NSLocalizedString("Switch camera", comment: "")) { [weak self] in + self?.switchCameraTapped() + } + + private let shutterView = InAppCameraShutterView() + + private lazy var timerLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.monospacedDigitSystemFont(ofSize: 15, weight: .semibold) + label.textColor = .white + label.textAlignment = .center + label.backgroundColor = UIColor.systemRed + label.layer.cornerRadius = 12 + label.layer.masksToBounds = true + label.isHidden = true + label.text = InAppCameraSupport.formattedDuration(0) + + return label + }() + + private lazy var hintLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .footnote) + label.adjustsFontForContentSizeCategory = true + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 0 + label.text = NSLocalizedString("Tap for photo, hold for video", comment: "") + + return label + }() + + private lazy var statusLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.textColor = .white + label.textAlignment = .center + label.numberOfLines = 0 + + return label + }() + + private lazy var settingsButton: UIButton = { + let button = UIButton(type: .system) + button.setTitle(NSLocalizedString("Settings", comment: ""), for: .normal) + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .headline) + button.addAction(UIAction { _ in + if let settingsURL = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(settingsURL) + } + }, for: .touchUpInside) + + return button + }() + + private lazy var statusStackView: UIStackView = { + let stackView = UIStackView(arrangedSubviews: [self.statusLabel, self.settingsButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = 12 + stackView.isHidden = true + + return stackView + }() + + private lazy var flashOverlayView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = .white + view.alpha = 0 + view.isUserInteractionEnabled = false + + return view + }() + + // MARK: - Lifecycle + + init() { + super.init(nibName: nil, bundle: nil) + + self.modalPresentationStyle = .fullScreen + self.overrideUserInterfaceStyle = .dark + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = .black + + self.setupViews() + self.setupCaptureCallbacks() + self.updateFlashButton() + self.updateControls() + + self.updateBodyLayout() + + NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(deviceOrientationDidChange), name: UIDevice.orientationDidChangeNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + self.isShown = true + + UIDevice.current.beginGeneratingDeviceOrientationNotifications() + self.updatePreviewOrientation() + self.updateBodyLayout() + self.updateIconRotation(animated: false) + self.evaluateAvailability() + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + self.isShown = false + + UIDevice.current.endGeneratingDeviceOrientationNotifications() + self.holdWorkItem?.cancel() + self.holdWorkItem = nil + self.stopSession() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + self.updatePreviewOrientation() + self.updateBodyLayout() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + self.isInterfaceTransitioning = true + + // The orientation of the interface is the new one at the latest when the transition is done + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.isInterfaceTransitioning = false + self?.updatePreviewOrientation() + self?.updateBodyLayout() + self?.updateIconRotation(animated: false) + } + } + + override var prefersStatusBarHidden: Bool { + return true + } + + /// The interface stays in portrait on the phone, only the icons turn with the device. The iPad has to rotate for + /// multitasking, there the buttons are laid out along the body of the device instead. + override var supportedInterfaceOrientations: UIInterfaceOrientationMask { + return UIDevice.current.userInterfaceIdiom == .pad ? .all : .portrait + } + + override var preferredInterfaceOrientationForPresentation: UIInterfaceOrientation { + return UIDevice.current.userInterfaceIdiom == .pad ? super.preferredInterfaceOrientationForPresentation : .portrait + } + + // MARK: - Setup + + private func setupViews() { + self.previewView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.previewView) + self.view.addSubview(self.flashOverlayView) + self.view.addSubview(self.statusStackView) + self.view.addSubview(self.hintLabel) + self.view.addSubview(self.shutterView) + self.view.addSubview(self.closeButton) + self.view.addSubview(self.flashButton) + self.view.addSubview(self.switchCameraButton) + self.view.addSubview(self.timerLabel) + + self.shutterView.translatesAutoresizingMaskIntoConstraints = false + self.shutterView.accessibilityLabel = NSLocalizedString("Take photo", comment: "") + self.shutterView.accessibilityHint = NSLocalizedString("Tap for photo, hold for video", comment: "") + self.shutterView.onAccessibilityActivate = { [weak self] in + guard let self else { return } + + if self.isRecordingRequested { + self.stopRecording() + } else { + self.takePhoto() + } + } + + let longPress = UILongPressGestureRecognizer(target: self, action: #selector(shutterGestureChanged)) + // A press is a photo or a video depending on its length, no matter how the finger moves + longPress.minimumPressDuration = 0 + longPress.allowableMovement = .greatestFiniteMagnitude + self.shutterView.addGestureRecognizer(longPress) + + // Like in the apps of the platform, a double tap on the picture switches the camera + let doubleTap = UITapGestureRecognizer(target: self, action: #selector(previewDoubleTapped)) + doubleTap.numberOfTapsRequired = 2 + self.previewView.addGestureRecognizer(doubleTap) + + let safeArea = self.view.safeAreaLayoutGuide + + NSLayoutConstraint.activate([ + self.previewView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.previewView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.previewView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.previewView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + + self.flashOverlayView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.flashOverlayView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.flashOverlayView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.flashOverlayView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + + // The place of the timer, the buttons and the hint is set in updateBodyLayout + self.timerLabel.heightAnchor.constraint(equalToConstant: 24), + self.timerLabel.widthAnchor.constraint(greaterThanOrEqualToConstant: 64), + + self.shutterView.widthAnchor.constraint(equalToConstant: InAppCameraShutterView.size), + self.shutterView.heightAnchor.constraint(equalToConstant: InAppCameraShutterView.size), + + self.hintLabel.leadingAnchor.constraint(greaterThanOrEqualTo: safeArea.leadingAnchor, constant: 16), + self.hintLabel.trailingAnchor.constraint(lessThanOrEqualTo: safeArea.trailingAnchor, constant: -16), + + self.statusStackView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + self.statusStackView.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + self.statusStackView.leadingAnchor.constraint(greaterThanOrEqualTo: safeArea.leadingAnchor, constant: 32), + self.statusStackView.trailingAnchor.constraint(lessThanOrEqualTo: safeArea.trailingAnchor, constant: -32) + ]) + } + + // MARK: - Layout along the body of the device + + /// Lays out the buttons along the body of the device: the shutter at the natural bottom (the edge with the + /// home button), the switch of the camera next to it, close and flash at the natural top. On the phone the interface + /// is always in portrait, so this is the normal layout there. On the iPad the interface rotates, so the + /// edges of the body are moved to the edges of the screen they are at. + private func updateBodyLayout() { + let orientation = CameraCaptureHelpers.interfaceOrientation(of: self.view) + + guard orientation != self.layoutOrientation else { return } + + self.layoutOrientation = orientation + + let bodyEdge = { (edge: InAppCameraEdge) in InAppCameraSupport.screenEdge(ofBodyEdge: edge, interface: orientation) } + let shutterEdge = bodyEdge(.bottom) + let topEdge = bodyEdge(.top) + let sideOfSwitch = bodyEdge(.right).outwardDirection + let towardsTop = topEdge.outwardDirection + + // The center of the switch is the radius of the shutter, the space and the radius of the switch away + let switchDistance = InAppCameraShutterView.size / 2 + 40 + 22 + + var constraints = [ + self.pinConstraint(self.shutterView, to: shutterEdge, inset: 24), + self.centerAlongConstraint(self.shutterView, edge: shutterEdge), + + self.switchCameraButton.centerXAnchor.constraint(equalTo: self.shutterView.centerXAnchor, constant: CGFloat(sideOfSwitch.dx) * switchDistance), + self.switchCameraButton.centerYAnchor.constraint(equalTo: self.shutterView.centerYAnchor, constant: CGFloat(sideOfSwitch.dy) * switchDistance), + + self.pinConstraint(self.closeButton, to: topEdge, inset: 12), + self.pinConstraint(self.closeButton, to: bodyEdge(.left), inset: 16), + self.pinConstraint(self.flashButton, to: topEdge, inset: 12), + self.pinConstraint(self.flashButton, to: bodyEdge(.right), inset: 16), + + // The timer is on the line of the centers of the buttons, at any edge + self.centerAlongConstraint(self.timerLabel, edge: topEdge), + self.centerAcrossConstraint(self.timerLabel, edge: topEdge, like: self.closeButton), + + self.centerAlongConstraint(self.hintLabel, edge: shutterEdge) + ] + + // The hint is on the side of the shutter that points to the top of the body + if towardsTop.dy < 0 { + constraints.append(self.hintLabel.bottomAnchor.constraint(equalTo: self.shutterView.topAnchor, constant: -16)) + } else if towardsTop.dy > 0 { + constraints.append(self.hintLabel.topAnchor.constraint(equalTo: self.shutterView.bottomAnchor, constant: 16)) + } else if towardsTop.dx > 0 { + constraints.append(self.hintLabel.leftAnchor.constraint(equalTo: self.shutterView.rightAnchor, constant: 16)) + } else { + constraints.append(self.hintLabel.rightAnchor.constraint(equalTo: self.shutterView.leftAnchor, constant: -16)) + } + + NSLayoutConstraint.deactivate(self.bodyConstraints) + NSLayoutConstraint.activate(constraints) + self.bodyConstraints = constraints + + self.updateIconRotation(animated: false) + } + + /// Fixes a view at a distance from an edge of the safe area. Left and right are the sides of the screen, they do + /// not turn around in a language that is written from the right, as the body of the device does not either. + private func pinConstraint(_ view: UIView, to edge: InAppCameraEdge, inset: CGFloat) -> NSLayoutConstraint { + let safeArea = self.view.safeAreaLayoutGuide + + switch edge { + case .top: return view.topAnchor.constraint(equalTo: safeArea.topAnchor, constant: inset) + case .bottom: return view.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor, constant: -inset) + case .left: return view.leftAnchor.constraint(equalTo: safeArea.leftAnchor, constant: inset) + case .right: return view.rightAnchor.constraint(equalTo: safeArea.rightAnchor, constant: -inset) + } + } + + /// Puts a view in the middle of the screen along an edge + private func centerAlongConstraint(_ view: UIView, edge: InAppCameraEdge) -> NSLayoutConstraint { + switch edge { + case .top, .bottom: return view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor) + case .left, .right: return view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor) + } + } + + /// Puts a view on the line of the center of another one, across the direction of an edge + private func centerAcrossConstraint(_ view: UIView, edge: InAppCameraEdge, like other: UIView) -> NSLayoutConstraint { + switch edge { + case .top, .bottom: return view.centerYAnchor.constraint(equalTo: other.centerYAnchor) + case .left, .right: return view.centerXAnchor.constraint(equalTo: other.centerXAnchor) + } + } + + // MARK: - Rotation of the icons + + @objc private func deviceOrientationDidChange() { + self.iconRotationWorkItem?.cancel() + self.iconRotationWorkItem = nil + + guard UIDevice.current.userInterfaceIdiom == .pad else { + self.updateIconRotation(animated: true) + return + } + + // On the iPad the interface rotates too, and the notification can come before the interface has its new + // orientation. The angle is calculated a moment later, so it is the one against the new interface. When the + // rotation is locked the interface does not change, and the icons turn after the same moment. + let workItem = DispatchWorkItem { [weak self] in + guard let self, !self.isInterfaceTransitioning else { return } + + self.updateIconRotation(animated: true) + } + + self.iconRotationWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2, execute: workItem) + } + + /// The orientation the device is held in. When it lies flat, or is not known, it is the last one it was held in. + private func heldOrientation() -> UIDeviceOrientation { + self.lastHeldOrientation = InAppCameraSupport.heldOrientation(current: UIDevice.current.orientation, last: self.lastHeldOrientation) + + return self.lastHeldOrientation + } + + /// Turns the icons so they are upright for the person who holds the device. Flat or unknown keeps the angle. + private func updateIconRotation(animated: Bool) { + guard let degrees = InAppCameraSupport.iconRotationDegrees(device: self.heldOrientation(), + interface: CameraCaptureHelpers.interfaceOrientation(of: self.view)) else { return } + + let target = InAppCameraSupport.shortestRotationTarget(current: self.iconAngle, target: degrees) + + guard target != self.iconAngle else { return } + + self.iconAngle = target + + let transform = CGAffineTransform(rotationAngle: CGFloat(target * .pi / 180)) + let rotate = { + for view in [self.closeButton, self.flashButton, self.switchCameraButton] { + view.transform = transform + } + } + + if animated { + UIView.animate(withDuration: 0.25, delay: 0, options: [.beginFromCurrentState, .allowUserInteraction], animations: rotate) + } else { + rotate() + } + } + + private func makeCircleButton(symbolName: String, accessibilityLabel: String, action: @escaping () -> Void) -> UIButton { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: symbolName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold)), for: .normal) + button.tintColor = .white + button.backgroundColor = UIColor.black.withAlphaComponent(0.4) + button.layer.cornerRadius = 22 + button.accessibilityLabel = accessibilityLabel + button.addAction(UIAction { _ in action() }, for: .touchUpInside) + + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 44), + button.heightAnchor.constraint(equalToConstant: 44) + ]) + + return button + } + + private func setupCaptureCallbacks() { + self.captureSession.onConfigurationChanged = { [weak self] in + self?.updateFlashButton() + self?.updateControls() + + // The connection of the preview is new after the session started or the camera was switched + self?.previewView.setNeedsLayout() + } + + self.captureSession.onInterruptionChanged = { [weak self] isInterrupted in + guard let self, self.isShown else { return } + + if isInterrupted { + self.showStatus(NSLocalizedString("The camera is not available right now", comment: ""), showsSettings: false) + } else { + self.showStatus(nil) + } + } + + self.captureSession.onRuntimeError = { [weak self] in + NCLog.log("Runtime error of the session of the in-app camera") + self?.updateControls() + } + + self.captureSession.onPhotoCaptured = { [weak self] result in + self?.photoCaptured(result) + } + + self.captureSession.onRecordingStarted = { [weak self] in + self?.recordingStarted() + } + + self.captureSession.onRecordingFinished = { [weak self] result in + self?.recordingFinished(result) + } + } + + // MARK: - Availability + + /// Starts the camera when it can be used. It can not be used when it is denied, or during a call, as the call needs it. + private func evaluateAvailability() { + guard self.isShown else { return } + + if NCRoomsManager.shared.callViewController != nil { + self.stopSession() + self.showStatus(NSLocalizedString("The camera can not be used during a call", comment: ""), showsSettings: false) + return + } + + switch AVCaptureDevice.authorizationStatus(for: .video) { + case .authorized: + self.startSession() + case .notDetermined: + AVCaptureDevice.requestAccess(for: .video) { [weak self] _ in + Task { @MainActor in + self?.evaluateAvailability() + } + } + default: + self.stopSession() + self.showStatus(NSLocalizedString("Camera access is not allowed. Check your settings.", comment: ""), showsSettings: true) + } + } + + private func startSession() { + guard !self.isSessionRunning else { return } + + self.isSessionRunning = true + self.showStatus(nil) + + self.captureSession.start { [weak self] success in + guard let self, self.isSessionRunning else { return } + + if success { + // The shutter works as soon as the session runs + self.isSessionStarted = true + self.showStatus(nil) + } else { + NCLog.log("Could not start the in-app camera") + self.isSessionRunning = false + self.showStatus(NSLocalizedString("The camera is not available right now", comment: ""), showsSettings: false) + } + } + } + + private func stopSession() { + guard self.isSessionRunning else { return } + + self.isSessionRunning = false + self.isSessionStarted = false + self.finishRecordingForTeardown() + self.captureSession.stop() + self.updateControls() + } + + /// Shows a message over the picture, or nothing when the camera can be used + private func showStatus(_ message: String?, showsSettings: Bool = false) { + self.statusLabel.text = message + self.settingsButton.isHidden = !showsSettings + self.statusStackView.isHidden = message == nil + + self.isCaptureAvailable = message == nil && self.isSessionStarted + self.updateControls() + } + + @objc private func applicationDidBecomeActive() { + self.evaluateAvailability() + } + + @objc private func applicationWillResignActive() { + // The recording ends in the background anyway, so the video that exists until now is kept + if self.isRecordingRequested { + self.stopRecording() + } + } + + // MARK: - Controls + + private func updateControls() { + let canSwitch = self.captureSession.canSwitchCamera && self.isCaptureAvailable && !self.isRecordingRequested + + self.switchCameraButton.isHidden = !canSwitch + self.shutterView.alpha = self.isCaptureAvailable ? 1 : 0.4 + self.shutterView.isUserInteractionEnabled = self.isCaptureAvailable + self.hintLabel.isHidden = self.isRecordingRequested || !self.isCaptureAvailable + self.flashButton.isHidden = !self.captureSession.supportsFlash || !self.isCaptureAvailable + self.updateShutterAccessibility() + } + + /// The shutter only takes photos with VoiceOver, so recording a video is an action of its own + private func updateShutterAccessibility() { + let title = self.isRecordingRequested ? NSLocalizedString("Stop recording", comment: "") : NSLocalizedString("Record video", comment: "Accessibility action of the shutter button of the camera that starts recording a video") + + self.shutterView.accessibilityCustomActions = self.isCaptureAvailable || self.isRecordingRequested ? [ + UIAccessibilityCustomAction(name: title) { [weak self] _ in + guard let self else { return false } + + if self.isRecordingRequested { + self.stopRecording() + } else { + self.startRecording() + } + + return true + } + ] : nil + } + + private func updateFlashButton() { + self.flashButton.setImage(UIImage(systemName: self.flashMode.symbolName, withConfiguration: UIImage.SymbolConfiguration(pointSize: 20, weight: .semibold)), for: .normal) + self.flashButton.accessibilityLabel = self.flashMode.accessibilityLabel + self.updateControls() + } + + private func updatePreviewOrientation() { + self.previewView.interfaceOrientation = CameraCaptureHelpers.interfaceOrientation(of: self.view) + } + + /// The orientation a photo or a video is captured in + private var captureOrientation: UIInterfaceOrientation { + return InAppCameraSupport.captureOrientation(device: self.heldOrientation(), + interface: CameraCaptureHelpers.interfaceOrientation(of: self.view)) + } + + // MARK: - Actions + + private func closeTapped() { + self.isClosing = true + self.holdWorkItem?.cancel() + + if self.isRecordingRequested { + // What is recorded so far is dropped + self.discardsRecording = true + self.stopRecording() + } + + self.delegate?.inAppCameraViewControllerDidCancel(self) + } + + private func flashTapped() { + self.flashMode = self.flashMode.next + NCUserDefaults.setPreferredCameraFlashMode(self.flashMode.rawValue) + self.updateFlashButton() + } + + private func switchCameraTapped() { + guard !self.isRecordingRequested else { return } + + self.captureSession.switchCamera() + } + + @objc private func previewDoubleTapped() { + self.switchCameraTapped() + } + + // MARK: - Shutter + + @objc private func shutterGestureChanged(_ gesture: UILongPressGestureRecognizer) { + switch gesture.state { + case .began: + self.shutterPressed() + case .ended: + self.shutterReleased(cancelled: false) + case .cancelled, .failed: + self.shutterReleased(cancelled: true) + default: + break + } + } + + private func shutterPressed() { + guard self.isCaptureAvailable, !self.isCapturingPhoto, !self.isRecordingRequested else { return } + + self.shutterView.setPressed(true) + + let workItem = DispatchWorkItem { [weak self] in + self?.startRecording() + } + + self.holdWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + Self.holdDuration, execute: workItem) + } + + private func shutterReleased(cancelled: Bool) { + let wasWaitingForHold = self.holdWorkItem != nil + + self.holdWorkItem?.cancel() + self.holdWorkItem = nil + + if self.isRecordingRequested { + self.stopRecording() + } else { + self.shutterView.setPressed(false) + + // Released before the time of a video: a photo + if wasWaitingForHold, !cancelled { + self.takePhoto() + } + } + } + + // MARK: - Photo + + private func takePhoto() { + guard self.isCaptureAvailable, !self.isCapturingPhoto, !self.isRecordingRequested else { return } + + self.isCapturingPhoto = true + self.captureSession.capturePhoto(flashMode: self.flashMode, orientation: self.captureOrientation) + + UIImpactFeedbackGenerator(style: .light).impactOccurred() + self.flashOverlayView.alpha = 0.8 + UIView.animate(withDuration: 0.25) { + self.flashOverlayView.alpha = 0 + } + } + + private func photoCaptured(_ result: Result) { + self.isCapturingPhoto = false + + switch result { + case .success(let url): + // A closed camera has no use for the photo, like for a video + if self.isClosing || !self.isShown { + try? FileManager.default.removeItem(at: url) + } else { + self.deliver(url) + } + case .failure(let error): + NCLog.log("Could not take a photo with the in-app camera: \(error.localizedDescription)") + self.showMessage(NSLocalizedString("Could not take the photo", comment: "")) + } + } + + // MARK: - Video + + private func startRecording() { + self.holdWorkItem = nil + + guard self.isCaptureAvailable, !self.isCapturingPhoto, !self.isRecordingRequested else { return } + + // The access to the microphone is asked for with the first video. The finger is on the shutter + // while the question is shown, so the video is recorded with the next hold. + if AVCaptureDevice.authorizationStatus(for: .audio) == .notDetermined { + self.shutterView.setPressed(false) + + AVCaptureDevice.requestAccess(for: .audio) { _ in } + return + } + + self.isRecordingRequested = true + self.discardsRecording = false + self.updateControls() + + UIImpactFeedbackGenerator(style: .medium).impactOccurred() + self.captureSession.startRecording(flashMode: self.flashMode, orientation: self.captureOrientation) + } + + private func stopRecording() { + self.captureSession.stopRecording() + } + + private func recordingStarted() { + self.recordingStartDate = Date() + + self.timerLabel.text = InAppCameraSupport.formattedDuration(0) + self.timerLabel.isHidden = false + self.shutterView.setRecording(true) + + let timer = Timer(timeInterval: 0.25, repeats: true) { [weak self] _ in + self?.updateRecordingTimer() + } + + RunLoop.main.add(timer, forMode: .common) + self.recordingTimer = timer + } + + private func updateRecordingTimer() { + guard let startDate = self.recordingStartDate else { return } + + self.timerLabel.text = InAppCameraSupport.formattedDuration(Date().timeIntervalSince(startDate)) + } + + private func recordingFinished(_ result: Result) { + self.recordingTimer?.invalidate() + self.recordingTimer = nil + self.recordingStartDate = nil + self.isRecordingRequested = false + + self.timerLabel.isHidden = true + self.shutterView.setRecording(false) + self.shutterView.setPressed(false) + self.updateControls() + + let discards = self.discardsRecording + self.discardsRecording = false + + switch result { + case .success(let url): + // A closed camera has no use for the video + if discards || self.isClosing || !self.isShown { + try? FileManager.default.removeItem(at: url) + } else { + self.deliver(url) + } + case .failure(let error): + NCLog.log("Could not record a video with the in-app camera: \(error.localizedDescription)") + + if !discards { + self.showMessage(NSLocalizedString("Could not record the video", comment: "")) + } + } + } + + /// The camera goes away, so what is recorded is dropped + private func finishRecordingForTeardown() { + self.holdWorkItem?.cancel() + self.holdWorkItem = nil + + if self.isRecordingRequested { + self.discardsRecording = true + self.stopRecording() + } + } + + // MARK: - Result + + private func deliver(_ url: URL) { + guard let delegate = self.delegate else { + try? FileManager.default.removeItem(at: url) + return + } + + delegate.inAppCameraViewController(self, didCaptureMediaAt: url) + } + + private func showMessage(_ message: String) { + let position = CGPoint(x: self.view.bounds.midX, y: self.view.bounds.height * 0.3) + self.view.makeToast(message, duration: 3, point: position, title: nil, image: nil, completion: nil) + } +} + +/// The big round button of the camera. The ring stays, the inner circle shrinks and turns red while recording. +private final class InAppCameraShutterView: UIView { + + static let size: CGFloat = 76 + + /// Called when the button is activated with VoiceOver or another assistive technology + var onAccessibilityActivate: (() -> Void)? + + private let ringView = UIView() + private let innerView = UIView() + + override init(frame: CGRect) { + super.init(frame: frame) + + self.isAccessibilityElement = true + self.accessibilityTraits = .button + + let innerSize = Self.size - 14 + + self.ringView.translatesAutoresizingMaskIntoConstraints = false + self.ringView.layer.borderColor = UIColor.white.cgColor + self.ringView.layer.borderWidth = 4 + self.ringView.layer.cornerRadius = Self.size / 2 + self.ringView.isUserInteractionEnabled = false + + self.innerView.translatesAutoresizingMaskIntoConstraints = false + self.innerView.backgroundColor = .white + self.innerView.layer.cornerRadius = innerSize / 2 + self.innerView.isUserInteractionEnabled = false + + self.addSubview(self.ringView) + self.addSubview(self.innerView) + + NSLayoutConstraint.activate([ + self.ringView.topAnchor.constraint(equalTo: self.topAnchor), + self.ringView.bottomAnchor.constraint(equalTo: self.bottomAnchor), + self.ringView.leadingAnchor.constraint(equalTo: self.leadingAnchor), + self.ringView.trailingAnchor.constraint(equalTo: self.trailingAnchor), + + self.innerView.centerXAnchor.constraint(equalTo: self.centerXAnchor), + self.innerView.centerYAnchor.constraint(equalTo: self.centerYAnchor), + self.innerView.widthAnchor.constraint(equalToConstant: innerSize), + self.innerView.heightAnchor.constraint(equalToConstant: innerSize) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func accessibilityActivate() -> Bool { + self.onAccessibilityActivate?() + return true + } + + func setPressed(_ pressed: Bool) { + UIView.animate(withDuration: 0.15) { + self.innerView.transform = pressed ? CGAffineTransform(scaleX: 0.88, y: 0.88) : .identity + } + } + + func setRecording(_ recording: Bool) { + UIView.animate(withDuration: 0.2) { + self.innerView.backgroundColor = recording ? .systemRed : .white + self.innerView.transform = recording ? CGAffineTransform(scaleX: 0.6, y: 0.6) : .identity + self.transform = recording ? CGAffineTransform(scaleX: 1.15, y: 1.15) : .identity + } + } +} diff --git a/NextcloudTalk/Info.plist b/NextcloudTalk/Info.plist index 2987f9827..10b53b571 100644 --- a/NextcloudTalk/Info.plist +++ b/NextcloudTalk/Info.plist @@ -53,7 +53,7 @@ LSSupportsOpeningDocumentsInPlace NSCameraUsageDescription - Camera is used in video calls and to record videos to share in chats + Camera is used in video calls and to take photos and videos to share in chats NSContactsUsageDescription Contacts access is needed to match your address book contacts with contacts in your server NSLocationWhenInUseUsageDescription diff --git a/NextcloudTalk/en.lproj/InfoPlist.strings b/NextcloudTalk/en.lproj/InfoPlist.strings index 971a1491a..3a183fc9c 100644 --- a/NextcloudTalk/en.lproj/InfoPlist.strings +++ b/NextcloudTalk/en.lproj/InfoPlist.strings @@ -1,4 +1,4 @@ -NSCameraUsageDescription = "Camera is used in video calls and to record videos to share in chats"; +NSCameraUsageDescription = "Camera is used in video calls and to take photos and videos to share in chats"; NSMicrophoneUsageDescription = "Microphone is used in voice and video calls and to record videos to share in chats"; NSPhotoLibraryUsageDescription = "Photo Library access is needed to share pictures and videos from your device"; NSContactsUsageDescription = "Contacts access is needed to match your address book contacts with contacts in your server"; diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index bed2050be..f006951b7 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -19,6 +19,9 @@ /* Alice, Bob, Charlie and 3 others will receive invitations */ "%1$@, %2$@, %3$@ and %4$ld others will receive invitations" = "%1$@, %2$@, %3$@ and %4$ld others will receive invitations"; +/* Position of the shown item in the selection, e.g. '2 of 5' */ +"%1$ld of %2$ld" = "%1$ld of %2$ld"; + /* 'Alice (bot)' - a participant that is a bot, not a person */ "%@ (bot)" = "%@ (bot)"; @@ -172,6 +175,9 @@ /* No comment provided by engineer. */ "Add (%lu)" = "Add (%lu)"; +/* Placeholder of the field for the text sent along with the photos and files */ +"Add a caption …" = "Add a caption …"; + /* No comment provided by engineer. */ "Add account" = "Add account"; @@ -184,6 +190,9 @@ /* No comment provided by engineer. */ "Add attendees" = "Add attendees"; +/* Add more photos, videos or files to the share */ +"Add more" = "Add more"; + /* No comment provided by engineer. */ "Add participants" = "Add participants"; @@ -802,6 +811,9 @@ /* No comment provided by engineer. */ "Could not prepare upload folder" = "Could not prepare upload folder"; +/* No comment provided by engineer. */ +"Could not record the video" = "Could not record the video"; + /* No comment provided by engineer. */ "Could not remove participant" = "Could not remove participant"; @@ -841,6 +853,9 @@ /* No comment provided by engineer. */ "Could not start group conversation" = "Could not start group conversation"; +/* No comment provided by engineer. */ +"Could not take the photo" = "Could not take the photo"; + /* No comment provided by engineer. */ "Could not update tags" = "Could not update tags"; @@ -868,6 +883,9 @@ /* No comment provided by engineer. */ "Credentials for this account were no longer valid" = "Credentials for this account were no longer valid"; +/* No comment provided by engineer. */ +"Crop and rotate" = "Crop and rotate"; + /* Custom message expiration */ "Custom" = "Custom"; @@ -1093,6 +1111,9 @@ /* Accessibility label of the indicator shown while the original file is downloaded */ "Downloading full quality media" = "Downloading full quality media"; +/* No comment provided by engineer. */ +"Draw" = "Draw"; + /* No comment provided by engineer. */ "Duplicate session" = "Duplicate session"; @@ -1237,6 +1258,15 @@ /* First day of the absence */ "First day" = "First day"; +/* No comment provided by engineer. */ +"Flash auto" = "Flash auto"; + +/* No comment provided by engineer. */ +"Flash off" = "Flash off"; + +/* No comment provided by engineer. */ +"Flash on" = "Flash on"; + /* No comment provided by engineer. */ "Follow conversation settings" = "Follow conversation settings"; @@ -2007,6 +2037,9 @@ /* No comment provided by engineer. */ "Recent threads" = "Recent threads"; +/* Accessibility action of the shutter button of the camera that starts recording a video */ +"Record video" = "Record video"; + /* No comment provided by engineer. */ "Record video message" = "Record video message"; @@ -2398,6 +2431,9 @@ /* 'Tags' meaning 'Conversation tags' */ "Tags" = "Tags"; +/* No comment provided by engineer. */ +"Take photo" = "Take photo"; + /* 'Talk to Alice' - start a conversation with a user */ "Talk to %@" = "Talk to %@"; @@ -2419,6 +2455,9 @@ /* No comment provided by engineer. */ "Tap and hold to record a voice message. Tap to switch to video messages." = "Tap and hold to record a voice message. Tap to switch to video messages."; +/* No comment provided by engineer. */ +"Tap for photo, hold for video" = "Tap for photo, hold for video"; + /* No comment provided by engineer. */ "Test failed" = "Test failed"; @@ -2446,6 +2485,12 @@ /* No comment provided by engineer. */ "The call might be recorded" = "The call might be recorded"; +/* No comment provided by engineer. */ +"The camera can not be used during a call" = "The camera can not be used during a call"; + +/* No comment provided by engineer. */ +"The camera is not available right now" = "The camera is not available right now"; + /* No comment provided by engineer. */ "The password is wrong" = "The password is wrong"; @@ -2743,6 +2788,9 @@ /* No comment provided by engineer. */ "You can enable your microphone again in a few seconds" = "You can enable your microphone again in a few seconds"; +/* Shown when more photos and videos are selected than can be sent at once */ +"You can select up to %ld items" = "You can select up to %ld items"; + /* No comment provided by engineer. */ "You can set your phone number so other users will be able to find you" = "You can set your phone number so other users will be able to find you"; diff --git a/NextcloudTalk/en.lproj/Localizable.stringsdict b/NextcloudTalk/en.lproj/Localizable.stringsdict index 0794793c3..449391e81 100644 --- a/NextcloudTalk/en.lproj/Localizable.stringsdict +++ b/NextcloudTalk/en.lproj/Localizable.stringsdict @@ -706,5 +706,37 @@ {actor0} and %ld more participants left the call + You can select up to %ld items + + NSStringLocalizedFormatKey + %#@items@ + items + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + You can select up to %ld item + other + You can select up to %ld items + + + %1$ld of %2$ld + + NSStringLocalizedFormatKey + %1$ld of %2$#@total@ + total + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %2$ld + other + %2$ld + + diff --git a/NextcloudTalkTests/Unit/Chat/UnitInAppCameraTest.swift b/NextcloudTalkTests/Unit/Chat/UnitInAppCameraTest.swift new file mode 100644 index 000000000..c3a748715 --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitInAppCameraTest.swift @@ -0,0 +1,188 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import UIKit +import XCTest +@testable import NextcloudTalk + +final class UnitInAppCameraTest: XCTestCase { + + // MARK: - Flash mode + + func testFlashModeKeepsTheValuesOfTheSystemCamera() throws { + XCTAssertEqual(InAppCameraFlashMode.off.rawValue, UIImagePickerController.CameraFlashMode.off.rawValue) + XCTAssertEqual(InAppCameraFlashMode.auto.rawValue, UIImagePickerController.CameraFlashMode.auto.rawValue) + XCTAssertEqual(InAppCameraFlashMode.on.rawValue, UIImagePickerController.CameraFlashMode.on.rawValue) + } + + func testFlashModeFromStoredValue() throws { + // Nothing stored is read as 0 by NCUserDefaults, which is auto + XCTAssertEqual(InAppCameraFlashMode(storedValue: 0), .auto) + XCTAssertEqual(InAppCameraFlashMode(storedValue: -1), .off) + XCTAssertEqual(InAppCameraFlashMode(storedValue: 1), .on) + XCTAssertEqual(InAppCameraFlashMode(storedValue: 42), .off) + } + + func testFlashModeCyclesThroughAllModes() throws { + XCTAssertEqual(InAppCameraFlashMode.off.next, .auto) + XCTAssertEqual(InAppCameraFlashMode.auto.next, .on) + XCTAssertEqual(InAppCameraFlashMode.on.next, .off) + } + + func testFlashModeMapsToCaptureFlashMode() throws { + XCTAssertEqual(InAppCameraFlashMode.off.captureFlashMode, .off) + XCTAssertEqual(InAppCameraFlashMode.auto.captureFlashMode, .auto) + XCTAssertEqual(InAppCameraFlashMode.on.captureFlashMode, .on) + } + + func testFlashModesHaveDifferentSymbols() throws { + let symbols = Set(InAppCameraFlashMode.allCases.map { $0.symbolName }) + XCTAssertEqual(symbols.count, InAppCameraFlashMode.allCases.count) + } + + // MARK: - Files + + func testFileURLsAreInTheGivenDirectoryAndHaveTheExtensionOfTheKind() throws { + let uuid = UUID() + + let photo = InAppCameraSupport.makeFileURL(for: .photo(isJPEG: true), directory: "/tmp/test", uuid: uuid) + XCTAssertEqual(photo.path, "/tmp/test/in-app-camera-\(uuid.uuidString).jpg") + + let heic = InAppCameraSupport.makeFileURL(for: .photo(isJPEG: false), directory: "/tmp/test", uuid: uuid) + XCTAssertEqual(heic.pathExtension, "heic") + + let video = InAppCameraSupport.makeFileURL(for: .video, directory: "/tmp/test", uuid: uuid) + XCTAssertEqual(video.pathExtension, "mov") + } + + func testEveryFileURLIsNew() throws { + let first = InAppCameraSupport.makeFileURL(for: .video) + let second = InAppCameraSupport.makeFileURL(for: .video) + + XCTAssertNotEqual(first, second) + XCTAssertTrue(first.path.hasPrefix(NSTemporaryDirectory()) || first.path.hasPrefix(URL(fileURLWithPath: NSTemporaryDirectory()).path)) + } + + // MARK: - Duration + + func testFormattedDuration() throws { + XCTAssertEqual(InAppCameraSupport.formattedDuration(0), "0:00") + XCTAssertEqual(InAppCameraSupport.formattedDuration(7.9), "0:07") + XCTAssertEqual(InAppCameraSupport.formattedDuration(60), "1:00") + XCTAssertEqual(InAppCameraSupport.formattedDuration(754), "12:34") + XCTAssertEqual(InAppCameraSupport.formattedDuration(3723), "1:02:03") + XCTAssertEqual(InAppCameraSupport.formattedDuration(-5), "0:00") + } + + // MARK: - Orientation + + func testCaptureOrientationFollowsTheDevice() throws { + XCTAssertEqual(InAppCameraSupport.captureOrientation(device: .portrait, interface: .landscapeLeft), .portrait) + XCTAssertEqual(InAppCameraSupport.captureOrientation(device: .portraitUpsideDown, interface: .portrait), .portraitUpsideDown) + // The interface is rotated the other way round than the device in landscape + XCTAssertEqual(InAppCameraSupport.captureOrientation(device: .landscapeLeft, interface: .portrait), .landscapeRight) + XCTAssertEqual(InAppCameraSupport.captureOrientation(device: .landscapeRight, interface: .portrait), .landscapeLeft) + } + + func testCaptureOrientationFallsBackToTheInterface() throws { + XCTAssertEqual(InAppCameraSupport.captureOrientation(device: .faceUp, interface: .landscapeRight), .landscapeRight) + XCTAssertEqual(InAppCameraSupport.captureOrientation(device: .unknown, interface: .landscapeLeft), .landscapeLeft) + XCTAssertEqual(InAppCameraSupport.captureOrientation(device: .faceDown, interface: .unknown), .portrait) + } + + func testHeldOrientationKeepsTheLastOneWhenTheDeviceLiesFlat() throws { + XCTAssertEqual(InAppCameraSupport.heldOrientation(current: .landscapeLeft, last: .portrait), .landscapeLeft) + XCTAssertEqual(InAppCameraSupport.heldOrientation(current: .portraitUpsideDown, last: .landscapeRight), .portraitUpsideDown) + XCTAssertEqual(InAppCameraSupport.heldOrientation(current: .faceUp, last: .landscapeLeft), .landscapeLeft) + XCTAssertEqual(InAppCameraSupport.heldOrientation(current: .faceDown, last: .landscapeRight), .landscapeRight) + XCTAssertEqual(InAppCameraSupport.heldOrientation(current: .unknown, last: .portraitUpsideDown), .portraitUpsideDown) + XCTAssertEqual(InAppCameraSupport.heldOrientation(current: .faceUp, last: .unknown), .unknown) + } + + func testFlatDeviceCapturesInTheLastHeldOrientationOnThePhone() throws { + // Held in landscape, then laid flat: the interface is portrait, the photo still is landscape + let held = InAppCameraSupport.heldOrientation(current: .faceUp, last: .landscapeLeft) + + XCTAssertEqual(InAppCameraSupport.captureOrientation(device: held, interface: .portrait), .landscapeRight) + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: held, interface: .portrait), 90) + } + + // MARK: - Layout along the body + + func testScreenEdgeOfTheBodyFollowsTheInterface() throws { + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .bottom, interface: .portrait), .bottom) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .top, interface: .portrait), .top) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .bottom, interface: .unknown), .bottom) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .bottom, interface: .portraitUpsideDown), .top) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .left, interface: .portraitUpsideDown), .right) + } + + func testScreenEdgeOfTheBodyInLandscape() throws { + // The home button is at the bottom of the body, in .landscapeLeft it is on the left of the screen + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .bottom, interface: .landscapeLeft), .left) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .top, interface: .landscapeLeft), .right) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .left, interface: .landscapeLeft), .top) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .right, interface: .landscapeLeft), .bottom) + + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .bottom, interface: .landscapeRight), .right) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .top, interface: .landscapeRight), .left) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .left, interface: .landscapeRight), .bottom) + XCTAssertEqual(InAppCameraSupport.screenEdge(ofBodyEdge: .right, interface: .landscapeRight), .top) + } + + func testOutwardDirectionOfAnEdge() throws { + XCTAssertEqual(InAppCameraEdge.top.outwardDirection.dy, -1) + XCTAssertEqual(InAppCameraEdge.top.outwardDirection.dx, 0) + XCTAssertEqual(InAppCameraEdge.bottom.outwardDirection.dy, 1) + XCTAssertEqual(InAppCameraEdge.left.outwardDirection.dx, -1) + XCTAssertEqual(InAppCameraEdge.right.outwardDirection.dx, 1) + XCTAssertEqual(InAppCameraEdge.right.outwardDirection.dy, 0) + } + + // MARK: - Rotation of the icons + + func testIconRotationOnThePhoneInPortrait() throws { + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .portrait, interface: .portrait), 0) + // The device is turned clockwise (home button on the left), so the icons turn back counterclockwise + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .landscapeRight, interface: .portrait), 270) + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .landscapeLeft, interface: .portrait), 90) + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .portraitUpsideDown, interface: .portrait), 180) + } + + func testIconRotationIsRelativeToTheInterface() throws { + // The interface turned with the device, so nothing is left to turn + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .landscapeRight, interface: .landscapeLeft), 0) + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .landscapeLeft, interface: .landscapeRight), 0) + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .portraitUpsideDown, interface: .portraitUpsideDown), 0) + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .portrait, interface: .landscapeLeft), 90) + XCTAssertEqual(InAppCameraSupport.iconRotationDegrees(device: .portrait, interface: .landscapeRight), 270) + } + + func testIconRotationIsUnchangedWithoutUsableDeviceOrientation() throws { + XCTAssertNil(InAppCameraSupport.iconRotationDegrees(device: .faceUp, interface: .portrait)) + XCTAssertNil(InAppCameraSupport.iconRotationDegrees(device: .faceDown, interface: .landscapeLeft)) + XCTAssertNil(InAppCameraSupport.iconRotationDegrees(device: .unknown, interface: .portrait)) + } + + func testShortestRotationTarget() throws { + // 0 to 270 goes through -90, not around the circle + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: 0, target: 270), -90) + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: -90, target: 0), 0) + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: 0, target: 90), 90) + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: 90, target: 270), 270) + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: -90, target: 90), 90) + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: 90, target: 0), 0) + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: 0, target: 0), 0) + // Half a turn has no short way, it is clockwise + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: 0, target: 180), 180) + } + + func testShortestRotationTargetFromAnAngleThatWentAround() throws { + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: -270, target: 0), -360) + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: 270, target: 0), 360) + XCTAssertEqual(InAppCameraSupport.shortestRotationTarget(current: 360, target: 270), 270) + } +} diff --git a/ShareExtension/MediaPreviewViews.swift b/ShareExtension/MediaPreviewViews.swift new file mode 100644 index 000000000..e96b39ff7 --- /dev/null +++ b/ShareExtension/MediaPreviewViews.swift @@ -0,0 +1,136 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +// The views of the full screen preview in ShareConfirmationViewController. The file is compiled into the app +// and into the share extension, so nothing in here may use UIApplication.shared. + +/// A view that is only there to hold other views: touches that do not hit one of them go through to the +/// views below, which is what lets the media underneath be swiped. +final class MediaPreviewPassthroughView: UIView { + + override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? { + let hitView = super.hitTest(point, with: event) + + return hitView === self ? nil : hitView + } +} + +/// A dark blurred capsule that stays readable on top of any photo. +class MediaPreviewCapsuleView: UIVisualEffectView { + + init() { + super.init(effect: UIBlurEffect(style: .systemThinMaterialDark)) + + self.translatesAutoresizingMaskIntoConstraints = false + self.clipsToBounds = true + self.layer.cornerCurve = .continuous + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layoutSubviews() { + super.layoutSubviews() + + self.layer.cornerRadius = self.bounds.height / 2 + } +} + +/// The pill with the position of the shown item, like "2 of 5". +final class MediaPreviewCounterView: MediaPreviewCapsuleView { + + private let label: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = .preferredFont(forTextStyle: .subheadline) + label.adjustsFontForContentSizeCategory = true + label.textColor = .white + + return label + }() + + override init() { + super.init() + + self.contentView.addSubview(self.label) + + NSLayoutConstraint.activate([ + self.label.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 12), + self.label.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -12), + self.label.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 6), + self.label.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -6) + ]) + + self.isAccessibilityElement = true + self.isHidden = true + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Shows the position, hidden as long as there is only one item to tell apart. `current` starts at 1. + func update(current: Int, total: Int) { + let text = String.localizedStringWithFormat(NSLocalizedString("%1$ld of %2$ld", comment: "Position of the shown item in the selection, e.g. '2 of 5'"), current, total) + + self.label.text = text + self.accessibilityLabel = text + self.isHidden = total < 2 + } +} + +/// The pill with the buttons that act on the shown item. +final class MediaPreviewToolsView: MediaPreviewCapsuleView { + + private let stackView: UIStackView = { + let stackView = UIStackView() + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 0 + + return stackView + }() + + init(buttons: [UIButton]) { + super.init() + + for button in buttons { + self.stackView.addArrangedSubview(button) + } + + self.contentView.addSubview(self.stackView) + + NSLayoutConstraint.activate([ + self.stackView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 4), + self.stackView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -4), + self.stackView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + self.stackView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// A white icon button with a touch target of the recommended size. + static func toolButton(systemName: String, accessibilityLabel: String) -> UIButton { + let button = UIButton(type: .system) + button.translatesAutoresizingMaskIntoConstraints = false + button.setImage(UIImage(systemName: systemName), for: .normal) + button.tintColor = .white + button.accessibilityLabel = accessibilityLabel + + NSLayoutConstraint.activate([ + button.widthAnchor.constraint(equalToConstant: 48), + button.heightAnchor.constraint(equalToConstant: 44) + ]) + + return button + } +} diff --git a/ShareExtension/ShareConfirmationViewController.swift b/ShareExtension/ShareConfirmationViewController.swift index 55ff51a74..145ac8a1d 100644 --- a/ShareExtension/ShareConfirmationViewController.swift +++ b/ShareExtension/ShareConfirmationViewController.swift @@ -13,6 +13,10 @@ import MBProgressHUD private let kShareConfirmationOptionsViewHeight: CGFloat = 44 +/// How many items can be in a share when more are added from this screen, and how many can be picked at once +/// in the chat. Matches the Android app. +let kShareConfirmationMaxItems = 10 + @objc public protocol ShareConfirmationViewControllerDelegate { @objc func shareConfirmationViewControllerDidFail(_ viewController: ShareConfirmationViewController) @objc func shareConfirmationViewControllerDidFinish(_ viewController: ShareConfirmationViewController) @@ -48,7 +52,7 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 private var serverCapabilities: ServerCapabilities private var shareType: ShareConfirmationType = .item - private var shareContentView = UIView() + private var shareContentView = MediaPreviewPassthroughView() private var shareSilently = false /// Quality the images are uploaded in. Deliberately not remembered between shares, so an @@ -65,6 +69,19 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 private var hud: MBProgressHUD? private var objectShareMessage: NCChatMessage? + /// Whether the media preview is shown, which it is as long as files are shared. Text and rich objects + /// have their own look. + private var showsMediaPreview = true { + didSet { + self.shareCollectionView.isHidden = !self.showsMediaPreview + self.toolsView.isHidden = !self.showsMediaPreview + self.counterView.isHidden = true + + // The media is shown on a dark background, no matter the theme + self.overrideUserInterfaceStyle = self.showsMediaPreview ? .dark : .unspecified + } + } + private enum ShareConfirmationType { case text case item @@ -112,61 +129,40 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 return view }() - private lazy var itemToolbar: UIToolbar = { - let toolbar = UIToolbar(frame: .init(x: 0, y: 0, width: 100, height: 44)) - - toolbar.barTintColor = .systemBackground - toolbar.isTranslucent = false - - if #unavailable(iOS 26) { - toolbar.setItems([removeItemButton, UIBarButtonItem(systemItem: .flexibleSpace), cropItemButton, previewItemButton, addItemButton], animated: false) - } else { - toolbar.setItems([UIBarButtonItem(systemItem: .flexibleSpace), removeItemButton, UIBarButtonItem(systemItem: .fixedSpace), cropItemButton, previewItemButton, addItemButton], animated: false) - } - - toolbar.translatesAutoresizingMaskIntoConstraints = false - - return toolbar + private lazy var toolsView: MediaPreviewToolsView = { + return MediaPreviewToolsView(buttons: [self.addItemButton, self.cropItemButton, self.markupItemButton, self.removeItemButton]) }() - private lazy var removeItemButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .init(systemName: "trash")) - button.width = 56 - button.target = self - button.action = #selector(removeItemButtonPressed) + private lazy var counterView = MediaPreviewCounterView() + + private lazy var removeItemButton: UIButton = { + let button = MediaPreviewToolsView.toolButton(systemName: "trash", accessibilityLabel: NSLocalizedString("Remove", comment: "")) + button.addTarget(self, action: #selector(removeItemButtonPressed), for: .touchUpInside) return button }() - private lazy var cropItemButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .init(systemName: "crop.rotate")) - button.width = 56 - button.target = self - button.action = #selector(cropItemButtonPressed) + private lazy var cropItemButton: UIButton = { + let button = MediaPreviewToolsView.toolButton(systemName: "crop.rotate", accessibilityLabel: NSLocalizedString("Crop and rotate", comment: "")) + button.addTarget(self, action: #selector(cropItemButtonPressed), for: .touchUpInside) return button }() - private lazy var previewItemButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .init(systemName: "eye")) - button.width = 56 - button.target = self - button.action = #selector(previewItemButtonPressed) + /// Draws on images and previews everything else, both through the QuickLook preview. The look is + /// adjusted to the shown item in `updateToolsForCurrentItem`. + private lazy var markupItemButton: UIButton = { + let button = MediaPreviewToolsView.toolButton(systemName: "pencil.tip.crop.circle", accessibilityLabel: NSLocalizedString("Draw", comment: "")) + button.addTarget(self, action: #selector(markupItemButtonPressed), for: .touchUpInside) return button }() - private lazy var addItemButton: UIBarButtonItem = { - let button = UIBarButtonItem(image: .init(systemName: "plus")) - button.width = 56 + private lazy var addItemButton: UIButton = { + let button = MediaPreviewToolsView.toolButton(systemName: "plus", accessibilityLabel: NSLocalizedString("Add more", comment: "Add more photos, videos or files to the share")) var items: [UIAction] = [] - let cameraAction = UIAction(title: NSLocalizedString("Camera", comment: ""), image: UIImage(systemName: "camera")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.checkAndPresentCamera() - } - let photoLibraryAction = UIAction(title: NSLocalizedString("Photo Library", comment: ""), image: UIImage(systemName: "photo")) { [unowned self] _ in self.textView.resignFirstResponder() self.presentPhotoLibrary() @@ -180,8 +176,11 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 #if !APP_EXTENSION // Camera access is not available in app extensions // https://developer.apple.com/library/archive/documentation/General/Conceptual/ExtensibilityPG/ExtensionOverview.html - if UIImagePickerController.isSourceTypeAvailable(.camera) { - items.append(cameraAction) + if InAppCameraViewController.isCameraAvailable { + items.append(UIAction(title: NSLocalizedString("Camera", comment: ""), image: UIImage(systemName: "camera")) { [unowned self] _ in + self.textView.resignFirstResponder() + self.checkAndPresentCamera() + }) } #endif @@ -189,6 +188,7 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 items.append(filesAction) button.menu = UIMenu(children: items) + button.showsMenuAsPrimaryAction = true return button }() @@ -355,7 +355,10 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 collectionView.dataSource = self collectionView.isPagingEnabled = true collectionView.showsVerticalScrollIndicator = false - collectionView.backgroundColor = .systemBackground + // The preview is dark no matter the theme, so the media stands out the same way in both + collectionView.backgroundColor = .black + // The collection view fills the screen: the page is the width of the view, not of its safe area + collectionView.contentInsetAdjustmentBehavior = .never return collectionView }() @@ -369,18 +372,6 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 return textView }() - private lazy var pageControl: UIPageControl = { - let pageControl = UIPageControl() - pageControl.translatesAutoresizingMaskIntoConstraints = false - pageControl.currentPageIndicatorTintColor = NCAppBranding.elementColor() - pageControl.pageIndicatorTintColor = NCAppBranding.placeholderColor() - pageControl.hidesForSinglePage = true - pageControl.numberOfPages = 1 - pageControl.addTarget(self, action: #selector(pageControlValueChanged), for: .valueChanged) - - return pageControl - }() - // MARK: - Init. public init?(room: NCRoom, thread: NCThread?, account: TalkAccount, serverCapabilities: ServerCapabilities) { @@ -389,10 +380,13 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 super.init(forRoom: room, withAccount: account, withView: self.shareContentView) self.thread = thread - self.shareContentView.addSubview(self.shareCollectionView) - self.shareContentView.addSubview(self.pageControl) + // The media fills the whole screen and sits below the content view. The content view only holds the + // controls on top of the media, so the media neither shrinks nor jumps when the keyboard shows up. + self.view.insertSubview(self.shareCollectionView, belowSubview: self.shareContentView) + self.shareContentView.addSubview(self.shareTextView) - self.shareContentView.addSubview(self.itemToolbar) + self.shareContentView.addSubview(self.counterView) + self.shareContentView.addSubview(self.toolsView) self.shareContentView.addSubview(self.optionsView) NSLayoutConstraint.activate([ @@ -402,9 +396,15 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 ]) NSLayoutConstraint.activate([ - self.itemToolbar.leftAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.leftAnchor), - self.itemToolbar.rightAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.rightAnchor), - self.itemToolbar.heightAnchor.constraint(equalToConstant: 44) + self.shareCollectionView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.shareCollectionView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + self.shareCollectionView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + + self.counterView.centerXAnchor.constraint(equalTo: self.shareContentView.centerXAnchor), + + self.toolsView.leadingAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.leadingAnchor, constant: 20), + self.toolsView.bottomAnchor.constraint(equalTo: self.optionsView.topAnchor, constant: -8), + self.toolsView.heightAnchor.constraint(equalToConstant: 44) ]) if #unavailable(iOS 26) { @@ -418,28 +418,21 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 self.shareTextView.topAnchor.constraint(equalTo: self.toLabelView.bottomAnchor, constant: 20), - self.itemToolbar.topAnchor.constraint(equalTo: self.toLabelView.bottomAnchor) + self.shareCollectionView.topAnchor.constraint(equalTo: self.toLabelView.bottomAnchor), + self.counterView.topAnchor.constraint(equalTo: self.toLabelView.bottomAnchor, constant: 8) ]) } else { - // On iOS 26 we don't have a toLabel anymore, so we need to constraint to the safe area as well + // On iOS 26 we don't have a toLabel anymore, so we need to constraint to the safe area as well. + // The navigation bar is glass there, the media can go below it. NSLayoutConstraint.activate([ self.shareTextView.topAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.topAnchor), - self.itemToolbar.topAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.topAnchor) + self.shareCollectionView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.counterView.topAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.topAnchor, constant: 8) ]) } NSLayoutConstraint.activate([ - self.shareCollectionView.leftAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.leftAnchor), - self.shareCollectionView.rightAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.rightAnchor), - self.shareCollectionView.topAnchor.constraint(equalTo: self.itemToolbar.bottomAnchor, constant: 8), - self.shareCollectionView.bottomAnchor.constraint(equalTo: self.pageControl.topAnchor, constant: -8), - - self.pageControl.leftAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.leftAnchor), - self.pageControl.rightAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.rightAnchor), - self.pageControl.heightAnchor.constraint(equalToConstant: 26), - self.pageControl.bottomAnchor.constraint(equalTo: self.optionsView.topAnchor), - self.optionsView.leftAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.leftAnchor, constant: 20), self.optionsView.rightAnchor.constraint(equalTo: self.shareContentView.safeAreaLayoutGuide.rightAnchor, constant: -20), self.optionsView.bottomAnchor.constraint(equalTo: self.textInputbar.topAnchor), @@ -456,8 +449,7 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 DispatchQueue.main.async { self.setTextInputbarHidden(true, animated: false) - self.shareCollectionView.isHidden = true - self.itemToolbar.isHidden = true + self.showsMediaPreview = false self.shareTextView.isHidden = false self.shareTextView.text = sharedText self.updateOptionsView() @@ -477,8 +469,7 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 DispatchQueue.main.async { self.setTextInputbarHidden(true, animated: false) - self.shareCollectionView.isHidden = true - self.itemToolbar.isHidden = true + self.showsMediaPreview = false self.shareTextView.isHidden = false self.shareTextView.isUserInteractionEnabled = false self.shareTextView.text = objectShareMessage.parsedMessage().string @@ -506,6 +497,9 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 self.shareCollectionView.register(UINib(nibName: kShareConfirmationTableCellNibName, bundle: bundle), forCellWithReuseIdentifier: kShareConfirmationCellIdentifier) self.shareCollectionView.delegate = self + self.overrideUserInterfaceStyle = .dark + self.configureCaptionInputbar() + // Configure communication lib guard let userToken = NCKeyChainController.sharedInstance().token(forAccountId: self.account.accountId) else { return } let userAgent = "Mozilla/5.0 (iOS) Nextcloud-Talk v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] ?? "Unknown")" @@ -530,6 +524,25 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 } + /// Makes the inputbar the caption field on top of the media: no bar of its own, a dark field and a round send button. + private func configureCaptionInputbar() { + self.textInputbar.backgroundColor = .clear + + self.textView.keyboardAppearance = .dark + self.textView.textColor = .white + self.textView.placeholder = NSLocalizedString("Add a caption …", comment: "Placeholder of the field for the text sent along with the photos and files") + self.textView.placeholderColor = UIColor.white.withAlphaComponent(0.6) + + if #unavailable(iOS 26.0) { + self.textView.backgroundColor = UIColor.black.withAlphaComponent(0.55) + self.textView.layer.borderWidth = 0 + + self.rightButton.backgroundColor = NCAppBranding.themeColor() + self.rightButton.tintColor = NCAppBranding.themeTextColor() + self.rightButton.clipsToBounds = true + } + } + /// The options that apply to what is being shared right now. /// /// Only uploaded files have a quality and a permission to choose, and each of both options @@ -594,6 +607,15 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 } self.updateOptionsView() + self.configureCaptionInputbar() + } + + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + if #unavailable(iOS 26.0) { + self.rightButton.layer.cornerRadius = self.rightButton.bounds.height / 2 + } } public override func viewDidAppear(_ animated: Bool) { @@ -659,7 +681,8 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 } } - func previewItemButtonPressed() { + func markupItemButtonPressed() { + // The QuickLook preview offers the markup of images, see previewController(_:editingModeFor:) self.previewCurrentItem() } @@ -695,6 +718,7 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 // MARK: - Add additional items +#if !APP_EXTENSION func checkAndPresentCamera() { // https://stackoverflow.com/a/20464727/2512312 let mediaType = AVMediaType.video @@ -722,18 +746,13 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 func presentCamera() { DispatchQueue.main.async { - self.imagePicker = UIImagePickerController() - - if let imagePicker = self.imagePicker, - let sourceType = UIImagePickerController.availableMediaTypes(for: imagePicker.sourceType) { - imagePicker.sourceType = .camera - imagePicker.cameraFlashMode = UIImagePickerController.CameraFlashMode(rawValue: NCUserDefaults.preferredCameraFlashMode()) ?? .off - imagePicker.mediaTypes = sourceType - imagePicker.delegate = self - self.present(imagePicker, animated: true) - } + let camera = InAppCameraViewController() + camera.delegate = self + camera.modalPresentationStyle = .fullScreen + self.present(camera, animated: true) } } +#endif func presentPhotoLibrary() { self.imagePicker = UIImagePickerController() @@ -988,27 +1007,36 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 } } - func updateToolbarForCurrentItem() { - if let item = self.getCurrentShareItem() { - UIView.transition(with: self.itemToolbar, duration: 0.3, options: .transitionCrossDissolve) { + func updateToolsForCurrentItem() { + guard self.shareType == .item else { return } + + let itemCount = self.shareItemController.shareItems.count + + if itemCount > 0 { + self.counterView.update(current: self.currentPageIndex + 1, total: itemCount) + } + + UIView.transition(with: self.toolsView, duration: 0.3, options: .transitionCrossDissolve) { + self.addItemButton.isEnabled = itemCount < kShareConfirmationMaxItems + self.removeItemButton.isHidden = itemCount <= 1 + + if let item = self.getCurrentShareItem() { self.cropItemButton.isEnabled = item.isImage - self.previewItemButton.isEnabled = QLPreviewController.canPreview(item.fileURL as QLPreviewItem) - self.addItemButton.isEnabled = self.shareItemController.shareItems.count < 20 + self.markupItemButton.isEnabled = QLPreviewController.canPreview(item.fileURL as QLPreviewItem) + + // Images can be drawn on in the preview, everything else can only be looked at + self.markupItemButton.setImage(UIImage(systemName: item.isImage ? "pencil.tip.crop.circle" : "eye"), for: .normal) + self.markupItemButton.accessibilityLabel = item.isImage ? NSLocalizedString("Draw", comment: "") : NSLocalizedString("Preview", comment: "") + } else { + self.cropItemButton.isEnabled = false + self.markupItemButton.isEnabled = false } - } else { - self.cropItemButton.isEnabled = false - self.previewItemButton.isEnabled = false } - - self.removeItemButton.isEnabled = self.shareItemController.shareItems.count > 1 - self.removeItemButton.tintColor = self.shareItemController.shareItems.count > 1 ? nil : .clear } // MARK: - UIImagePickerController Delegate public func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) { - self.saveImagePickerSettings(picker) - guard let mediaType = info[.mediaType] as? String else { return } if mediaType == "public.image" { @@ -1030,24 +1058,29 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 } public func imagePickerControllerDidCancel(_ picker: UIImagePickerController) { - self.saveImagePickerSettings(picker) self.dismiss(animated: true) } - func saveImagePickerSettings(_ picker: UIImagePickerController) { - if picker.sourceType == .camera && picker.cameraCaptureMode == .photo { - NCUserDefaults.setPreferredCameraFlashMode(picker.cameraFlashMode.rawValue) - } - } - // MARK: - UIDocumentPickerViewController Delegate public func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { - for documentURL in urls { + let freeSlots = max(0, kShareConfirmationMaxItems - self.shareItemController.shareItems.count) + + for documentURL in urls.prefix(freeSlots) { self.shareItemController.addItem(with: documentURL) } self.collectionViewScrollToEnd() + + if urls.count > freeSlots { + let message = String.localizedStringWithFormat(NSLocalizedString("You can select up to %ld items", comment: "Shown when more photos and videos are selected than can be sent at once"), kShareConfirmationMaxItems) + let alert = UIAlertController(title: nil, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default)) + + DispatchQueue.main.async { + self.present(alert, animated: true) + } + } } // MARK: - ScrollView/CollectionView @@ -1113,11 +1146,11 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 } public override func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - self.updatePageControlPage() + self.updateCurrentPage() } public override func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { - self.updatePageControlPage() + self.updateCurrentPage() } func collectionViewScrollToEnd() { @@ -1136,28 +1169,30 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 } } - func getCurrentShareItem() -> ShareItem? { - let currentIndex = Int(self.shareCollectionView.contentOffset.x / self.shareCollectionView.frame.size.width) + /// The index of the page that is shown, kept inside the items. A removal can leave the offset behind the last one. + private var currentPageIndex: Int { + let width = self.shareCollectionView.frame.size.width + let itemCount = self.shareItemController.shareItems.count - if currentIndex >= self.shareItemController.shareItems.count { - return nil - } + guard width > 0, itemCount > 0 else { return 0 } - return self.shareItemController.shareItems[currentIndex] + // see: https://stackoverflow.com/a/46181277/2512312 + return max(0, min(Int((self.shareCollectionView.contentOffset.x / width).rounded()), itemCount - 1)) } - // MARK: - PageControl + func getCurrentShareItem() -> ShareItem? { + let shareItems = self.shareItemController.shareItems + + if shareItems.isEmpty { + return nil + } - func pageControlValueChanged() { - let indexPath = IndexPath(row: self.pageControl.currentPage, section: 0) - self.shareCollectionView.scrollToItem(at: indexPath, at: [], animated: true) + return shareItems[self.currentPageIndex] } - func updatePageControlPage() { - // see: https://stackoverflow.com/a/46181277/2512312 + func updateCurrentPage() { DispatchQueue.main.async { - self.pageControl.currentPage = Int(self.shareCollectionView.contentOffset.x / self.shareCollectionView.frame.width) - self.updateToolbarForCurrentItem() + self.updateToolsForCurrentItem() } } @@ -1220,9 +1255,8 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 // Make sure all changes are fully populated before we update our UI elements self.shareCollectionView.layoutIfNeeded() - self.updateToolbarForCurrentItem() + self.updateToolsForCurrentItem() self.updateOptionsView() - self.pageControl.numberOfPages = shareItemController.shareItems.count // Update the text input to check if sending is (not-)possible self.textDidUpdate(false) @@ -1267,3 +1301,25 @@ private let kShareConfirmationOptionsViewHeight: CGFloat = 44 } } + +#if !APP_EXTENSION +// MARK: - InAppCameraViewController Delegate + +extension ShareConfirmationViewController: InAppCameraViewControllerDelegate { + + // The camera does not close itself, that is up to us. The shot becomes one more item of the share and + // the screen stays open, showing it. + func inAppCameraViewController(_ controller: InAppCameraViewController, didCaptureMediaAt fileURL: URL) { + controller.dismiss(animated: true) { [weak self] in + guard let self else { return } + + self.shareItemController.addItem(with: fileURL) + self.collectionViewScrollToEnd() + } + } + + func inAppCameraViewControllerDidCancel(_ controller: InAppCameraViewController) { + controller.dismiss(animated: true) + } +} +#endif diff --git a/ShareExtension/ShareItemController.m b/ShareExtension/ShareItemController.m index aac6849f9..cb2749d4a 100644 --- a/ShareExtension/ShareItemController.m +++ b/ShareExtension/ShareItemController.m @@ -12,6 +12,11 @@ // be encoded here. The sender picks the quality later on, so nothing is thrown away yet. CGFloat const kShareItemControllerImageQuality = 1.0f; +// An edited (cropped or rotated) photo is stored as JPEG with this quality, a PNG stays a PNG. +// Its longest side is limited, so a crop of a huge photo stays a reasonable file. +static CGFloat const kShareItemControllerEditedImageQuality = 0.95f; +static CGFloat const kShareItemControllerEditedImageMaxPixelSize = 4096.0f; + @interface ShareItemController () @property (nonatomic, strong) NSString *tempDirectoryPath; @@ -200,14 +205,45 @@ - (void)updateItem:(ShareItem *)item withURL:(NSURL *)fileURL - (void)updateItem:(ShareItem *)item withImage:(UIImage *)image { - NSData *jpegData = UIImageJPEGRepresentation(image, kShareItemControllerImageQuality); - [jpegData writeToFile:item.filePath atomically:YES]; + UIImage *limitedImage = [self image:image limitedToMaxPixelSize:kShareItemControllerEditedImageMaxPixelSize]; + NSData *imageData; + + if ([[item.filePath.pathExtension lowercaseString] isEqualToString:@"png"]) { + imageData = UIImagePNGRepresentation(limitedImage); + } else { + imageData = UIImageJPEGRepresentation(limitedImage, kShareItemControllerEditedImageQuality); + } + + [imageData writeToFile:item.filePath atomically:YES]; NSLog(@"Updating shareItem with Image: %@ %@", item.fileName, item.fileURL); [self.delegate shareItemControllerItemsChanged:self]; } +- (UIImage *)image:(UIImage *)image limitedToMaxPixelSize:(CGFloat)maxPixelSize +{ + CGFloat pixelWidth = image.size.width * image.scale; + CGFloat pixelHeight = image.size.height * image.scale; + CGFloat longestSide = MAX(pixelWidth, pixelHeight); + + if (longestSide <= maxPixelSize) { + return image; + } + + CGFloat ratio = maxPixelSize / longestSide; + CGSize targetSize = CGSizeMake(floor(pixelWidth * ratio), floor(pixelHeight * ratio)); + + UIGraphicsImageRendererFormat *format = [UIGraphicsImageRendererFormat preferredFormat]; + format.scale = 1; + + UIGraphicsImageRenderer *renderer = [[UIGraphicsImageRenderer alloc] initWithSize:targetSize format:format]; + + return [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull context) { + [image drawInRect:CGRectMake(0, 0, targetSize.width, targetSize.height)]; + }]; +} + - (void)removeItem:(ShareItem *)item { [self removeItems:@[item]]; From 61ff453f0b49f82b14b193ddd4f6789105aded22 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:15:55 +0000 Subject: [PATCH 3/7] feat(chat): Add attachment sheet with recent photos and videos Tapping "+" in the chat input opens a sheet instead of the popup menu. It shows a grid of the most recent photos and videos of the photo library with a live camera tile, and the other ways to share: files, Nextcloud files, location, contact, poll, thread and GIF (Giphy) as before. - Photos and videos are selected in the grid (up to 10) and sent through the upload confirmation, with the text of the input field as caption. HEIC photos are converted to JPEG. - The camera tile opens the in-app camera, it stops with the sheet. - With limited access to the photo library the sheet offers to select more photos, without access to open the settings. - A long press on "+" still opens the photo picker directly, and the button still cancels while a message is edited. - The popup menu of the "+" button and its handling are removed. - No photo library alert for limited access is shown by the system (PHPhotoLibraryPreventAutomaticLimitedAccessAlert), the sheet has its own entry for that. Unit tests cover the actions of the sheet and the exported file names. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../AttachmentAssetExporter.swift | 195 +++++++ .../AttachmentCameraPreviewSession.swift | 78 +++ .../AttachmentGridViewController.swift | 499 ++++++++++++++++++ .../AttachmentSheetAction.swift | 147 ++++++ .../AttachmentSheetCells.swift | 257 +++++++++ .../AttachmentSheetViewController.swift | 418 +++++++++++++++ ...seChatViewController+AttachmentSheet.swift | 86 +++ .../Chat/BaseChatViewController.swift | 120 +---- NextcloudTalk/Info.plist | 2 + NextcloudTalk/en.lproj/Localizable.strings | 42 ++ .../en.lproj/Localizable.stringsdict | 32 ++ .../Chat/UnitAttachmentSheetActionTest.swift | 78 +++ .../UnitAttachmentSheetExportNameTest.swift | 49 ++ 13 files changed, 1888 insertions(+), 115 deletions(-) create mode 100644 NextcloudTalk/Chat/Attachment sheet/AttachmentAssetExporter.swift create mode 100644 NextcloudTalk/Chat/Attachment sheet/AttachmentCameraPreviewSession.swift create mode 100644 NextcloudTalk/Chat/Attachment sheet/AttachmentGridViewController.swift create mode 100644 NextcloudTalk/Chat/Attachment sheet/AttachmentSheetAction.swift create mode 100644 NextcloudTalk/Chat/Attachment sheet/AttachmentSheetCells.swift create mode 100644 NextcloudTalk/Chat/Attachment sheet/AttachmentSheetViewController.swift create mode 100644 NextcloudTalk/Chat/Attachment sheet/BaseChatViewController+AttachmentSheet.swift create mode 100644 NextcloudTalkTests/Unit/Chat/UnitAttachmentSheetActionTest.swift create mode 100644 NextcloudTalkTests/Unit/Chat/UnitAttachmentSheetExportNameTest.swift diff --git a/NextcloudTalk/Chat/Attachment sheet/AttachmentAssetExporter.swift b/NextcloudTalk/Chat/Attachment sheet/AttachmentAssetExporter.swift new file mode 100644 index 000000000..81312e5e4 --- /dev/null +++ b/NextcloudTalk/Chat/Attachment sheet/AttachmentAssetExporter.swift @@ -0,0 +1,195 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import ImageIO +import Photos +import UniformTypeIdentifiers + +/// Writes photos and videos of the photo library into files, so they can be handed to the upload confirmation. +/// +/// The original resource of an asset is written, not a rendition: an animated GIF stays a GIF, a PNG stays a PNG +/// and a video stays a MOV. Edits made in the Photos app are kept by using the edited resource. +/// HEIC and HEIF photos are the exception, they are written as JPEG, as the server does not make previews of them. +enum AttachmentAssetExporter { + + struct ExportedFile { + let url: URL + let fileName: String + } + + enum ExportError: LocalizedError { + case noResource(fileName: String) + case failed(fileName: String, underlyingError: Error) + + var errorDescription: String? { + switch self { + case .noResource(let fileName): + return String(format: NSLocalizedString("“%@” could not be read from the photo library", comment: "File name"), fileName) + case .failed(let fileName, let underlyingError): + return String(format: NSLocalizedString("“%1$@” could not be prepared: %2$@", comment: "File name, error description"), + fileName, underlyingError.localizedDescription) + } + } + } + + /// Where all exports are written to. Every export gets a folder of its own below it. + static var exportsDirectory: URL { + return FileManager.default.temporaryDirectory.appendingPathComponent("AttachmentSheet", isDirectory: true) + } + + /// Creates the folder for the files of one send. + static func makeExportDirectory() throws -> URL { + let directory = self.exportsDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory + } + + /// Removes exports of earlier sends. Files younger than `age` are kept, as an upload might still read them. + static func removeOldExports(olderThan age: TimeInterval = 24 * 60 * 60) { + let fileManager = FileManager.default + let keys: [URLResourceKey] = [.creationDateKey] + + guard let folders = try? fileManager.contentsOfDirectory(at: self.exportsDirectory, includingPropertiesForKeys: keys) else { return } + + for folder in folders { + let created = (try? folder.resourceValues(forKeys: Set(keys)))?.creationDate ?? .distantPast + + if Date().timeIntervalSince(created) > age { + try? fileManager.removeItem(at: folder) + } + } + } + + // MARK: - Naming and resource choice + + /// The edited resource of an asset if it has one, otherwise the original. + static func preferredResource(in resources: [PHAssetResource], isVideo: Bool) -> PHAssetResource? { + let types: [PHAssetResourceType] = isVideo ? [.fullSizeVideo, .video] : [.fullSizePhoto, .photo] + + for type in types { + if let resource = resources.first(where: { $0.type == type }) { + return resource + } + } + + return nil + } + + /// The name of the file in the conversation: the name the asset was created with (like `IMG_0042`), and the + /// type of the data that is exported. Both can differ, for example when a HEIC photo was edited. + static func exportFileName(originalFileName: String, resourceFileName: String, uniformTypeIdentifier: String?, isVideo: Bool) -> String { + let baseName = (originalFileName as NSString).deletingPathExtension + var fileExtension = (resourceFileName as NSString).pathExtension + + if fileExtension.isEmpty, let uniformTypeIdentifier, let type = UTType(uniformTypeIdentifier) { + fileExtension = type.preferredFilenameExtension ?? "" + } + + if fileExtension.isEmpty { + fileExtension = (originalFileName as NSString).pathExtension + } + + if fileExtension.isEmpty { + fileExtension = isVideo ? "mov" : "jpg" + } + + var name = baseName + + if name.isEmpty { + name = "\(isVideo ? "VID" : "IMG")_\(Int(Date().timeIntervalSince1970 * 1000))" + } + + return "\(name).\(fileExtension)" + } + + /// Whether a photo of this type is converted to JPEG, which is the case for HEIC and HEIF + static func needsJPEGConversion(uniformTypeIdentifier: String?) -> Bool { + guard let uniformTypeIdentifier, let type = UTType(uniformTypeIdentifier) else { return false } + + return type.conforms(to: .heic) || type.conforms(to: .heif) + } + + /// The name of a file with the extension changed to jpg + static func jpegFileName(for fileName: String) -> String { + return (fileName as NSString).deletingPathExtension + ".jpg" + } + + /// Writes a JPEG of an image file. Orientation and metadata of the image are kept. + static func writeJPEG(from sourceURL: URL, to destinationURL: URL) throws { + guard let source = CGImageSourceCreateWithURL(sourceURL as CFURL, nil), + CGImageSourceGetCount(source) > 0, + let destination = CGImageDestinationCreateWithURL(destinationURL as CFURL, UTType.jpeg.identifier as CFString, 1, nil) + else { throw CocoaError(.fileReadCorruptFile) } + + CGImageDestinationAddImageFromSource(destination, source, 0, [kCGImageDestinationLossyCompressionQuality: 0.9] as CFDictionary) + + guard CGImageDestinationFinalize(destination) else { throw CocoaError(.fileWriteUnknown) } + } + + // MARK: - Export + + /// Writes the data of an asset into a new file below `directory`. Assets that live in iCloud are downloaded. + /// + /// - Parameter progress: Called on the main queue with the fraction of the download that is done + /// - Throws: `ExportError`, when the data could not be read or written + static func export(_ asset: PHAsset, into directory: URL, progress: ((Double) -> Void)? = nil) async throws -> ExportedFile { + let isVideo = asset.mediaType == .video + let resources = PHAssetResource.assetResources(for: asset) + let originalType: PHAssetResourceType = isVideo ? .video : .photo + let originalResource = resources.first(where: { $0.type == originalType }) + let displayName = originalResource?.originalFilename ?? resources.first?.originalFilename ?? asset.localIdentifier + + guard let resource = self.preferredResource(in: resources, isVideo: isVideo) else { + throw ExportError.noResource(fileName: displayName) + } + + let resourceFileName = self.exportFileName(originalFileName: originalResource?.originalFilename ?? resource.originalFilename, + resourceFileName: resource.originalFilename, + uniformTypeIdentifier: resource.uniformTypeIdentifier, + isVideo: isVideo) + + let convertsToJPEG = !isVideo && self.needsJPEGConversion(uniformTypeIdentifier: resource.uniformTypeIdentifier) + let fileName = convertsToJPEG ? self.jpegFileName(for: resourceFileName) : resourceFileName + + // A folder per asset, as two assets can have the same file name + let assetDirectory = directory.appendingPathComponent(UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: assetDirectory, withIntermediateDirectories: true) + let fileURL = assetDirectory.appendingPathComponent(fileName) + + // The data is written as it is, and converted afterwards when needed + let writtenURL = convertsToJPEG ? assetDirectory.appendingPathComponent("source-" + resourceFileName) : fileURL + + let options = PHAssetResourceRequestOptions() + options.isNetworkAccessAllowed = true + + if let progress { + options.progressHandler = { fraction in + DispatchQueue.main.async { progress(fraction) } + } + } + + do { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + PHAssetResourceManager.default().writeData(for: resource, toFile: writtenURL, options: options) { error in + if let error { + continuation.resume(throwing: error) + } else { + continuation.resume() + } + } + } + + if convertsToJPEG { + try self.writeJPEG(from: writtenURL, to: fileURL) + try? FileManager.default.removeItem(at: writtenURL) + } + } catch { + try? FileManager.default.removeItem(at: assetDirectory) + throw ExportError.failed(fileName: fileName, underlyingError: error) + } + + return ExportedFile(url: fileURL, fileName: fileName) + } +} diff --git a/NextcloudTalk/Chat/Attachment sheet/AttachmentCameraPreviewSession.swift b/NextcloudTalk/Chat/Attachment sheet/AttachmentCameraPreviewSession.swift new file mode 100644 index 000000000..cf18c9ada --- /dev/null +++ b/NextcloudTalk/Chat/Attachment sheet/AttachmentCameraPreviewSession.swift @@ -0,0 +1,78 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation + +/// A light capture session for the live camera tile of the attachment sheet: video only, no audio, no output. +/// The camera of the chat has its own session, this one is stopped before the camera is opened. +/// +/// The session is started and stopped on a queue of its own, as `startRunning()` blocks. +final class AttachmentCameraPreviewSession { + + let session = AVCaptureSession() + + private let queue = DispatchQueue(label: "\(Bundle.main.bundleIdentifier ?? "talk").attachmentSheet.cameraPreview") + private var isConfigured = false + + /// Whether the user allowed the camera already. The permission is never asked for here, only when the camera is opened. + static var isCameraAuthorized: Bool { + return AVCaptureDevice.authorizationStatus(for: .video) == .authorized + } + + /// The camera is in use during a call + @MainActor + static var isCameraBusyWithCall: Bool { + return NCRoomsManager.shared.callViewController != nil + } + + func start() { + self.queue.async { [weak self] in + guard let self, self.configureIfNeeded() else { return } + + if !self.session.isRunning { + self.session.startRunning() + } + } + } + + /// - Parameter completion: Called on the main queue once the session is stopped + func stop(completion: (() -> Void)? = nil) { + // Strong on purpose: the session needs to be stopped even if its owner is gone by the time the queue gets to it + self.queue.async { + if self.session.isRunning { + self.session.stopRunning() + } + + if let completion { + DispatchQueue.main.async(execute: completion) + } + } + } + + /// Needs to run on `queue` + private func configureIfNeeded() -> Bool { + if self.isConfigured { + return true + } + + guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) + ?? AVCaptureDevice.default(for: .video), + let input = try? AVCaptureDeviceInput(device: camera) + else { return false } + + self.session.beginConfiguration() + defer { self.session.commitConfiguration() } + + if self.session.canSetSessionPreset(.medium) { + self.session.sessionPreset = .medium + } + + guard self.session.canAddInput(input) else { return false } + self.session.addInput(input) + + self.isConfigured = true + return true + } +} diff --git a/NextcloudTalk/Chat/Attachment sheet/AttachmentGridViewController.swift b/NextcloudTalk/Chat/Attachment sheet/AttachmentGridViewController.swift new file mode 100644 index 000000000..ac20693b3 --- /dev/null +++ b/NextcloudTalk/Chat/Attachment sheet/AttachmentGridViewController.swift @@ -0,0 +1,499 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import Photos +import Toast +import UIKit + +@MainActor +protocol AttachmentGridViewControllerDelegate: AnyObject { + func attachmentGridDidChangeSelection(_ grid: AttachmentGridViewController) + func attachmentGridDidTapCamera(_ grid: AttachmentGridViewController) +} + +/// The recent photos and videos of the library in a grid of three columns, with the live camera as first tile. +/// Asks for access to the library when it is shown, and adapts to limited access and to no access. +final class AttachmentGridViewController: UIViewController, + UICollectionViewDataSource, + UICollectionViewDelegate, + UICollectionViewDataSourcePrefetching, + PHPhotoLibraryChangeObserver { + + /// The most items that can be selected at once + static let maxSelection = kShareConfirmationMaxItems + + private static let columns = 3 + private static let spacing: CGFloat = 2 + + weak var delegate: AttachmentGridViewControllerDelegate? + + /// Whether the first tile opens the camera + var showsCameraTile = false { + didSet { + guard self.showsCameraTile != oldValue, self.isViewLoaded else { return } + self.collectionView.reloadData() + } + } + + /// The session to show in the camera tile. Without a session, the tile shows an icon. + var cameraSession: AVCaptureSession? { + didSet { self.updateCameraTile() } + } + + /// The selected items, in the order they were selected in + private(set) var selectedAssets: [PHAsset] = [] + + private var fetchResult: PHFetchResult? + private let imageManager = PHCachingImageManager() + private var isObservingLibrary = false + private var isRequestingAuthorization = false + + private lazy var thumbnailOptions: PHImageRequestOptions = { + let options = PHImageRequestOptions() + options.deliveryMode = .opportunistic + options.resizeMode = .fast + options.isNetworkAccessAllowed = true + return options + }() + + // MARK: - Views + + private lazy var collectionView: UICollectionView = { + let collectionView = UICollectionView(frame: .zero, collectionViewLayout: self.makeLayout()) + collectionView.backgroundColor = .clear + collectionView.dataSource = self + collectionView.delegate = self + collectionView.prefetchDataSource = self + collectionView.alwaysBounceVertical = true + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.register(AttachmentAssetCell.self, forCellWithReuseIdentifier: AttachmentAssetCell.reuseIdentifier) + collectionView.register(AttachmentCameraCell.self, forCellWithReuseIdentifier: AttachmentCameraCell.reuseIdentifier) + collectionView.accessibilityIdentifier = "attachmentSheetGrid" + return collectionView + }() + + private lazy var limitedAccessBanner: UIView = { + let label = UILabel() + label.text = NSLocalizedString("You allowed access to some of your photos only", comment: "Shown in the attachment sheet when the access to the photo library is limited") + label.font = UIFont.preferredFont(forTextStyle: .footnote) + label.adjustsFontForContentSizeCategory = true + label.textColor = .secondaryLabel + label.numberOfLines = 0 + + var configuration = UIButton.Configuration.plain() + configuration.title = NSLocalizedString("Select more photos", comment: "Opens the system picker to allow access to more photos") + configuration.contentInsets = NSDirectionalEdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 0) + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = UIFont.preferredFont(forTextStyle: .footnote) + return attributes + } + + let button = UIButton(configuration: configuration) + button.addAction(UIAction { [weak self] _ in self?.presentLimitedLibraryPicker() }, for: .touchUpInside) + button.accessibilityIdentifier = "attachmentSheetSelectMorePhotos" + button.setContentHuggingPriority(.required, for: .horizontal) + button.setContentCompressionResistancePriority(.required, for: .horizontal) + + let stackView = UIStackView(arrangedSubviews: [label, button]) + stackView.axis = .horizontal + stackView.alignment = .center + stackView.spacing = 8 + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = UIEdgeInsets(top: 4, left: 16, bottom: 4, right: 12) + stackView.isHidden = true + + return stackView + }() + + private let deniedLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.textColor = .secondaryLabel + label.textAlignment = .center + label.numberOfLines = 0 + return label + }() + + private lazy var allowAccessButton: UIButton = { + var configuration = UIButton.Configuration.tinted() + configuration.title = NSLocalizedString("Allow access", comment: "Button to allow access to the photo library in the settings") + configuration.cornerStyle = .capsule + + let button = UIButton(configuration: configuration) + button.addAction(UIAction { _ in + if let url = URL(string: UIApplication.openSettingsURLString) { + UIApplication.shared.open(url) + } + }, for: .touchUpInside) + button.accessibilityIdentifier = "attachmentSheetAllowAccess" + return button + }() + + private lazy var deniedView: UIView = { + let imageView = UIImageView(image: UIImage(systemName: "photo.on.rectangle.angled")) + imageView.tintColor = .secondaryLabel + imageView.contentMode = .scaleAspectFit + imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 40, weight: .light) + imageView.isAccessibilityElement = false + + let stackView = UIStackView(arrangedSubviews: [imageView, self.deniedLabel, self.allowAccessButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .center + stackView.spacing = 12 + + let container = UIView() + container.addSubview(stackView) + container.isHidden = true + + NSLayoutConstraint.activate([ + stackView.centerYAnchor.constraint(equalTo: container.centerYAnchor), + stackView.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 32), + stackView.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -32) + ]) + + return container + }() + + // MARK: - Lifecycle + + deinit { + if self.isObservingLibrary { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + } + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = .clear + + let contentStack = UIStackView(arrangedSubviews: [self.limitedAccessBanner, self.collectionView]) + contentStack.translatesAutoresizingMaskIntoConstraints = false + contentStack.axis = .vertical + self.limitedAccessBanner.setContentHuggingPriority(.required, for: .vertical) + self.collectionView.setContentHuggingPriority(.defaultLow, for: .vertical) + + self.deniedView.translatesAutoresizingMaskIntoConstraints = false + + self.view.addSubview(contentStack) + self.view.addSubview(self.deniedView) + + NSLayoutConstraint.activate([ + contentStack.topAnchor.constraint(equalTo: self.view.topAnchor), + contentStack.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + contentStack.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + contentStack.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + + self.deniedView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.deniedView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.deniedView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.deniedView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + + // The access can be changed in the settings while the sheet is open + NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + + self.refreshAuthorization() + } + + override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + self.imageManager.stopCachingImagesForAllAssets() + + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.updateCameraTile() + } + } + + @objc private func applicationDidBecomeActive() { + self.refreshAuthorization() + } + + // MARK: - Photo library access + + /// Shows the grid, or what replaces it, depending on what the user allowed. Asks for access when that + /// was not decided yet. + private func refreshAuthorization() { + let status = PHPhotoLibrary.authorizationStatus(for: .readWrite) + + switch status { + case .notDetermined: + guard !self.isRequestingAuthorization else { return } + self.isRequestingAuthorization = true + + PHPhotoLibrary.requestAuthorization(for: .readWrite) { _ in + DispatchQueue.main.async { [weak self] in + self?.isRequestingAuthorization = false + self?.refreshAuthorization() + } + } + case .authorized: + self.showGrid(isLimited: false) + case .limited: + self.showGrid(isLimited: true) + case .denied: + self.showNoAccess(isRestricted: false) + case .restricted: + self.showNoAccess(isRestricted: true) + @unknown default: + self.showNoAccess(isRestricted: false) + } + } + + private func showGrid(isLimited: Bool) { + self.deniedView.isHidden = true + self.collectionView.isHidden = false + self.limitedAccessBanner.isHidden = !isLimited + + if !self.isObservingLibrary { + PHPhotoLibrary.shared().register(self) + self.isObservingLibrary = true + } + + if self.fetchResult == nil { + self.fetchAssets() + } + } + + private func showNoAccess(isRestricted: Bool) { + if self.isObservingLibrary { + PHPhotoLibrary.shared().unregisterChangeObserver(self) + self.isObservingLibrary = false + } + + self.fetchResult = nil + self.imageManager.stopCachingImagesForAllAssets() + self.setSelectedAssets([]) + + if isRestricted { + self.deniedLabel.text = NSLocalizedString("Access to your photos is restricted on this device", comment: "Shown in the attachment sheet when the photo library can not be used") + } else { + self.deniedLabel.text = NSLocalizedString("Allow access to your photos to see your recent photos and videos here", comment: "Shown in the attachment sheet when the access to the photo library was denied") + } + + self.allowAccessButton.isHidden = isRestricted + self.collectionView.reloadData() + self.collectionView.isHidden = true + self.limitedAccessBanner.isHidden = true + self.deniedView.isHidden = false + } + + private func fetchAssets() { + let options = PHFetchOptions() + options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)] + options.predicate = NSPredicate(format: "mediaType == %d OR mediaType == %d", + PHAssetMediaType.image.rawValue, PHAssetMediaType.video.rawValue) + + self.fetchResult = PHAsset.fetchAssets(with: options) + self.collectionView.reloadData() + } + + private func presentLimitedLibraryPicker() { + PHPhotoLibrary.shared().presentLimitedLibraryPicker(from: self) + } + + nonisolated func photoLibraryDidChange(_ changeInstance: PHChange) { + DispatchQueue.main.async { [weak self] in + self?.handleLibraryChange(changeInstance) + } + } + + private func handleLibraryChange(_ change: PHChange) { + guard let fetchResult, let details = change.changeDetails(for: fetchResult) else { return } + + self.fetchResult = details.fetchResultAfterChanges + + // Selected items can be edited or deleted in the meantime + var newSelection: [PHAsset] = [] + + for asset in self.selectedAssets { + guard let assetDetails = change.changeDetails(for: asset) else { + newSelection.append(asset) + continue + } + + if assetDetails.objectWasDeleted { + continue + } + + let updatedAsset: PHAsset? = assetDetails.objectAfterChanges + newSelection.append(updatedAsset ?? asset) + } + + self.imageManager.stopCachingImagesForAllAssets() + self.setSelectedAssets(newSelection) + self.collectionView.reloadData() + } + + // MARK: - Selection + + private func setSelectedAssets(_ assets: [PHAsset]) { + let changed = assets.map { $0.localIdentifier } != self.selectedAssets.map { $0.localIdentifier } + self.selectedAssets = assets + + if changed { + self.refreshVisibleSelectionStates() + self.delegate?.attachmentGridDidChangeSelection(self) + } + } + + /// Forgets the selection, for example after the items were sent + func clearSelection() { + self.setSelectedAssets([]) + } + + private func selectionIndex(of assetIdentifier: String) -> Int? { + guard let index = self.selectedAssets.firstIndex(where: { $0.localIdentifier == assetIdentifier }) else { return nil } + return index + 1 + } + + private func refreshVisibleSelectionStates() { + for case let cell as AttachmentAssetCell in self.collectionView.visibleCells { + if let identifier = cell.representedAssetIdentifier { + cell.setSelectionIndex(self.selectionIndex(of: identifier)) + } + } + } + + private func toggleSelection(of asset: PHAsset) { + var newSelection = self.selectedAssets + + if let index = newSelection.firstIndex(where: { $0.localIdentifier == asset.localIdentifier }) { + newSelection.remove(at: index) + } else if newSelection.count >= Self.maxSelection { + let message = String.localizedStringWithFormat(NSLocalizedString("You can select up to %ld items", comment: "Shown when more photos and videos are selected than can be sent at once"), Self.maxSelection) + + UINotificationFeedbackGenerator().notificationOccurred(.warning) + UIAccessibility.post(notification: .announcement, argument: message) + self.view.makeToast(message, duration: 2, point: CGPoint(x: self.view.bounds.midX, y: self.view.bounds.midY), title: nil, image: nil, completion: nil) + return + } else { + newSelection.append(asset) + } + + self.setSelectedAssets(newSelection) + } + + // MARK: - Camera tile + + private func updateCameraTile() { + guard self.isViewLoaded, self.showsCameraTile, + let cell = self.collectionView.cellForItem(at: IndexPath(item: 0, section: 0)) as? AttachmentCameraCell + else { return } + + cell.configure(session: self.cameraSession, interfaceOrientation: CameraCaptureHelpers.interfaceOrientation(of: self.view)) + } + + // MARK: - Layout + + private func makeLayout() -> UICollectionViewLayout { + let spacing = Self.spacing + let itemSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0 / CGFloat(Self.columns)), heightDimension: .fractionalHeight(1.0)) + let item = NSCollectionLayoutItem(layoutSize: itemSize) + + let groupSize = NSCollectionLayoutSize(widthDimension: .fractionalWidth(1.0), heightDimension: .fractionalWidth(1.0 / CGFloat(Self.columns))) + let group = NSCollectionLayoutGroup.horizontal(layoutSize: groupSize, subitem: item, count: Self.columns) + group.interItemSpacing = .fixed(spacing) + + let section = NSCollectionLayoutSection(group: group) + section.interGroupSpacing = spacing + section.contentInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 8, trailing: 0) + + return UICollectionViewCompositionalLayout(section: section) + } + + /// The size thumbnails are requested in, in pixels. Prefetching needs the same size as the cells, or the cache is not used. + private var thumbnailSize: CGSize { + let width = (self.collectionView.bounds.width - Self.spacing * CGFloat(Self.columns - 1)) / CGFloat(Self.columns) + let side = max(width, 80) * self.traitCollection.displayScale + return CGSize(width: side, height: side) + } + + // MARK: - Collection view + + private var cameraTileCount: Int { + return self.showsCameraTile ? 1 : 0 + } + + private func asset(at indexPath: IndexPath) -> PHAsset? { + let index = indexPath.item - self.cameraTileCount + + guard index >= 0, let fetchResult, index < fetchResult.count else { return nil } + return fetchResult.object(at: index) + } + + func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { + guard self.fetchResult != nil else { return 0 } + return self.cameraTileCount + (self.fetchResult?.count ?? 0) + } + + func collectionView(_ collectionView: UICollectionView, cellForItemAt indexPath: IndexPath) -> UICollectionViewCell { + if self.showsCameraTile, indexPath.item == 0 { + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AttachmentCameraCell.reuseIdentifier, for: indexPath) + + if let cameraCell = cell as? AttachmentCameraCell { + cameraCell.configure(session: self.cameraSession, interfaceOrientation: CameraCaptureHelpers.interfaceOrientation(of: self.view)) + } + + return cell + } + + let cell = collectionView.dequeueReusableCell(withReuseIdentifier: AttachmentAssetCell.reuseIdentifier, for: indexPath) + + guard let assetCell = cell as? AttachmentAssetCell, let asset = self.asset(at: indexPath) else { return cell } + + let identifier = asset.localIdentifier + assetCell.representedAssetIdentifier = identifier + assetCell.configure(for: asset) + assetCell.setSelectionIndex(self.selectionIndex(of: identifier)) + + assetCell.imageRequestID = self.imageManager.requestImage(for: asset, + targetSize: self.thumbnailSize, + contentMode: .aspectFill, + options: self.thumbnailOptions) { [weak assetCell] image, _ in + guard let assetCell, assetCell.representedAssetIdentifier == identifier else { return } + assetCell.setThumbnail(image) + } + + return assetCell + } + + func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { + if let assetCell = cell as? AttachmentAssetCell, assetCell.imageRequestID != PHInvalidImageRequestID { + self.imageManager.cancelImageRequest(assetCell.imageRequestID) + assetCell.imageRequestID = PHInvalidImageRequestID + } + } + + func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { + collectionView.deselectItem(at: indexPath, animated: false) + + if self.showsCameraTile, indexPath.item == 0 { + self.delegate?.attachmentGridDidTapCamera(self) + return + } + + if let asset = self.asset(at: indexPath) { + self.toggleSelection(of: asset) + } + } + + func collectionView(_ collectionView: UICollectionView, prefetchItemsAt indexPaths: [IndexPath]) { + let assets = indexPaths.compactMap { self.asset(at: $0) } + + self.imageManager.startCachingImages(for: assets, targetSize: self.thumbnailSize, contentMode: .aspectFill, options: self.thumbnailOptions) + } + + func collectionView(_ collectionView: UICollectionView, cancelPrefetchingForItemsAt indexPaths: [IndexPath]) { + let assets = indexPaths.compactMap { self.asset(at: $0) } + + self.imageManager.stopCachingImages(for: assets, targetSize: self.thumbnailSize, contentMode: .aspectFill, options: self.thumbnailOptions) + } +} diff --git a/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetAction.swift b/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetAction.swift new file mode 100644 index 000000000..328231791 --- /dev/null +++ b/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetAction.swift @@ -0,0 +1,147 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +/// The items of the strip at the bottom of the attachment sheet. They are the entries the "+" menu of the chat +/// had before the sheet replaced it, in the order they are shown in the strip. +enum AttachmentSheetAction: CaseIterable { + case camera + case photoLibrary + case giphy + case files + case nextcloudFiles + case thread + case poll + case location + case contact + + /// Everything that decides which items are available. Kept apart from the database and the room, so the + /// rules can be tested without them. + struct Context { + var isCameraAvailable: Bool + var isGiphyAvailable: Bool + var hasThreadsCapability: Bool + var hasPollsCapability: Bool + var hasLocationCapability: Bool + var roomType: NCRoomType + var isInThread: Bool + } + + /// The items to show, in the order of the strip. + static func availableActions(in context: Context) -> [AttachmentSheetAction] { + var actions: [AttachmentSheetAction] = [] + + if context.isCameraAvailable { + actions.append(.camera) + } + + actions.append(.photoLibrary) + + if context.isGiphyAvailable { + actions.append(.giphy) + } + + actions.append(.files) + actions.append(.nextcloudFiles) + + // Rich objects and polls can not be shared in threads yet. Remove this check when they can. + if !context.isInThread { + if context.hasThreadsCapability { + actions.append(.thread) + } + + if context.hasPollsCapability, context.roomType != .oneToOne, context.roomType != .noteToSelf { + actions.append(.poll) + } + + if context.hasLocationCapability { + actions.append(.location) + } + + actions.append(.contact) + } + + return actions + } + + /// The items to show for a conversation, based on what the server and the conversation allow. + @MainActor + static func availableActions(room: NCRoom, account: TalkAccount, thread: NCThread?) -> [AttachmentSheetAction] { + let databaseManager = NCDatabaseManager.sharedInstance() + let serverCapabilities = databaseManager.serverCapabilities(forAccountId: account.accountId) + + let context = Context(isCameraAvailable: InAppCameraViewController.isCameraAvailable, + isGiphyAvailable: serverCapabilities?.giphyEnabled == true && serverCapabilities?.giphyConfigured == true, + hasThreadsCapability: databaseManager.roomHasTalkCapability(.threads, for: room), + hasPollsCapability: databaseManager.roomHasTalkCapability(.talkPolls, for: room), + hasLocationCapability: databaseManager.roomHasTalkCapability(.locationSharing, for: room), + roomType: room.type, + isInThread: thread != nil) + + return self.availableActions(in: context) + } + + var title: String { + switch self { + case .camera: + return NSLocalizedString("Camera", comment: "") + case .photoLibrary: + return NSLocalizedString("Photo Library", comment: "") + case .giphy: + // Not localized: "GIF" is a file format and "Giphy" a company name + return "GIF (Giphy)" + case .files: + return NSLocalizedString("Files", comment: "") + case .nextcloudFiles: + return filesAppName + case .thread: + return NSLocalizedString("Thread", comment: "Context menu action to reply to a message in a thread") + case .poll: + return NSLocalizedString("Poll", comment: "") + case .location: + return NSLocalizedString("Location", comment: "") + case .contact: + return NSLocalizedString("Contacts", comment: "") + } + } + + var image: UIImage? { + switch self { + case .camera: + return UIImage(systemName: "camera") + case .photoLibrary: + return UIImage(systemName: "photo") + case .giphy: + return UIImage(systemName: "play.square.stack") + case .files: + return UIImage(systemName: "doc") + case .nextcloudFiles: + return UIImage(named: "logo-action")?.withRenderingMode(.alwaysTemplate) + case .thread: + return UIImage(systemName: "bubble.left.and.bubble.right") + case .poll: + return UIImage(systemName: "chart.bar") + case .location: + return UIImage(systemName: "location") + case .contact: + return UIImage(systemName: "person") + } + } + + var accessibilityIdentifier: String { + switch self { + case .camera: return "attachmentSheetCamera" + case .photoLibrary: return "attachmentSheetPhotoLibrary" + case .giphy: return "attachmentSheetGiphy" + case .files: return "attachmentSheetFiles" + case .nextcloudFiles: return "attachmentSheetNextcloudFiles" + case .thread: return "attachmentSheetThread" + case .poll: return "attachmentSheetPoll" + case .location: return "attachmentSheetLocation" + case .contact: return "attachmentSheetContact" + } + } +} diff --git a/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetCells.swift b/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetCells.swift new file mode 100644 index 000000000..0e883fcf4 --- /dev/null +++ b/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetCells.swift @@ -0,0 +1,257 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import AVFoundation +import Photos +import UIKit + +/// A photo or a video of the grid, with a circle that shows its place in the selection +final class AttachmentAssetCell: UICollectionViewCell { + + static let reuseIdentifier = "AttachmentAssetCell" + + private static let circleSize: CGFloat = 26 + + /// Which asset the cell shows, to ignore thumbnails that arrive after the cell was reused + var representedAssetIdentifier: String? + var imageRequestID = PHInvalidImageRequestID + + private let imageView: UIImageView = { + let imageView = UIImageView() + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.backgroundColor = .secondarySystemFill + imageView.isAccessibilityElement = false + return imageView + }() + + private let dimView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.backgroundColor = UIColor.black.withAlphaComponent(0.25) + view.isHidden = true + return view + }() + + private let durationLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFont.preferredFont(forTextStyle: .caption1) + label.adjustsFontForContentSizeCategory = true + label.textColor = .white + label.layer.shadowColor = UIColor.black.cgColor + label.layer.shadowOpacity = 0.7 + label.layer.shadowRadius = 2 + label.layer.shadowOffset = .zero + return label + }() + + private let circleView: UIView = { + let view = UIView() + view.translatesAutoresizingMaskIntoConstraints = false + view.layer.cornerRadius = AttachmentAssetCell.circleSize / 2 + view.layer.borderWidth = 2 + view.layer.borderColor = UIColor.white.cgColor + view.backgroundColor = UIColor.black.withAlphaComponent(0.2) + return view + }() + + private let numberLabel: UILabel = { + let label = UILabel() + label.translatesAutoresizingMaskIntoConstraints = false + label.font = UIFontMetrics(forTextStyle: .caption1).scaledFont(for: UIFont.systemFont(ofSize: 12, weight: .semibold)) + label.adjustsFontForContentSizeCategory = true + label.adjustsFontSizeToFitWidth = true + label.minimumScaleFactor = 0.5 + label.textAlignment = .center + label.textColor = .white + return label + }() + + override init(frame: CGRect) { + super.init(frame: frame) + + self.contentView.addSubview(self.imageView) + self.contentView.addSubview(self.dimView) + self.contentView.addSubview(self.durationLabel) + self.contentView.addSubview(self.circleView) + self.circleView.addSubview(self.numberLabel) + + NSLayoutConstraint.activate([ + self.imageView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + self.imageView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + self.imageView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor), + self.imageView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor), + + self.dimView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + self.dimView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + self.dimView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor), + self.dimView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor), + + self.durationLabel.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor, constant: 6), + self.durationLabel.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor, constant: -4), + self.durationLabel.trailingAnchor.constraint(lessThanOrEqualTo: self.contentView.trailingAnchor, constant: -6), + + self.circleView.topAnchor.constraint(equalTo: self.contentView.topAnchor, constant: 6), + self.circleView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor, constant: -6), + self.circleView.widthAnchor.constraint(equalToConstant: Self.circleSize), + self.circleView.heightAnchor.constraint(equalToConstant: Self.circleSize), + + self.numberLabel.leadingAnchor.constraint(equalTo: self.circleView.leadingAnchor, constant: 2), + self.numberLabel.trailingAnchor.constraint(equalTo: self.circleView.trailingAnchor, constant: -2), + self.numberLabel.centerYAnchor.constraint(equalTo: self.circleView.centerYAnchor) + ]) + + self.isAccessibilityElement = true + self.accessibilityTraits = .button + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func prepareForReuse() { + super.prepareForReuse() + + self.representedAssetIdentifier = nil + self.imageRequestID = PHInvalidImageRequestID + self.imageView.image = nil + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { + super.traitCollectionDidChange(previousTraitCollection) + + // CGColors do not follow the appearance on their own + self.circleView.layer.borderColor = self.selectionIndex == nil ? UIColor.white.cgColor : UIColor.tintColor.cgColor + } + + private(set) var selectionIndex: Int? + + func setThumbnail(_ image: UIImage?) { + self.imageView.image = image + } + + func configure(for asset: PHAsset) { + let isVideo = asset.mediaType == .video + + self.durationLabel.text = isVideo ? InAppCameraSupport.formattedDuration(asset.duration) : nil + self.durationLabel.isHidden = !isVideo + + let kind = isVideo ? NSLocalizedString("Video", comment: "A video in the photo library") : NSLocalizedString("Photo", comment: "A photo in the photo library") + + if let date = asset.creationDate { + self.accessibilityLabel = "\(kind), \(Self.dateFormatter.string(from: date))" + } else { + self.accessibilityLabel = kind + } + + if isVideo { + self.accessibilityLabel = "\(self.accessibilityLabel ?? kind), \(InAppCameraSupport.formattedDuration(asset.duration))" + } + } + + /// - Parameter index: The place in the selection, starting at 1, or `nil` when the item is not selected + func setSelectionIndex(_ index: Int?) { + self.selectionIndex = index + + if let index { + self.numberLabel.text = "\(index)" + self.circleView.backgroundColor = .tintColor + self.circleView.layer.borderColor = UIColor.tintColor.cgColor + self.dimView.isHidden = false + self.accessibilityValue = NSLocalizedString("Selected", comment: "An item of the attachment sheet is selected") + ", \(index)" + self.accessibilityTraits = [.button, .selected] + } else { + self.numberLabel.text = nil + self.circleView.backgroundColor = UIColor.black.withAlphaComponent(0.2) + self.circleView.layer.borderColor = UIColor.white.cgColor + self.dimView.isHidden = true + self.accessibilityValue = nil + self.accessibilityTraits = .button + } + } + + private static let dateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() +} + +/// The first tile of the grid: a live view of the camera, or an icon when the camera can not be used right now +final class AttachmentCameraCell: UICollectionViewCell { + + static let reuseIdentifier = "AttachmentCameraCell" + + private let iconView: UIImageView = { + let imageView = UIImageView(image: UIImage(systemName: "camera.fill")) + imageView.translatesAutoresizingMaskIntoConstraints = false + imageView.tintColor = .white + imageView.contentMode = .scaleAspectFit + imageView.isAccessibilityElement = false + imageView.preferredSymbolConfiguration = UIImage.SymbolConfiguration(pointSize: 28, weight: .regular) + return imageView + }() + + private var previewView: VideoMessagePreviewView? + private var previewSession: AVCaptureSession? + private var previewOrientation: UIInterfaceOrientation? + + override init(frame: CGRect) { + super.init(frame: frame) + + self.contentView.backgroundColor = .darkGray + self.contentView.clipsToBounds = true + self.contentView.addSubview(self.iconView) + + NSLayoutConstraint.activate([ + self.iconView.centerXAnchor.constraint(equalTo: self.contentView.centerXAnchor), + self.iconView.centerYAnchor.constraint(equalTo: self.contentView.centerYAnchor) + ]) + + self.isAccessibilityElement = true + self.accessibilityTraits = .button + self.accessibilityLabel = NSLocalizedString("Camera", comment: "") + self.accessibilityIdentifier = "attachmentSheetCameraTile" + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// - Parameter session: The session to show, or `nil` to show only the icon + func configure(session: AVCaptureSession?, interfaceOrientation: UIInterfaceOrientation) { + if self.previewSession === session, self.previewOrientation == interfaceOrientation { + return + } + + self.previewView?.removeFromSuperview() + self.previewView = nil + self.previewSession = session + self.previewOrientation = interfaceOrientation + + guard let session else { return } + + let previewView = VideoMessagePreviewView(session: session, interfaceOrientation: interfaceOrientation, showsSwitchCameraButton: false) + previewView.translatesAutoresizingMaskIntoConstraints = false + previewView.layer.cornerRadius = 0 + previewView.isUserInteractionEnabled = false + previewView.isAccessibilityElement = false + + // Below the icon, so the tile is recognizable as the camera + self.contentView.insertSubview(previewView, belowSubview: self.iconView) + + NSLayoutConstraint.activate([ + previewView.topAnchor.constraint(equalTo: self.contentView.topAnchor), + previewView.bottomAnchor.constraint(equalTo: self.contentView.bottomAnchor), + previewView.leadingAnchor.constraint(equalTo: self.contentView.leadingAnchor), + previewView.trailingAnchor.constraint(equalTo: self.contentView.trailingAnchor) + ]) + + self.previewView = previewView + } +} diff --git a/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetViewController.swift b/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetViewController.swift new file mode 100644 index 000000000..bd2e43c4a --- /dev/null +++ b/NextcloudTalk/Chat/Attachment sheet/AttachmentSheetViewController.swift @@ -0,0 +1,418 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import Photos +import UIKit + +@MainActor +protocol AttachmentSheetViewControllerDelegate: AnyObject { + /// The user chose one of the items of the strip, or the camera tile. The sheet stays open, the delegate closes it. + func attachmentSheet(_ sheet: AttachmentSheetViewController, didChoose action: AttachmentSheetAction) + + /// The selected photos and videos were written to files. The sheet stays open, the delegate closes it. + func attachmentSheet(_ sheet: AttachmentSheetViewController, didExport files: [AttachmentAssetExporter.ExportedFile]) +} + +/// What the "+" button of the chat shows: a sheet with the recent photos and videos, and a strip with everything +/// else that can be shared at the bottom. +final class AttachmentSheetViewController: UIViewController, AttachmentGridViewControllerDelegate { + + weak var delegate: AttachmentSheetViewControllerDelegate? + + private let actions: [AttachmentSheetAction] + private let grid = AttachmentGridViewController() + + private var previewSession: AttachmentCameraPreviewSession? + private var isVisible = false + private var exportTask: Task? + private var exportToken = UUID() + + private var hasCameraTile: Bool { + return self.actions.contains(.camera) + } + + // MARK: - Views + + private lazy var sendButton: UIButton = { + var configuration = UIButton.Configuration.filled() + configuration.cornerStyle = .capsule + configuration.buttonSize = .large + + let button = UIButton(configuration: configuration) + button.addAction(UIAction { [weak self] _ in self?.startExport() }, for: .touchUpInside) + button.accessibilityIdentifier = "attachmentSheetSend" + return button + }() + + private lazy var sendContainer: UIView = { + let container = UIView() + self.sendButton.translatesAutoresizingMaskIntoConstraints = false + container.addSubview(self.sendButton) + container.isHidden = true + + NSLayoutConstraint.activate([ + self.sendButton.topAnchor.constraint(equalTo: container.topAnchor, constant: 8), + self.sendButton.bottomAnchor.constraint(equalTo: container.bottomAnchor, constant: -8), + self.sendButton.leadingAnchor.constraint(equalTo: container.leadingAnchor, constant: 16), + self.sendButton.trailingAnchor.constraint(equalTo: container.trailingAnchor, constant: -16) + ]) + + return container + }() + + private lazy var progressView: UIProgressView = { + let progressView = UIProgressView(progressViewStyle: .default) + progressView.translatesAutoresizingMaskIntoConstraints = false + return progressView + }() + + private lazy var progressLabel: UILabel = { + let label = UILabel() + label.font = UIFont.preferredFont(forTextStyle: .body) + label.adjustsFontForContentSizeCategory = true + label.textAlignment = .center + label.numberOfLines = 0 + return label + }() + + /// Covers the sheet while the selected items are written to files, which takes a while for items in iCloud + private lazy var progressOverlay: UIView = { + var cancelConfiguration = UIButton.Configuration.plain() + cancelConfiguration.title = NSLocalizedString("Cancel", comment: "") + + let cancelButton = UIButton(configuration: cancelConfiguration) + cancelButton.addAction(UIAction { [weak self] _ in self?.cancelExport() }, for: .touchUpInside) + cancelButton.accessibilityIdentifier = "attachmentSheetCancelExport" + + let stackView = UIStackView(arrangedSubviews: [self.progressLabel, self.progressView, cancelButton]) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .fill + stackView.spacing = 16 + + let overlay = UIView() + overlay.backgroundColor = UIColor.systemBackground.withAlphaComponent(0.95) + overlay.addSubview(stackView) + overlay.isHidden = true + + NSLayoutConstraint.activate([ + stackView.centerYAnchor.constraint(equalTo: overlay.centerYAnchor), + stackView.leadingAnchor.constraint(equalTo: overlay.leadingAnchor, constant: 32), + stackView.trailingAnchor.constraint(equalTo: overlay.trailingAnchor, constant: -32) + ]) + + return overlay + }() + + // MARK: - Init + + init(actions: [AttachmentSheetAction]) { + self.actions = actions + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + self.previewSession?.stop() + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + self.view.backgroundColor = .systemBackground + + self.grid.delegate = self + self.grid.showsCameraTile = self.hasCameraTile + self.addChild(self.grid) + self.grid.view.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.grid.view) + self.grid.didMove(toParent: self) + + let separator = UIView() + separator.backgroundColor = .separator + separator.heightAnchor.constraint(equalToConstant: 1 / max(self.traitCollection.displayScale, 1)).isActive = true + + let bottomStack = UIStackView(arrangedSubviews: [self.sendContainer, separator, self.makeActionStrip()]) + bottomStack.translatesAutoresizingMaskIntoConstraints = false + bottomStack.axis = .vertical + self.view.addSubview(bottomStack) + + self.progressOverlay.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(self.progressOverlay) + + NSLayoutConstraint.activate([ + // Leaves room for the grabber + self.grid.view.topAnchor.constraint(equalTo: self.view.topAnchor, constant: 24), + self.grid.view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.grid.view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + self.grid.view.bottomAnchor.constraint(equalTo: bottomStack.topAnchor), + + bottomStack.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + bottomStack.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + bottomStack.bottomAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.bottomAnchor), + + self.progressOverlay.topAnchor.constraint(equalTo: self.view.topAnchor), + self.progressOverlay.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.progressOverlay.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + self.progressOverlay.trailingAnchor.constraint(equalTo: self.view.trailingAnchor) + ]) + + // The camera is released when the app is left, and taken again when it's back + NotificationCenter.default.addObserver(self, selector: #selector(applicationWillResignActive), name: UIApplication.willResignActiveNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive), name: UIApplication.didBecomeActiveNotification, object: nil) + + // Files of earlier sends are not needed anymore + DispatchQueue.global(qos: .utility).async { + AttachmentAssetExporter.removeOldExports() + } + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + self.isVisible = true + self.startPreviewIfPossible() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Also called when the sheet is moved behind another screen + self.isVisible = false + self.stopPreview() + } + + // MARK: - Strip + + private func makeActionStrip() -> UIView { + let stackView = UIStackView(arrangedSubviews: self.actions.map { self.makeStripButton(for: $0) }) + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .horizontal + stackView.distribution = .fillEqually + stackView.alignment = .fill + stackView.isLayoutMarginsRelativeArrangement = true + stackView.layoutMargins = UIEdgeInsets(top: 4, left: 8, bottom: 4, right: 8) + + let scrollView = UIScrollView() + scrollView.showsHorizontalScrollIndicator = false + scrollView.addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: scrollView.contentLayoutGuide.topAnchor), + stackView.bottomAnchor.constraint(equalTo: scrollView.contentLayoutGuide.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: scrollView.contentLayoutGuide.trailingAnchor), + + // Fills the width when there are only a few items, scrolls when there are many + stackView.widthAnchor.constraint(greaterThanOrEqualTo: scrollView.frameLayoutGuide.widthAnchor), + scrollView.heightAnchor.constraint(equalTo: stackView.heightAnchor) + ]) + + return scrollView + } + + private func makeStripButton(for action: AttachmentSheetAction) -> UIButton { + var configuration = UIButton.Configuration.plain() + configuration.title = action.title + configuration.image = self.stripImage(for: action) + configuration.imagePlacement = .top + configuration.imagePadding = 6 + configuration.titleLineBreakMode = .byTruncatingTail + configuration.contentInsets = NSDirectionalEdgeInsets(top: 8, leading: 4, bottom: 8, trailing: 4) + configuration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { attributes in + var attributes = attributes + attributes.font = UIFont.preferredFont(forTextStyle: .caption2) + return attributes + } + + let button = UIButton(configuration: configuration) + button.addAction(UIAction { [weak self] _ in self?.choose(action) }, for: .touchUpInside) + button.accessibilityIdentifier = action.accessibilityIdentifier + button.widthAnchor.constraint(greaterThanOrEqualToConstant: 76).isActive = true + + return button + } + + /// Symbols fit the strip as they are, images of the app are brought to the same size + private func stripImage(for action: AttachmentSheetAction) -> UIImage? { + guard let image = action.image else { return nil } + + if image.isSymbolImage { + return image.applyingSymbolConfiguration(UIImage.SymbolConfiguration(pointSize: 20)) + } + + let size = CGSize(width: 24, height: 24) + let scaledImage = UIGraphicsImageRenderer(size: size).image { _ in + image.draw(in: CGRect(origin: .zero, size: size)) + } + + return scaledImage.withRenderingMode(.alwaysTemplate) + } + + private func choose(_ action: AttachmentSheetAction) { + // The camera of the chat is opened after the sheet is closed, so the preview needs to have let go of it + if action == .camera { + self.stopPreview { [weak self] in + guard let self else { return } + self.delegate?.attachmentSheet(self, didChoose: action) + } + } else { + self.stopPreview() + self.delegate?.attachmentSheet(self, didChoose: action) + } + } + + // MARK: - Camera preview + + /// The camera is only started when it's allowed already and not needed for a call + private var canShowLivePreview: Bool { + return self.hasCameraTile + && AttachmentCameraPreviewSession.isCameraAuthorized + && !AttachmentCameraPreviewSession.isCameraBusyWithCall + } + + private func startPreviewIfPossible() { + guard self.isVisible, self.canShowLivePreview else { return } + + let previewSession = self.previewSession ?? AttachmentCameraPreviewSession() + self.previewSession = previewSession + + previewSession.start() + self.grid.cameraSession = previewSession.session + } + + private func stopPreview(completion: (() -> Void)? = nil) { + self.grid.cameraSession = nil + + guard let previewSession else { + completion?() + return + } + + previewSession.stop(completion: completion) + } + + @objc private func applicationWillResignActive() { + self.stopPreview() + } + + @objc private func applicationDidBecomeActive() { + self.startPreviewIfPossible() + } + + // MARK: - Grid delegate + + func attachmentGridDidChangeSelection(_ grid: AttachmentGridViewController) { + let count = grid.selectedAssets.count + + if count > 0 { + self.sendButton.configuration?.title = String.localizedStringWithFormat(NSLocalizedString("Send (%ld)", comment: "Sends the selected photos and videos, the number is how many"), count) + } + + UIView.animate(withDuration: 0.2) { + self.sendContainer.isHidden = count == 0 + self.view.layoutIfNeeded() + } + } + + func attachmentGridDidTapCamera(_ grid: AttachmentGridViewController) { + self.choose(.camera) + } + + // MARK: - Export + + private func startExport() { + let assets = self.grid.selectedAssets + guard !assets.isEmpty, self.exportTask == nil else { return } + + self.setExportInProgress(true) + self.updateExportProgress(index: 0, count: assets.count, fraction: 0) + + // A cancelled export finishes in the background, so it must not clear the task of a newer one + let token = UUID() + self.exportToken = token + + self.exportTask = Task { + await self.export(assets) + + if self.exportToken == token { + self.exportTask = nil + } + } + } + + private func export(_ assets: [PHAsset]) async { + var directory: URL? + + do { + let exportDirectory = try AttachmentAssetExporter.makeExportDirectory() + directory = exportDirectory + + var files: [AttachmentAssetExporter.ExportedFile] = [] + + for (index, asset) in assets.enumerated() { + try Task.checkCancellation() + self.updateExportProgress(index: index, count: assets.count, fraction: 0) + + let file = try await AttachmentAssetExporter.export(asset, into: exportDirectory) { [weak self] fraction in + self?.updateExportProgress(index: index, count: assets.count, fraction: fraction) + } + + files.append(file) + } + + try Task.checkCancellation() + + self.setExportInProgress(false) + self.delegate?.attachmentSheet(self, didExport: files) + } catch { + if let directory { + try? FileManager.default.removeItem(at: directory) + } + + // The overlay was removed when the export was cancelled + guard !(error is CancellationError), !Task.isCancelled else { return } + + self.setExportInProgress(false) + self.showExportError(error) + } + } + + private func cancelExport() { + self.exportTask?.cancel() + self.exportTask = nil + self.exportToken = UUID() + self.setExportInProgress(false) + } + + private func setExportInProgress(_ inProgress: Bool) { + self.progressOverlay.isHidden = !inProgress + self.isModalInPresentation = inProgress + + if inProgress { + UIAccessibility.post(notification: .screenChanged, argument: self.progressLabel) + } + } + + private func updateExportProgress(index: Int, count: Int, fraction: Double) { + self.progressLabel.text = String.localizedStringWithFormat(NSLocalizedString("Preparing %1$ld of %2$ld…", comment: "Photos and videos are being prepared for sending, the numbers are the current one and all of them"), index + 1, count) + self.progressView.progress = Float((Double(index) + fraction) / Double(count)) + } + + private func showExportError(_ error: Error) { + let alert = UIAlertController(title: NSLocalizedString("Could not prepare the selected items", comment: "Title of an error shown when photos or videos could not be read from the photo library"), + message: error.localizedDescription, + preferredStyle: .alert) + + alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default)) + self.present(alert, animated: true) + } +} diff --git a/NextcloudTalk/Chat/Attachment sheet/BaseChatViewController+AttachmentSheet.swift b/NextcloudTalk/Chat/Attachment sheet/BaseChatViewController+AttachmentSheet.swift new file mode 100644 index 000000000..dc01abc53 --- /dev/null +++ b/NextcloudTalk/Chat/Attachment sheet/BaseChatViewController+AttachmentSheet.swift @@ -0,0 +1,86 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +/// The "+" button of the chat opens the attachment sheet, see `AttachmentSheetViewController`. +extension BaseChatViewController: AttachmentSheetViewControllerDelegate { + + func presentAttachmentSheet() { + // The keyboard will be hidden when an action is invoked. Depending on what + // attachment is shared, not resigning might lead to a currupted chat view + self.textView.resignFirstResponder() + + let actions = AttachmentSheetAction.availableActions(room: self.room, account: self.account, thread: self.thread) + let sheet = AttachmentSheetViewController(actions: actions) + sheet.delegate = self + sheet.modalPresentationStyle = .pageSheet + + // The chat is not usable while the sheet is open, there is no undimmed detent + if let sheetPresentationController = sheet.sheetPresentationController { + sheetPresentationController.detents = [.medium(), .large()] + sheetPresentationController.prefersGrabberVisible = true + sheetPresentationController.prefersScrollingExpandsWhenScrolledToEdge = false + } + + self.present(sheet, animated: true) + } + + // MARK: - AttachmentSheetViewController delegate + + func attachmentSheet(_ sheet: AttachmentSheetViewController, didChoose action: AttachmentSheetAction) { + sheet.dismiss(animated: true) { [weak self] in + self?.handleAttachmentSheetAction(action) + } + } + + func attachmentSheet(_ sheet: AttachmentSheetViewController, didExport files: [AttachmentAssetExporter.ExportedFile]) { + guard let (shareConfirmationVC, navigationController) = self.createShareConfirmationViewController() else { + // Every file has a folder of its own + for file in files { + try? FileManager.default.removeItem(at: file.url.deletingLastPathComponent()) + } + + sheet.dismiss(animated: true) + return + } + + shareConfirmationVC.setChatMessage(self.textView.text) + self.setChatMessage("") + + sheet.dismiss(animated: true) { + self.present(navigationController, animated: true) { + for file in files { + shareConfirmationVC.shareItemController.addItem(withURLAndName: file.url, withName: file.fileName) + } + } + } + } + + private func handleAttachmentSheetAction(_ action: AttachmentSheetAction) { + self.textView.resignFirstResponder() + + switch action { + case .camera: + self.checkAndPresentCamera() + case .photoLibrary: + self.presentPhotoLibrary() + case .giphy: + self.presentGiphyPicker() + case .files: + self.presentDocumentPicker() + case .nextcloudFiles: + self.presentNextcloudFilesBrowser() + case .thread: + self.presentThreadCreation() + case .poll: + self.presentPollCreation() + case .location: + self.presentShareLocation() + case .contact: + self.presentShareContact() + } + } +} diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 57c5192bb..a3769cdfe 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -338,8 +338,6 @@ import Toast self.scrollToBottomButton.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, constant: -10).isActive = true self.voiceRecordingLockButton.trailingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.trailingAnchor, constant: -10).isActive = true - self.addMenuToLeftButton() - self.replyMessageView?.addObserver(self, forKeyPath: "visible", options: .new, context: nil) self.textView.pastableMediaTypes = .images @@ -773,7 +771,7 @@ import Toast func showAttachmentButton() { self.setInputbarImage(UIImage(systemName: "plus"), for: self.leftButton) self.leftButton.accessibilityLabel = NSLocalizedString("Share a file from your Nextcloud", comment: "") - self.leftButton.accessibilityHint = NSLocalizedString("Double tap to open file browser", comment: "") + self.leftButton.accessibilityHint = NSLocalizedString("Double tap to choose what to share", comment: "Accessibility hint of the button that opens the attachment sheet") self.leftButton.accessibilityIdentifier = "shareButton" } @@ -856,7 +854,10 @@ import Toast return } - super.didPressLeftButton(sender) + // No sharing options in federation v1, the button has no image there + guard !self.room.isFederated else { return } + + self.presentAttachmentSheet() } public override func didPressRightButton(_ sender: Any?) { @@ -1019,121 +1020,14 @@ import Toast self.rightButton.menu = UIMenu(children: actions.reversed()) } - func addMenuToLeftButton() { - // The keyboard will be hidden when an action is invoked. Depending on what - // attachment is shared, not resigning might lead to a currupted chat view - var items: [UIMenuElement] = [] - - let cameraAction = UIAction(title: NSLocalizedString("Camera", comment: ""), image: UIImage(systemName: "camera")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.checkAndPresentCamera() - } - - let photoLibraryAction = UIAction(title: NSLocalizedString("Photo Library", comment: ""), image: UIImage(systemName: "photo")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.presentPhotoLibrary() - } - - let shareLocationAction = UIAction(title: NSLocalizedString("Location", comment: ""), image: UIImage(systemName: "location")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.presentShareLocation() - } - - let contactShareAction = UIAction(title: NSLocalizedString("Contacts", comment: ""), image: UIImage(systemName: "person")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.presentShareContact() - } - - let filesAction = UIAction(title: NSLocalizedString("Files", comment: ""), image: UIImage(systemName: "doc")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.presentDocumentPicker() - } - - let ncFilesAction = UIAction(title: filesAppName, image: UIImage(named: "logo-action")?.withRenderingMode(.alwaysTemplate)) { [unowned self] _ in - self.textView.resignFirstResponder() - self.presentNextcloudFilesBrowser() - } - - let pollAction = UIAction(title: NSLocalizedString("Poll", comment: ""), image: UIImage(systemName: "chart.bar")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.presentPollCreation() - } - - let threadAction = UIAction(title: NSLocalizedString("Thread", comment: "Context menu action to reply to a message in a thread"), image: UIImage(systemName: "bubble.left.and.bubble.right")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.presentThreadCreation() - } - - // Not localized: "GIF" is a file format and "Giphy" a company name - let giphyAction = UIAction(title: "GIF (Giphy)", image: UIImage(systemName: "play.square.stack")) { [unowned self] _ in - self.textView.resignFirstResponder() - self.presentGiphyPicker() - } - - // Add actions (inverted) - var objectItems = [UIMenuElement]() - objectItems.append(contactShareAction) - - if NCDatabaseManager.sharedInstance().roomHasTalkCapability(.locationSharing, for: self.room) { - objectItems.append(shareLocationAction) - } - - if NCDatabaseManager.sharedInstance().roomHasTalkCapability(.talkPolls, for: self.room), - self.room.type != .oneToOne, self.room.type != .noteToSelf { - - objectItems.append(pollAction) - } - - if NCDatabaseManager.sharedInstance().roomHasTalkCapability(.threads, for: self.room), - self.thread == nil { - - objectItems.append(threadAction) - } - - // TODO: Remove this check when rich objects and polls can be shared in threads - if thread == nil { - items.append(UIMenu(options: .displayInline, children: objectItems)) - } - - items.append(ncFilesAction) - items.append(filesAction) - - let serverCapabilities = NCDatabaseManager.sharedInstance().serverCapabilities(forAccountId: self.account.accountId) - if serverCapabilities?.giphyEnabled == true, serverCapabilities?.giphyConfigured == true { - items.append(giphyAction) - } - - items.append(photoLibraryAction) - - if UIImagePickerController.isSourceTypeAvailable(.camera) { - items.append(cameraAction) - } - - self.leftButton.menu = UIMenu(children: items) - self.leftButton.showsMenuAsPrimaryAction = true - - // Ensure that our longPressGestureRecognizer does not interfere with the native ones - _ = self.leftButton.gestureRecognizers?.map { recognizer in - if let leftButtonLongPressGesture { - recognizer.require(toFail: leftButtonLongPressGesture) - } - } - } - func longPress(gestureRecognizer: UILongPressGestureRecognizer) { guard gestureRecognizer.state == .began else { return } - // Remove the menu, so we don't accidentially open the menu on a long press - self.leftButton.menu = nil - // Add haptic feedback let generator = UIImpactFeedbackGenerator(style: .heavy) generator.impactOccurred() self.presentPhotoLibrary() - - // Re-add the menu to the left button - self.addMenuToLeftButton() } func presentNextcloudFilesBrowser() { @@ -1581,9 +1475,6 @@ import Toast guard self.hasGlassInputbar else { return } if self.textInputbar.isEditing { - // The attachment menu is the primary action of the left button, so no press is reported while it's set - self.leftButton.menu = nil - self.leftButton.showsMenuAsPrimaryAction = false self.leftButtonLongPressGesture?.isEnabled = false self.setInputbarImage(UIImage(systemName: "xmark"), for: self.leftButton) @@ -1596,7 +1487,6 @@ import Toast } else { self.leftButtonLongPressGesture?.isEnabled = true self.showAttachmentButton() - self.addMenuToLeftButton() // Both buttons are set up from scratch instead of being restored: showAttachmentButton() knows // about federation, and canPressRightButton() about send or record for the text we have now diff --git a/NextcloudTalk/Info.plist b/NextcloudTalk/Info.plist index 10b53b571..a1cb439d7 100644 --- a/NextcloudTalk/Info.plist +++ b/NextcloudTalk/Info.plist @@ -130,6 +130,8 @@ UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight + PHPhotoLibraryPreventAutomaticLimitedAccessAlert + UIViewControllerBasedStatusBarAppearance diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index f006951b7..f5b3f2682 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -154,6 +154,9 @@ /* No comment provided by engineer. */ "Accept" = "Accept"; +/* Shown in the attachment sheet when the photo library can not be used */ +"Access to your photos is restricted on this device" = "Access to your photos is restricted on this device"; + /* No comment provided by engineer. */ "Account" = "Account"; @@ -229,6 +232,12 @@ /* No comment provided by engineer. */ "All notifications are muted" = "All notifications are muted"; +/* Button to allow access to the photo library in the settings */ +"Allow access" = "Allow access"; + +/* Shown in the attachment sheet when the access to the photo library was denied */ +"Allow access to your photos to see your recent photos and videos here" = "Allow access to your photos to see your recent photos and videos here"; + /* No comment provided by engineer. */ "Allow guests to join this conversation via link" = "Allow guests to join this conversation via link"; @@ -808,6 +817,9 @@ /* No comment provided by engineer. */ "Could not login with QR code" = "Could not login with QR code"; +/* Title of an error shown when photos or videos could not be read from the photo library */ +"Could not prepare the selected items" = "Could not prepare the selected items"; + /* No comment provided by engineer. */ "Could not prepare upload folder" = "Could not prepare upload folder"; @@ -1024,6 +1036,9 @@ /* No comment provided by engineer. */ "Double tap to change who can modify the shared files" = "Double tap to change who can modify the shared files"; +/* Accessibility hint of the button that opens the attachment sheet */ +"Double tap to choose what to share" = "Double tap to choose what to share"; + /* No comment provided by engineer. */ "Double tap to discard the changes" = "Double tap to discard the changes"; @@ -1926,6 +1941,9 @@ /* No comment provided by engineer. */ "Phone number set successfully" = "Phone number set successfully"; +/* A photo in the photo library */ +"Photo" = "Photo"; + /* No comment provided by engineer. */ "Photo Library" = "Photo Library"; @@ -1989,6 +2007,9 @@ /* No comment provided by engineer. */ "Polls" = "Polls"; +/* Photos and videos are being prepared for sending, the numbers are the current one and all of them */ +"Preparing %1$ld of %2$ld…" = "Preparing %1$ld of %2$ld…"; + /* No comment provided by engineer. */ "Preview" = "Preview"; @@ -2203,12 +2224,21 @@ /* No comment provided by engineer. */ "Select language" = "Select language"; +/* Opens the system picker to allow access to more photos */ +"Select more photos" = "Select more photos"; + +/* An item of the attachment sheet is selected */ +"Selected" = "Selected"; + /* Copy a 'selection' of a message */ "Selection" = "Selection"; /* Button to send the shared content to a conversation */ "Send" = "Send"; +/* Sends the selected photos and videos, the number is how many */ +"Send (%ld)" = "Send (%ld)"; + /* No comment provided by engineer. */ "Send a reaction" = "Send a reaction"; @@ -2713,6 +2743,9 @@ /* No comment provided by engineer. */ "Version" = "Version"; +/* A video in the photo library */ +"Video" = "Video"; + /* Join a call in video call mode */ "Video call" = "Video call"; @@ -2761,6 +2794,9 @@ /* Please put {user0} and {user1} placeholders in the correct position on the translated text but do not translate them */ "You added {user0} and {user1}" = "You added {user0} and {user1}"; +/* Shown in the attachment sheet when the access to the photo library is limited */ +"You allowed access to some of your photos only" = "You allowed access to some of your photos only"; + /* Please put %ld placeholder in the correct position on the translated text but do not translate it */ "You and %ld more participants joined the call" = "You and %ld more participants joined the call"; @@ -2989,6 +3025,12 @@ /* No comment provided by engineer. */ "{actor} stopped the video recording" = "{actor} stopped the video recording"; +/* File name, error description */ +"“%1$@” could not be prepared: %2$@" = "“%1$@” could not be prepared: %2$@"; + +/* File name */ +"“%@” could not be read from the photo library" = "“%@” could not be read from the photo library"; + /* No comment provided by engineer. */ "↓ New messages" = "↓ New messages"; diff --git a/NextcloudTalk/en.lproj/Localizable.stringsdict b/NextcloudTalk/en.lproj/Localizable.stringsdict index 449391e81..a3bb11be4 100644 --- a/NextcloudTalk/en.lproj/Localizable.stringsdict +++ b/NextcloudTalk/en.lproj/Localizable.stringsdict @@ -722,6 +722,38 @@ You can select up to %ld items + Send (%ld) + + NSStringLocalizedFormatKey + %#@count@ + count + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + Send (%ld) + other + Send (%ld) + + + Preparing %1$ld of %2$ld… + + NSStringLocalizedFormatKey + Preparing %1$ld of %2$#@total@ + total + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + ld + one + %2$ld… + other + %2$ld… + + %1$ld of %2$ld NSStringLocalizedFormatKey diff --git a/NextcloudTalkTests/Unit/Chat/UnitAttachmentSheetActionTest.swift b/NextcloudTalkTests/Unit/Chat/UnitAttachmentSheetActionTest.swift new file mode 100644 index 000000000..c8da0b87a --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitAttachmentSheetActionTest.swift @@ -0,0 +1,78 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import XCTest +@testable import NextcloudTalk + +final class UnitAttachmentSheetActionTest: XCTestCase { + + private func context(isCameraAvailable: Bool = true, + isGiphyAvailable: Bool = true, + hasThreadsCapability: Bool = true, + hasPollsCapability: Bool = true, + hasLocationCapability: Bool = true, + roomType: NCRoomType = .group, + isInThread: Bool = false) -> AttachmentSheetAction.Context { + + return AttachmentSheetAction.Context(isCameraAvailable: isCameraAvailable, + isGiphyAvailable: isGiphyAvailable, + hasThreadsCapability: hasThreadsCapability, + hasPollsCapability: hasPollsCapability, + hasLocationCapability: hasLocationCapability, + roomType: roomType, + isInThread: isInThread) + } + + func testAllActionsInGroupConversation() throws { + XCTAssertEqual(AttachmentSheetAction.availableActions(in: self.context()), + [.camera, .photoLibrary, .giphy, .files, .nextcloudFiles, .thread, .poll, .location, .contact]) + XCTAssertEqual(Set(AttachmentSheetAction.availableActions(in: self.context())), Set(AttachmentSheetAction.allCases)) + } + + func testNoCameraNoActionForCamera() throws { + let actions = AttachmentSheetAction.availableActions(in: self.context(isCameraAvailable: false)) + + XCTAssertFalse(actions.contains(.camera)) + XCTAssertTrue(actions.contains(.photoLibrary)) + } + + func testGiphyNeedsServerSupport() throws { + XCTAssertFalse(AttachmentSheetAction.availableActions(in: self.context(isGiphyAvailable: false)).contains(.giphy)) + } + + func testBasicActionsAreAlwaysThere() throws { + let actions = AttachmentSheetAction.availableActions(in: self.context(isCameraAvailable: false, + isGiphyAvailable: false, + hasThreadsCapability: false, + hasPollsCapability: false, + hasLocationCapability: false)) + + XCTAssertEqual(actions, [.photoLibrary, .files, .nextcloudFiles, .contact]) + } + + func testCapabilitiesDecideRichObjects() throws { + XCTAssertFalse(AttachmentSheetAction.availableActions(in: self.context(hasThreadsCapability: false)).contains(.thread)) + XCTAssertFalse(AttachmentSheetAction.availableActions(in: self.context(hasPollsCapability: false)).contains(.poll)) + XCTAssertFalse(AttachmentSheetAction.availableActions(in: self.context(hasLocationCapability: false)).contains(.location)) + } + + func testPollsAreNotAvailableInOneToOneAndNoteToSelf() throws { + XCTAssertFalse(AttachmentSheetAction.availableActions(in: self.context(roomType: .oneToOne)).contains(.poll)) + XCTAssertFalse(AttachmentSheetAction.availableActions(in: self.context(roomType: .noteToSelf)).contains(.poll)) + XCTAssertTrue(AttachmentSheetAction.availableActions(in: self.context(roomType: .public)).contains(.poll)) + + // Everything else is not influenced + let oneToOne = AttachmentSheetAction.availableActions(in: self.context(roomType: .oneToOne)) + XCTAssertTrue(oneToOne.contains(.thread)) + XCTAssertTrue(oneToOne.contains(.location)) + XCTAssertTrue(oneToOne.contains(.contact)) + } + + func testRichObjectsAreNotAvailableInThread() throws { + let actions = AttachmentSheetAction.availableActions(in: self.context(isInThread: true)) + + XCTAssertEqual(actions, [.camera, .photoLibrary, .giphy, .files, .nextcloudFiles]) + } +} diff --git a/NextcloudTalkTests/Unit/Chat/UnitAttachmentSheetExportNameTest.swift b/NextcloudTalkTests/Unit/Chat/UnitAttachmentSheetExportNameTest.swift new file mode 100644 index 000000000..477a4384f --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitAttachmentSheetExportNameTest.swift @@ -0,0 +1,49 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import XCTest +@testable import NextcloudTalk + +final class UnitAttachmentSheetExportNameTest: XCTestCase { + + func testOriginalNameAndExtensionAreKept() throws { + XCTAssertEqual(AttachmentAssetExporter.exportFileName(originalFileName: "IMG_0042.HEIC", resourceFileName: "IMG_0042.HEIC", uniformTypeIdentifier: "public.heic", isVideo: false), + "IMG_0042.HEIC") + XCTAssertEqual(AttachmentAssetExporter.exportFileName(originalFileName: "IMG_0043.GIF", resourceFileName: "IMG_0043.GIF", uniformTypeIdentifier: "com.compuserve.gif", isVideo: false), + "IMG_0043.GIF") + XCTAssertEqual(AttachmentAssetExporter.exportFileName(originalFileName: "IMG_0044.MOV", resourceFileName: "IMG_0044.MOV", uniformTypeIdentifier: "com.apple.quicktime-movie", isVideo: true), + "IMG_0044.MOV") + } + + func testEditedPhotoKeepsNameButTakesTypeOfEditedData() throws { + XCTAssertEqual(AttachmentAssetExporter.exportFileName(originalFileName: "IMG_0042.HEIC", resourceFileName: "FullSizeRender.jpeg", uniformTypeIdentifier: "public.jpeg", isVideo: false), + "IMG_0042.jpeg") + } + + func testExtensionFromTypeWhenResourceHasNone() throws { + XCTAssertEqual(AttachmentAssetExporter.exportFileName(originalFileName: "IMG_0045", resourceFileName: "IMG_0045", uniformTypeIdentifier: "public.png", isVideo: false), + "IMG_0045.png") + } + + func testFallbacksWithoutAnyInformation() throws { + XCTAssertEqual(AttachmentAssetExporter.exportFileName(originalFileName: "IMG_0046", resourceFileName: "IMG_0046", uniformTypeIdentifier: nil, isVideo: false), "IMG_0046.jpg") + XCTAssertEqual(AttachmentAssetExporter.exportFileName(originalFileName: "VID_0001", resourceFileName: "VID_0001", uniformTypeIdentifier: nil, isVideo: true), "VID_0001.mov") + XCTAssertTrue(AttachmentAssetExporter.exportFileName(originalFileName: "", resourceFileName: "", uniformTypeIdentifier: nil, isVideo: true).hasPrefix("VID_")) + } + + func testOnlyHeicAndHeifAreConvertedToJPEG() throws { + XCTAssertTrue(AttachmentAssetExporter.needsJPEGConversion(uniformTypeIdentifier: "public.heic")) + XCTAssertTrue(AttachmentAssetExporter.needsJPEGConversion(uniformTypeIdentifier: "public.heif")) + XCTAssertFalse(AttachmentAssetExporter.needsJPEGConversion(uniformTypeIdentifier: "public.jpeg")) + XCTAssertFalse(AttachmentAssetExporter.needsJPEGConversion(uniformTypeIdentifier: "public.png")) + XCTAssertFalse(AttachmentAssetExporter.needsJPEGConversion(uniformTypeIdentifier: "com.compuserve.gif")) + XCTAssertFalse(AttachmentAssetExporter.needsJPEGConversion(uniformTypeIdentifier: "com.apple.quicktime-movie")) + XCTAssertFalse(AttachmentAssetExporter.needsJPEGConversion(uniformTypeIdentifier: nil)) + } + + func testJPEGFileName() throws { + XCTAssertEqual(AttachmentAssetExporter.jpegFileName(for: "IMG_0042.HEIC"), "IMG_0042.jpg") + } +} From ac60be2b62e22d05b8baa7c182bf3d153ca92af9 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:02:13 +0000 Subject: [PATCH 4/7] fix(chat): lay out the video message preview like a camera view The preview of a video message recording is placed by a pure function in the free space between the safe area and the recording panel: 9:16 in portrait and 16:9 in landscape, 16 pt from the edges, at most 75 % of the width. It is placed again on rotation, when the panel of a locked recording appears and after the camera was switched, and its frames follow the orientation of the interface. The recorded file keeps the orientation the recording started in. The chat is dimmed under the preview, which keeps the touches away from it, and VoiceOver does not read the chat while recording. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../BaseChatViewController+VideoMessage.swift | 80 ++++++-- .../Chat/BaseChatViewController.swift | 32 ++++ .../Camera/VideoMessagePreviewPlacement.swift | 87 +++++++++ .../Chat views/VideoMessageRecorder.swift | 5 + ...UnitVideoMessagePreviewPlacementTest.swift | 172 ++++++++++++++++++ 5 files changed, 356 insertions(+), 20 deletions(-) create mode 100644 NextcloudTalk/Chat/Camera/VideoMessagePreviewPlacement.swift create mode 100644 NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift diff --git a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift index f85e7930a..fee84a323 100644 --- a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift +++ b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift @@ -206,8 +206,7 @@ extension BaseChatViewController { let isLongEnough = recorder.elapsed >= 1 - self.videoMessagePreviewView?.removeFromSuperview() - self.videoMessagePreviewView = nil + self.hideVideoMessagePreview() self.hideVoiceMessageRecordingView() self.handleCollapseVoiceRecording() @@ -231,34 +230,75 @@ extension BaseChatViewController { } private func showVideoMessagePreview(for recorder: VideoMessageRecorder) { + // Dims the chat and keeps the touches away from it, the preview and the recording panel are the only things + // to use while recording + let scrimView = UIView() + scrimView.backgroundColor = UIColor.black.withAlphaComponent(0.5) + scrimView.isUserInteractionEnabled = true + scrimView.isAccessibilityElement = false + let previewView = VideoMessagePreviewView(session: recorder.session, - interfaceOrientation: recorder.interfaceOrientation, + interfaceOrientation: CameraCaptureHelpers.interfaceOrientation(of: self.view), showsSwitchCameraButton: recorder.canSwitchCamera) - previewView.translatesAutoresizingMaskIntoConstraints = false previewView.onSwitchCamera = { [weak recorder] in recorder?.switchCamera() } + // The connection of the preview is created again with the camera, so the orientation is applied once more + recorder.onCameraSwitched = { [weak self] in + self?.videoMessagePreviewView?.setNeedsLayout() + self?.updateVideoMessagePreviewLayout() + } + + self.view.addSubview(scrimView) self.view.addSubview(previewView) + self.videoMessageScrimView = scrimView self.videoMessagePreviewView = previewView - NSLayoutConstraint.activate([ - previewView.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), - // Leaves room for the buttons of a locked recording - previewView.bottomAnchor.constraint(equalTo: self.textInputbar.topAnchor, constant: -96) - ]) - - if recorder.isLandscape { - NSLayoutConstraint.activate([ - previewView.widthAnchor.constraint(equalTo: self.view.widthAnchor, multiplier: 0.5), - previewView.heightAnchor.constraint(equalTo: previewView.widthAnchor, multiplier: 9.0 / 16.0) - ]) - } else { - NSLayoutConstraint.activate([ - previewView.heightAnchor.constraint(equalTo: self.view.heightAnchor, multiplier: 0.42), - previewView.widthAnchor.constraint(equalTo: previewView.heightAnchor, multiplier: 9.0 / 16.0) - ]) + // The gesture of the lock needs its button to be seen + self.bringVoiceRecordingLockButtonToFront() + + // VoiceOver reads the recording panel and the switch camera button, not the chat behind them + self.tableView?.accessibilityElementsHidden = true + + self.updateVideoMessagePreviewLayout() + } + + /// Removes the preview and what is shown with it, whatever the way the recording ended + private func hideVideoMessagePreview() { + self.videoMessagePreviewView?.removeFromSuperview() + self.videoMessagePreviewView = nil + self.videoMessageScrimView?.removeFromSuperview() + self.videoMessageScrimView = nil + + self.tableView?.accessibilityElementsHidden = false + } + + /// Places the preview in the free space above the recording panel: it follows the orientation of the interface, + /// and is placed again whenever the layout changes, e.g. by a rotation or when the panel of a locked recording appears. + func updateVideoMessagePreviewLayout() { + guard let previewView = self.videoMessagePreviewView else { return } + + let inputbarTop = self.view.convert(self.textInputbar.bounds, from: self.textInputbar).minY + var areaBottom = inputbarTop + + if let panel = self.visibleExpandedRecordingPanel { + areaBottom = min(areaBottom, self.view.convert(panel.bounds, from: panel).minY) } + + self.videoMessageScrimView?.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: max(inputbarTop, 0)) + + let insets = self.view.safeAreaInsets + let area = CGRect(x: insets.left, + y: insets.top, + width: self.view.bounds.width - insets.left - insets.right, + height: areaBottom - insets.top) + + let orientation = CameraCaptureHelpers.interfaceOrientation(of: self.view) + + // Only what is shown follows the orientation. The file keeps the one the recording started in. + previewView.interfaceOrientation = orientation + previewView.frame = VideoMessagePreviewPlacement.frame(in: area, aspect: VideoMessagePreviewPlacement.frameAspect(for: orientation)) } // MARK: - Sending diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index a3769cdfe..ce27c9b2d 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -106,6 +106,7 @@ import Toast internal var isVideoGestureActive = false internal var videoMessageRecorder: VideoMessageRecorder? internal var videoMessagePreviewView: VideoMessagePreviewView? + internal var videoMessageScrimView: UIView? internal var videoMessageLimitTimer: Timer? private var animationDispatchGroup = DispatchGroup() @@ -477,6 +478,22 @@ import Toast } } + public override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + self.updateVideoMessagePreviewLayout() + } + + public override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { + super.viewWillTransition(to: size, with: coordinator) + + // The orientation of the interface is the new one at the latest when the transition is done (on an iPad + // it can change after the layout), so the preview is placed once more then + coordinator.animate(alongsideTransition: nil) { [weak self] _ in + self?.updateVideoMessagePreviewLayout() + } + } + public override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { super.traitCollectionDidChange(previousTraitCollection) @@ -1974,6 +1991,8 @@ import Toast self.expandedUIHostingController = hostingController self.view.addSubview(expandedVoiceMessageRecordingView) + // The preview of a video makes room for the panel + self.view.setNeedsLayout() expandedVoiceMessageRecordingView.translatesAutoresizingMaskIntoConstraints = false @@ -2037,6 +2056,19 @@ import Toast self.expandedUIHostingController?.removeFromParent() self.expandedUIHostingController?.view.isHidden = true self.textInputbar.bringSubviewToFront(self.textInputbar) + self.view.setNeedsLayout() + } + + /// The panel of a locked recording while it is shown + internal var visibleExpandedRecordingPanel: UIView? { + guard let panel = self.expandedUIHostingController?.view, !panel.isHidden, panel.superview === self.view else { return nil } + + return panel + } + + /// Brings the lock button of the recording above the views that are added for a video, which are shown over the chat + internal func bringVoiceRecordingLockButtonToFront() { + self.view.bringSubviewToFront(self.voiceRecordingLockButton) } func setupAudioRecorder() { diff --git a/NextcloudTalk/Chat/Camera/VideoMessagePreviewPlacement.swift b/NextcloudTalk/Chat/Camera/VideoMessagePreviewPlacement.swift new file mode 100644 index 000000000..58b8ffb9e --- /dev/null +++ b/NextcloudTalk/Chat/Camera/VideoMessagePreviewPlacement.swift @@ -0,0 +1,87 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit + +/// Where the live preview of a video message recording is shown in the chat. This is pure arithmetic, so it can be +/// tested without a screen. +enum VideoMessagePreviewPlacement { + + /// The gap kept free at every edge of the area + static let margin: CGFloat = 16 + + /// The part of the width of the area the preview takes at most + static let maxWidthFraction: CGFloat = 0.75 + + /// The longest side the preview has at most, so it does not grow without limit on an iPad + static let maxSide: CGFloat = 480 + + static let portraitAspect: CGFloat = 9.0 / 16.0 + static let landscapeAspect: CGFloat = 16.0 / 9.0 + + /// A margin may not eat the area: at most this part of the shorter side is kept free at every edge + private static let marginDivisor: CGFloat = 8 + + /// Width divided by height of the frame the viewer sees in the given orientation of the interface: + /// 9:16 in portrait, 16:9 in landscape. The orientation of the file that is recorded does not matter here. + static func frameAspect(for orientation: UIInterfaceOrientation) -> CGFloat { + return orientation.isLandscape ? self.landscapeAspect : self.portraitAspect + } + + /// Fits a frame of the given aspect ratio into the area and centers it there. 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 low: the frame + /// is never distorted, only scaled down. An area without size gives an empty rect. + /// + /// - Parameters: + /// - area: the free space, between the top of the safe area and the top of the recording panel + /// - aspect: width divided by height of the frame, a value that is not usable is read as portrait + /// - margin: the gap kept free at every edge of the area, shrinks on a very small area + /// - maxWidthFraction: the part of the width of the area the preview takes at most + /// - maxSide: the longest side the preview has at most + static func frame(in area: CGRect, + aspect: CGFloat, + margin: CGFloat = VideoMessagePreviewPlacement.margin, + maxWidthFraction: CGFloat = VideoMessagePreviewPlacement.maxWidthFraction, + maxSide: CGFloat = VideoMessagePreviewPlacement.maxSide) -> CGRect { + // Not `area.width`, which is the absolute value for a rect with a negative size + let areaWidth = max(area.size.width, 0) + let areaHeight = max(area.size.height, 0) + let origin = area.origin + + guard areaWidth.isFinite, areaHeight.isFinite, areaWidth > 0, areaHeight > 0 else { + return CGRect(origin: origin, size: .zero) + } + + let safeAspect = (aspect.isFinite && aspect > 0) ? aspect : self.portraitAspect + let safeMargin = max(min(margin, min(areaWidth, areaHeight) / self.marginDivisor), 0) + + let boxWidth = max(min(areaWidth - 2 * safeMargin, areaWidth * maxWidthFraction), 0) + let boxHeight = max(areaHeight - 2 * safeMargin, 0) + + var width = boxWidth + var height = width / safeAspect + + if height > boxHeight { + height = boxHeight + width = height * safeAspect + } + + let longest = max(width, height) + + if longest > maxSide, longest > 0 { + let scale = maxSide / longest + width *= scale + height *= scale + } + + width = width.rounded() + height = height.rounded() + + return CGRect(x: origin.x + ((areaWidth - width) / 2).rounded(), + y: origin.y + ((areaHeight - height) / 2).rounded(), + width: width, + height: height) + } +} diff --git a/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift b/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift index 5e6f0a5de..d1fe817e0 100644 --- a/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift +++ b/NextcloudTalk/Chat/Chat views/VideoMessageRecorder.swift @@ -33,6 +33,9 @@ final class VideoMessageRecorder: NSObject, AVCaptureVideoDataOutputSampleBuffer /// Called on the main queue when the recording can not go on (session interrupted, runtime error) var onFailure: (() -> Void)? + /// Called on the main queue after the camera was switched, as the connection of a preview is created again then + var onCameraSwitched: (() -> Void)? + /// Time of the start of the recording, only set on the main queue private(set) var startDate: Date? @@ -181,6 +184,8 @@ final class VideoMessageRecorder: NSObject, AVCaptureVideoDataOutputSampleBuffer self.configureVideoConnection() self.session.commitConfiguration() + + DispatchQueue.main.async { self.onCameraSwitched?() } } } diff --git a/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift b/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift new file mode 100644 index 000000000..3627ab7df --- /dev/null +++ b/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift @@ -0,0 +1,172 @@ +// +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-3.0-or-later +// + +import UIKit +import XCTest +@testable import NextcloudTalk + +final class UnitVideoMessagePreviewPlacementTest: XCTestCase { + + private let portrait = VideoMessagePreviewPlacement.portraitAspect + private let landscape = VideoMessagePreviewPlacement.landscapeAspect + + private func place(_ width: CGFloat, _ height: CGFloat, aspect: CGFloat, origin: CGPoint = .zero) -> CGRect { + return VideoMessagePreviewPlacement.frame(in: CGRect(origin: origin, size: CGSize(width: width, height: height)), aspect: aspect) + } + + private func assertInside(_ rect: CGRect, _ area: CGRect, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertGreaterThanOrEqual(rect.minX, area.minX, file: file, line: line) + XCTAssertGreaterThanOrEqual(rect.minY, area.minY, file: file, line: line) + XCTAssertLessThanOrEqual(rect.maxX, area.maxX, file: file, line: line) + XCTAssertLessThanOrEqual(rect.maxY, area.maxY, file: file, line: line) + } + + private func assertAspect(_ rect: CGRect, _ aspect: CGFloat, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual(rect.width / rect.height, aspect, accuracy: 0.03, file: file, line: line) + } + + // MARK: - Size + + func testPortraitTakesThreeQuartersOfTheWidthAndKeepsTheAspect() throws { + let rect = self.place(348, 560, aspect: self.portrait) + + XCTAssertEqual(rect.width, 261) + XCTAssertEqual(rect.height, 464) + self.assertAspect(rect, self.portrait) + self.assertInside(rect, CGRect(x: 0, y: 0, width: 348, height: 560)) + } + + func testPreviewIsCenteredInTheAreaAndFollowsItsOrigin() throws { + let area = CGRect(x: 10, y: 100, width: 348, height: 560) + let rect = VideoMessagePreviewPlacement.frame(in: area, aspect: self.portrait) + + XCTAssertEqual(rect.midX, area.midX, accuracy: 0.5) + XCTAssertEqual(rect.midY, area.midY, accuracy: 0.5) + } + + func testPortraitInALowAreaIsFittedByTheHeightWithMargins() throws { + let rect = self.place(348, 400, aspect: self.portrait) + + // 400 - 2 * 16 + XCTAssertEqual(rect.height, 368) + XCTAssertEqual(rect.width, 207) + self.assertAspect(rect, self.portrait) + } + + func testLandscapeOnAPhoneInLandscapeIsFittedByTheHeightAndNotSquashed() throws { + // Landscape iPhone: the area between the navigation bar and the recording panel is low + let area = CGRect(x: 0, y: 50, width: 700, height: 200) + let rect = VideoMessagePreviewPlacement.frame(in: area, aspect: self.landscape) + + XCTAssertEqual(rect.height, 168) + XCTAssertEqual(rect.width, 299) + self.assertAspect(rect, self.landscape) + self.assertInside(rect, area.insetBy(dx: 16, dy: 16)) + } + + func testLandscapeInAPortraitAreaIsFittedByTheWidth() throws { + let rect = self.place(348, 560, aspect: self.landscape) + + XCTAssertEqual(rect.width, 261) + XCTAssertEqual(rect.height, 147) + self.assertAspect(rect, self.landscape) + } + + func testNarrowAreaKeepsTheAspectAndTheMargin() throws { + let rect = self.place(240, 520, aspect: self.portrait) + + // 75 % of 240, the margins would allow 208 + XCTAssertEqual(rect.width, 180) + XCTAssertEqual(rect.height, 320) + self.assertAspect(rect, self.portrait) + } + + func testMarginLimitsTheWidthWhenItIsNarrowerThanTheFraction() throws { + let rect = VideoMessagePreviewPlacement.frame(in: CGRect(x: 0, y: 0, width: 200, height: 520), aspect: self.landscape, maxWidthFraction: 0.95) + + // 200 - 2 * 16 is less than 95 % of 200 + XCTAssertEqual(rect.width, 168) + self.assertAspect(rect, self.landscape) + } + + func testVeryLowAreaStillGivesAPreviewInsideTheArea() throws { + let area = CGRect(x: 0, y: 0, width: 348, height: 40) + let rect = VideoMessagePreviewPlacement.frame(in: area, aspect: self.portrait) + + XCTAssertGreaterThan(rect.height, 0) + XCTAssertGreaterThan(rect.width, 0) + self.assertAspect(rect, self.portrait) + self.assertInside(rect, area) + + let landscapeArea = CGRect(x: 0, y: 0, width: 700, height: 24) + let landscapeRect = VideoMessagePreviewPlacement.frame(in: landscapeArea, aspect: self.landscape) + + XCTAssertGreaterThan(landscapeRect.height, 0) + self.assertAspect(landscapeRect, self.landscape) + self.assertInside(landscapeRect, landscapeArea) + } + + func testIPadIsCappedByTheLongestSide() throws { + let portraitRect = self.place(1000, 1000, aspect: self.portrait) + + XCTAssertEqual(portraitRect.height, 480) + XCTAssertEqual(portraitRect.width, 270) + self.assertAspect(portraitRect, self.portrait) + + let landscapeRect = self.place(1100, 700, aspect: self.landscape) + + XCTAssertEqual(landscapeRect.width, 480) + XCTAssertEqual(landscapeRect.height, 270) + self.assertAspect(landscapeRect, self.landscape) + } + + // MARK: - Areas without size + + func testEmptyAreaGivesAnEmptyRect() throws { + XCTAssertEqual(self.place(0, 0, aspect: self.portrait).size, .zero) + XCTAssertEqual(self.place(348, 0, aspect: self.portrait).size, .zero) + XCTAssertEqual(self.place(0, 560, aspect: self.landscape).size, .zero) + } + + func testNegativeAreaGivesAnEmptyRect() throws { + // The area is the space above the panel, which is negative when the panel is higher than the chat + XCTAssertEqual(self.place(348, -120, aspect: self.portrait).size, .zero) + XCTAssertEqual(self.place(-5, 560, aspect: self.portrait).size, .zero) + XCTAssertEqual(self.place(-5, -5, aspect: self.landscape).size, .zero) + } + + func testAspectThatIsNotUsableIsReadAsPortrait() throws { + let expected = self.place(348, 560, aspect: self.portrait) + + XCTAssertEqual(self.place(348, 560, aspect: .nan), expected) + XCTAssertEqual(self.place(348, 560, aspect: 0), expected) + XCTAssertEqual(self.place(348, 560, aspect: -1), expected) + XCTAssertEqual(self.place(348, 560, aspect: .infinity), expected) + } + + // MARK: - Aspect of the orientation + + func testFrameAspectOfTheOrientations() throws { + let expected: [(UIInterfaceOrientation, CGFloat)] = [ + (.portrait, 9.0 / 16.0), + (.portraitUpsideDown, 9.0 / 16.0), + (.landscapeLeft, 16.0 / 9.0), + (.landscapeRight, 16.0 / 9.0), + (.unknown, 9.0 / 16.0) + ] + + for (orientation, aspect) in expected { + XCTAssertEqual(VideoMessagePreviewPlacement.frameAspect(for: orientation), aspect, accuracy: 0.0001, "orientation \(orientation.rawValue)") + } + } + + func testPlacementAfterARotationSwitchesBetweenThePortraitAndTheLandscapeShape() throws { + let inPortrait = self.place(390, 600, aspect: VideoMessagePreviewPlacement.frameAspect(for: .portrait)) + let inLandscape = self.place(750, 250, aspect: VideoMessagePreviewPlacement.frameAspect(for: .landscapeLeft)) + + XCTAssertLessThan(inPortrait.width, inPortrait.height) + XCTAssertGreaterThan(inLandscape.width, inLandscape.height) + } +} From 740a630e56e4aac87cd216929970591c0d5bceed Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:05:50 +0000 Subject: [PATCH 5/7] fix(chat): keep a locked video recording in the row of the inputbar A locked video recording no longer opens the tall panel of the voice message: it stays in the row of the inputbar, which shows the indicator and the time, and gets a cancel button in the row and a send button over the record button. The preview takes the freed height. Voice messages keep their panel, which loses the pause switch that only videos needed. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../BaseChatViewController+VideoMessage.swift | 12 ++--- .../Chat/BaseChatViewController.swift | 49 ++++++++++++++----- .../ExpandedVoiceMessageRecordingView.swift | 5 -- .../VoiceMessageRecordingView.swift | 22 +++++++++ ...UnitVideoMessagePreviewPlacementTest.swift | 22 +++++++++ 5 files changed, 85 insertions(+), 25 deletions(-) diff --git a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift index fee84a323..d41a549e7 100644 --- a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift +++ b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift @@ -270,29 +270,25 @@ extension BaseChatViewController { self.videoMessagePreviewView = nil self.videoMessageScrimView?.removeFromSuperview() self.videoMessageScrimView = nil + self.lockedVideoSendButton?.removeFromSuperview() + self.lockedVideoSendButton = nil self.tableView?.accessibilityElementsHidden = false } /// Places the preview in the free space above the recording panel: it follows the orientation of the interface, - /// and is placed again whenever the layout changes, e.g. by a rotation or when the panel of a locked recording appears. + /// and is placed again whenever the layout changes, e.g. by a rotation. func updateVideoMessagePreviewLayout() { guard let previewView = self.videoMessagePreviewView else { return } let inputbarTop = self.view.convert(self.textInputbar.bounds, from: self.textInputbar).minY - var areaBottom = inputbarTop - - if let panel = self.visibleExpandedRecordingPanel { - areaBottom = min(areaBottom, self.view.convert(panel.bounds, from: panel).minY) - } - self.videoMessageScrimView?.frame = CGRect(x: 0, y: 0, width: self.view.bounds.width, height: max(inputbarTop, 0)) let insets = self.view.safeAreaInsets let area = CGRect(x: insets.left, y: insets.top, width: self.view.bounds.width - insets.left - insets.right, - height: areaBottom - insets.top) + height: inputbarTop - insets.top) let orientation = CameraCaptureHelpers.interfaceOrientation(of: self.view) diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index ce27c9b2d..17caa18a0 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -107,6 +107,7 @@ import Toast internal var videoMessageRecorder: VideoMessageRecorder? internal var videoMessagePreviewView: VideoMessagePreviewView? internal var videoMessageScrimView: UIView? + internal var lockedVideoSendButton: UIButton? internal var videoMessageLimitTimer: Timer? private var animationDispatchGroup = DispatchGroup() @@ -1981,9 +1982,9 @@ import Toast // MARK: - Expanded voice message recording - func showExpandedVoiceMessageRecordingView(offset: Int, allowsPause: Bool = true) { + func showExpandedVoiceMessageRecordingView(offset: Int) { let expandedView = ExpandedVoiceMessageRecordingView( - deleteFunc: handleDelete, sendFunc: handleSend, recordFunc: handleRecord(isRecording:), allowsPause: allowsPause, timeElapsed: offset + deleteFunc: handleDelete, sendFunc: handleSend, recordFunc: handleRecord(isRecording:), timeElapsed: offset ) let hostingController = UIHostingController(rootView: expandedView) @@ -1991,8 +1992,6 @@ import Toast self.expandedUIHostingController = hostingController self.view.addSubview(expandedVoiceMessageRecordingView) - // The preview of a video makes room for the panel - self.view.setNeedsLayout() expandedVoiceMessageRecordingView.translatesAutoresizingMaskIntoConstraints = false @@ -2056,14 +2055,35 @@ import Toast self.expandedUIHostingController?.removeFromParent() self.expandedUIHostingController?.view.isHidden = true self.textInputbar.bringSubviewToFront(self.textInputbar) - self.view.setNeedsLayout() } - /// The panel of a locked recording while it is shown - internal var visibleExpandedRecordingPanel: UIView? { - guard let panel = self.expandedUIHostingController?.view, !panel.isHidden, panel.superview === self.view else { return nil } + /// A locked video recording stays in the row of the inputbar, which shows the indicator and the time. It gets the + /// button to cancel in the row and the one to send over the record button, which is no longer held. + internal func showLockedVideoMessageActions() { + self.voiceMessageRecordingView?.showCancelButton { [weak self] in + self?.handleDelete() + } + + var configuration = UIButton.Configuration.filled() + configuration.image = UIImage(systemName: "paperplane.fill") + configuration.cornerStyle = .capsule + configuration.baseBackgroundColor = .systemBlue + configuration.baseForegroundColor = .white + + let sendButton = UIButton(configuration: configuration) + sendButton.translatesAutoresizingMaskIntoConstraints = false + sendButton.accessibilityLabel = NSLocalizedString("Send message", comment: "") + sendButton.addAction(UIAction { [weak self] _ in self?.handleSend() }, for: .touchUpInside) + + self.textInputbar.addSubview(sendButton) + self.lockedVideoSendButton = sendButton - return panel + NSLayoutConstraint.activate([ + sendButton.centerXAnchor.constraint(equalTo: self.rightButton.centerXAnchor), + sendButton.centerYAnchor.constraint(equalTo: self.rightButton.centerYAnchor), + sendButton.widthAnchor.constraint(equalTo: self.rightButton.widthAnchor), + sendButton.heightAnchor.constraint(equalTo: self.rightButton.heightAnchor) + ]) } /// Brings the lock button of the recording above the views that are added for a video, which are shown over the chat @@ -2504,9 +2524,14 @@ import Toast if slideY > maxSlideY, !self.recordCancelled { if !isVoiceRecordingLocked { self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock"), for: .normal) - let offset = self.voiceMessageRecordingView?.getTimeCounted() - let intOffset = Int(offset!.magnitude) - showExpandedVoiceMessageRecordingView(offset: intOffset, allowsPause: !self.isVideoGestureActive) + if self.isVideoGestureActive { + // A video keeps the row of the inputbar, so the preview gets the height of a panel + self.showLockedVideoMessageActions() + } else { + let offset = self.voiceMessageRecordingView?.getTimeCounted() + let intOffset = Int(offset!.magnitude) + showExpandedVoiceMessageRecordingView(offset: intOffset) + } print("LOCKED") isVoiceRecordingLocked = true } diff --git a/NextcloudTalk/Chat/Chat views/ExpandedVoiceMessageRecordingView.swift b/NextcloudTalk/Chat/Chat views/ExpandedVoiceMessageRecordingView.swift index e511e22f7..760a64bfb 100644 --- a/NextcloudTalk/Chat/Chat views/ExpandedVoiceMessageRecordingView.swift +++ b/NextcloudTalk/Chat/Chat views/ExpandedVoiceMessageRecordingView.swift @@ -18,9 +18,6 @@ struct ExpandedVoiceMessageRecordingView: View { var sendFunc: () -> Void var recordFunc: (Bool) -> Void - /// A video can not be paused, so the stop/restart button is not usable for it - var allowsPause = true - @State var isRecording = true @State var timeElapsed: Int @State var timeFormatted = "" @@ -61,8 +58,6 @@ struct ExpandedVoiceMessageRecordingView: View { }, label: { Label("", systemImage: isRecording ? "square.fill" : "arrow.clockwise.square").font(.title2) }) - .opacity(allowsPause ? 1 : 0) - .disabled(!allowsPause) Spacer() Button(action: { // Send Recording self.sendFunc() diff --git a/NextcloudTalk/Chat/Chat views/VoiceMessageRecordingView.swift b/NextcloudTalk/Chat/Chat views/VoiceMessageRecordingView.swift index df130c370..1e4811dc8 100644 --- a/NextcloudTalk/Chat/Chat views/VoiceMessageRecordingView.swift +++ b/NextcloudTalk/Chat/Chat views/VoiceMessageRecordingView.swift @@ -119,6 +119,28 @@ class VoiceMessageRecordingView: UIView { leftBackgroundView.isHidden = true } + /// Replaces the hint to slide to cancel with a button, as the finger no longer holds the record button + /// once the recording is locked + func showCancelButton(action: @escaping () -> Void) { + hintContainerView.isHidden = true + + let cancelButton = UIButton(type: .system) + cancelButton.translatesAutoresizingMaskIntoConstraints = false + cancelButton.setTitle(NSLocalizedString("Cancel", comment: ""), for: .normal) + cancelButton.titleLabel?.font = .preferredFont(forTextStyle: .body) + cancelButton.addAction(UIAction { _ in action() }, for: .touchUpInside) + + contentView.addSubview(cancelButton) + + cancelButton.setContentCompressionResistancePriority(.required, for: .horizontal) + + NSLayoutConstraint.activate([ + cancelButton.leadingAnchor.constraint(greaterThanOrEqualTo: recordingTimeLabel.trailingAnchor, constant: 8), + cancelButton.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -16), + cancelButton.centerYAnchor.constraint(equalTo: contentView.centerYAnchor) + ]) + } + /// Gives the recording view a glass capsule of its own, so it can take the place of the input field @available(iOS 26.0, *) public func useGlassBackground(cornerRadius: CGFloat) { diff --git a/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift b/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift index 3627ab7df..5d610244f 100644 --- a/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift +++ b/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift @@ -169,4 +169,26 @@ final class UnitVideoMessagePreviewPlacementTest: XCTestCase { XCTAssertLessThan(inPortrait.width, inPortrait.height) XCTAssertGreaterThan(inLandscape.width, inLandscape.height) } + + // MARK: - Compact recording panel (iPhone 390x844 and 844x390, areas from the estimates in the report) + + func testLandscapePhoneGetsMoreWhenThePanelIsTheHeightOfTheInputbar() throws { + // Between the navigation bar (32) and the top of the inputbar (317), safe area left and right 47 + let compact = VideoMessagePreviewPlacement.frame(in: CGRect(x: 47, y: 32, width: 750, height: 285), aspect: self.landscape) + // Between the navigation bar and the top of the big panel of the first version (about 282) + let big = VideoMessagePreviewPlacement.frame(in: CGRect(x: 47, y: 32, width: 750, height: 250), aspect: self.landscape) + + XCTAssertEqual(compact, CGRect(x: 197, y: 48, width: 450, height: 253)) + XCTAssertEqual(big, CGRect(x: 228, y: 48, width: 388, height: 218)) + XCTAssertGreaterThan(compact.height, big.height) + self.assertAspect(compact, self.landscape) + } + + func testPortraitPhoneIsCappedByTheLongestSideWithTheCompactPanel() throws { + // Between the navigation bar (47 + 44) and the top of the inputbar (758) + let compact = VideoMessagePreviewPlacement.frame(in: CGRect(x: 0, y: 91, width: 390, height: 667), aspect: self.portrait) + + XCTAssertEqual(compact, CGRect(x: 60, y: 185, width: 270, height: 480)) + XCTAssertLessThanOrEqual(compact.maxY, 758 - 16) + } } From 270ddcdc5881b2eaf24246f38f19b58bd2780f19 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Sun, 4 Oct 2026 19:10:03 +0000 Subject: [PATCH 6/7] fix(chat): follow up on the review of the video message preview Overlays that appear while recording (pinned message, out of office) are added below the dimming, VoiceOver gets its focus moved to the preview and does not read the record button under the send button of a locked recording, the send button uses the colors of the theme, the orientation of the start is read in one way, and the test of the centering has room for rounding. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../BaseChatViewController+VideoMessage.swift | 17 +++++++++++++++-- NextcloudTalk/Chat/BaseChatViewController.swift | 9 +++++++-- NextcloudTalk/Chat/ChatViewController.swift | 4 ++-- .../UnitVideoMessagePreviewPlacementTest.swift | 4 ++-- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift index d41a549e7..ffb616bdb 100644 --- a/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift +++ b/NextcloudTalk/Chat/BaseChatViewController+VideoMessage.swift @@ -161,8 +161,7 @@ extension BaseChatViewController { // Playing a voice message and recording do not go together self.pauseVoiceMessagePlayer() - let interfaceOrientation = self.view.window?.windowScene?.interfaceOrientation ?? .portrait - let recorder = VideoMessageRecorder(interfaceOrientation: interfaceOrientation == .unknown ? .portrait : interfaceOrientation) + let recorder = VideoMessageRecorder(interfaceOrientation: CameraCaptureHelpers.interfaceOrientation(of: self.view)) recorder.onFailure = { [weak self, weak recorder] in guard let self, let recorder, self.videoMessageRecorder === recorder else { return } @@ -262,6 +261,17 @@ extension BaseChatViewController { self.tableView?.accessibilityElementsHidden = true self.updateVideoMessagePreviewLayout() + + UIAccessibility.post(notification: .layoutChanged, argument: previewView) + } + + /// Adds a view to the chat, below the dimming of a video recording when it is shown, so it is not left over it + func addBelowVideoMessageScrim(_ overlayView: UIView) { + if let scrimView = self.videoMessageScrimView { + self.view.insertSubview(overlayView, belowSubview: scrimView) + } else { + self.view.addSubview(overlayView) + } } /// Removes the preview and what is shown with it, whatever the way the recording ended @@ -274,6 +284,9 @@ extension BaseChatViewController { self.lockedVideoSendButton = nil self.tableView?.accessibilityElementsHidden = false + self.rightButton.accessibilityElementsHidden = false + + UIAccessibility.post(notification: .layoutChanged, argument: nil) } /// Places the preview in the free space above the recording panel: it follows the orientation of the interface, diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 17caa18a0..9745a8f91 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -2060,6 +2060,8 @@ import Toast /// A locked video recording stays in the row of the inputbar, which shows the indicator and the time. It gets the /// button to cancel in the row and the one to send over the record button, which is no longer held. internal func showLockedVideoMessageActions() { + self.lockedVideoSendButton?.removeFromSuperview() + self.voiceMessageRecordingView?.showCancelButton { [weak self] in self?.handleDelete() } @@ -2067,8 +2069,8 @@ import Toast var configuration = UIButton.Configuration.filled() configuration.image = UIImage(systemName: "paperplane.fill") configuration.cornerStyle = .capsule - configuration.baseBackgroundColor = .systemBlue - configuration.baseForegroundColor = .white + configuration.baseBackgroundColor = NCAppBranding.themeColor() + configuration.baseForegroundColor = NCAppBranding.themeTextColor() let sendButton = UIButton(configuration: configuration) sendButton.translatesAutoresizingMaskIntoConstraints = false @@ -2078,6 +2080,9 @@ import Toast self.textInputbar.addSubview(sendButton) self.lockedVideoSendButton = sendButton + // The send button takes its place, VoiceOver does not read the record button below it as well + self.rightButton.accessibilityElementsHidden = true + NSLayoutConstraint.activate([ sendButton.centerXAnchor.constraint(equalTo: self.rightButton.centerXAnchor), sendButton.centerYAnchor.constraint(equalTo: self.rightButton.centerYAnchor), diff --git a/NextcloudTalk/Chat/ChatViewController.swift b/NextcloudTalk/Chat/ChatViewController.swift index 186632c8d..d1738aac0 100644 --- a/NextcloudTalk/Chat/ChatViewController.swift +++ b/NextcloudTalk/Chat/ChatViewController.swift @@ -987,7 +987,7 @@ import SwiftUI oooView.setupAbsence(withData: absenceData, inRoom: self.room) oooView.alpha = 0 - self.view.addSubview(oooView) + self.addBelowVideoMessageScrim(oooView) NSLayoutConstraint.activate([ oooView.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), @@ -1030,7 +1030,7 @@ import SwiftUI view.setupPinnedMessage(withMessage: message, inRoom: self.room) view.alpha = 0 - self.view.addSubview(view) + self.addBelowVideoMessageScrim(view) NSLayoutConstraint.activate([ view.leadingAnchor.constraint(equalTo: self.view.safeAreaLayoutGuide.leadingAnchor), diff --git a/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift b/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift index 5d610244f..fcf6b7028 100644 --- a/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift +++ b/NextcloudTalkTests/Unit/Chat/UnitVideoMessagePreviewPlacementTest.swift @@ -42,8 +42,8 @@ final class UnitVideoMessagePreviewPlacementTest: XCTestCase { let area = CGRect(x: 10, y: 100, width: 348, height: 560) let rect = VideoMessagePreviewPlacement.frame(in: area, aspect: self.portrait) - XCTAssertEqual(rect.midX, area.midX, accuracy: 0.5) - XCTAssertEqual(rect.midY, area.midY, accuracy: 0.5) + XCTAssertEqual(rect.midX, area.midX, accuracy: 1) + XCTAssertEqual(rect.midY, area.midY, accuracy: 1) } func testPortraitInALowAreaIsFittedByTheHeightWithMargins() throws { From 938b8209994ce44c14479e05a22ea717b475f469 Mon Sep 17 00:00:00 2001 From: Krainov Gleb Date: Mon, 5 Oct 2026 09:12:20 +0000 Subject: [PATCH 7/7] refactor(chat): Move locking of a recording out of the gesture handler The handler of the record button gets one branch more with the locked video recording, which raised its cyclomatic complexity. The locking is its own function now, and the handler is as complex as before. Signed-off-by: Krainov Gleb Assisted-by: Claude-Code:claude-opus-5-5 --- .../Chat/BaseChatViewController.swift | 29 ++++++++++++------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/NextcloudTalk/Chat/BaseChatViewController.swift b/NextcloudTalk/Chat/BaseChatViewController.swift index 9745a8f91..1cd62f329 100644 --- a/NextcloudTalk/Chat/BaseChatViewController.swift +++ b/NextcloudTalk/Chat/BaseChatViewController.swift @@ -2528,17 +2528,7 @@ import Toast let maxSlideY = 64.0 if slideY > maxSlideY, !self.recordCancelled { if !isVoiceRecordingLocked { - self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock"), for: .normal) - if self.isVideoGestureActive { - // A video keeps the row of the inputbar, so the preview gets the height of a panel - self.showLockedVideoMessageActions() - } else { - let offset = self.voiceMessageRecordingView?.getTimeCounted() - let intOffset = Int(offset!.magnitude) - showExpandedVoiceMessageRecordingView(offset: intOffset) - } - print("LOCKED") - isVoiceRecordingLocked = true + self.lockVoiceMessageRecording() } } } @@ -2551,6 +2541,23 @@ import Toast } } + /// Locks the recording, so it goes on without holding the button. A video keeps the row of the inputbar, so its + /// preview gets the height of a panel, a voice message gets the expanded panel. + private func lockVoiceMessageRecording() { + self.voiceRecordingLockButton.setImage(UIImage(systemName: "lock"), for: .normal) + + if self.isVideoGestureActive { + self.showLockedVideoMessageActions() + } else { + let offset = self.voiceMessageRecordingView?.getTimeCounted() + let intOffset = Int(offset!.magnitude) + showExpandedVoiceMessageRecordingView(offset: intOffset) + } + + print("LOCKED") + isVoiceRecordingLocked = true + } + func shouldLockInterfaceOrientation(lock: Bool) { if let appDelegate = UIApplication.shared.delegate as? AppDelegate { appDelegate.shouldLockInterfaceOrientation = lock