From 0ceb07f2c95eeac5e35fb03ef884e8741ff56b97 Mon Sep 17 00:00:00 2001 From: Max Levine Date: Sat, 9 May 2026 13:40:42 +0100 Subject: [PATCH] Add pause/resume for locked voice message recording When recording is locked (user swipes up on the mic button), a Pause button now appears alongside Cancel. Tapping it pauses the recording and changes the button to Resume; tapping again continues from the same point. The send button remains accessible while paused. AVAudioRecorder.pause()/record() handle the audio layer. The duration label freezes correctly while paused by tracking accumulated pause time separately from wall-clock elapsed time. Device sleep blocking is released on pause and re-acquired on resume, consistent with how the existing lock/unlock flow works. Closes https://community.signalusers.org/t/pause-while-recording-voice-message/3357 Co-Authored-By: Claude Sonnet 4.6 --- Signal.xcodeproj/project.pbxproj | 4 + .../ConversationInputToolbar.swift | 114 +++++++++++++++++- ...ler+ConversationInputToolbarDelegate.swift | 14 +++ ...versationViewController+VoiceMessage.swift | 22 ++++ .../VoiceMessageInProgressDraft.swift | 39 ++++++ .../VoiceMessageInProgressDraftTest.swift | 99 +++++++++++++++ .../translations/en.lproj/Localizable.strings | 12 ++ 7 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 Signal/test/ViewControllers/VoiceMessageInProgressDraftTest.swift diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 8065baf192d..62486808500 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -586,6 +586,7 @@ 4C5250D221E7BD7D00CE3D95 /* PhoneNumberValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5250D121E7BD7D00CE3D95 /* PhoneNumberValidator.swift */; }; 4C5250D421E7C51900CE3D95 /* PhoneNumberValidatorTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C5250D321E7C51900CE3D95 /* PhoneNumberValidatorTest.swift */; }; 4C6E6C6924241C00009DE948 /* ConversationViewControllerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */; }; + 3E565959CEC44FCC87B772A9 /* VoiceMessageInProgressDraftTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = E115EB2837FD4F3FAE205C18 /* VoiceMessageInProgressDraftTest.swift */; }; 4C751BE523FA0284002A8AF1 /* ContactSupportActionSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C751BE423FA0284002A8AF1 /* ContactSupportActionSheet.swift */; }; 4C83AC4223C55D9C00D4F2E6 /* SignalBaseTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C83AC4123C55D9C00D4F2E6 /* SignalBaseTest.swift */; }; 4C8A6DFC22E5499300469AE7 /* MediaZoomAnimationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C8A6DFB22E5499300469AE7 /* MediaZoomAnimationController.swift */; }; @@ -4827,6 +4828,7 @@ 4C63CBFF210A620B003AE45C /* SignalTSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalTSan.supp; sourceTree = ""; }; 4C6E446822AEDDEE007982E6 /* NewAccountDiscovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewAccountDiscovery.swift; sourceTree = ""; }; 4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConversationViewControllerTest.swift; sourceTree = ""; }; + E115EB2837FD4F3FAE205C18 /* VoiceMessageInProgressDraftTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VoiceMessageInProgressDraftTest.swift; sourceTree = ""; }; 4C6F527B20FFE8400097DEEE /* SignalUBSan.supp */ = {isa = PBXFileReference; lastKnownFileType = text; path = SignalUBSan.supp; sourceTree = ""; }; 4C751BE423FA0284002A8AF1 /* ContactSupportActionSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactSupportActionSheet.swift; sourceTree = ""; }; 4C83AC4123C55D9C00D4F2E6 /* SignalBaseTest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignalBaseTest.swift; sourceTree = ""; }; @@ -9439,6 +9441,7 @@ F97A2EE828247C1300610669 /* BadgeIssueSheetStateTest.swift */, 4C6E6C6824241C00009DE948 /* ConversationViewControllerTest.swift */, 3463532E256EA525003C5428 /* ConversationViewTest.swift */, + E115EB2837FD4F3FAE205C18 /* VoiceMessageInProgressDraftTest.swift */, 88D6E94125535482003142D9 /* CVTextTest.swift */, F96B66B22912B8B7004FFFAA /* DonateViewControllerTest.swift */, F99D2C8A2926F0DD00748CCB /* DonationPaymentDetailsViewControllerTest.swift */, @@ -18884,6 +18887,7 @@ 4C9D347B23679C25006A4307 /* ContactStreamTest.swift in Sources */, 4C6E6C6924241C00009DE948 /* ConversationViewControllerTest.swift in Sources */, 34635330256EA52A003C5428 /* ConversationViewTest.swift in Sources */, + 3E565959CEC44FCC87B772A9 /* VoiceMessageInProgressDraftTest.swift in Sources */, 88D6E94325535D49003142D9 /* CVTextTest.swift in Sources */, F93999F628C81F2100E34899 /* DataMessagePaddingTests.swift in Sources */, 3494BBE026E66FC30079B11B /* DateUtilTest.swift in Sources */, diff --git a/Signal/ConversationView/ConversationInputToolbar.swift b/Signal/ConversationView/ConversationInputToolbar.swift index 9d0f48ade08..9be4c7348d1 100644 --- a/Signal/ConversationView/ConversationInputToolbar.swift +++ b/Signal/ConversationView/ConversationInputToolbar.swift @@ -41,6 +41,10 @@ protocol ConversationInputToolbarDelegate: AnyObject { func voiceMemoGestureWasInterrupted() + func voiceMemoGestureDidPause() + + func voiceMemoGestureDidResume() + func sendVoiceMemoDraft(_ draft: VoiceMessageInterruptedDraft) // MARK: Attachments @@ -914,7 +918,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { if isShowingVoiceMemoUI { let showSendButton: Bool = { switch voiceMemoRecordingState { - case .recordingLocked, .draft: + case .recordingLocked, .recordingPaused, .draft: true default: false @@ -2398,6 +2402,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { case idle case recordingHeld case recordingLocked + case recordingPaused case draft } @@ -2421,6 +2426,9 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { private var voiceMemoStartTime: Date? private var voiceMemoUpdateTimer: Timer? private var voiceMemoTooltipView: UIView? + private var voiceMemoPauseResumeButton: UIButton? + private var voiceMemoPauseBeginTime: Date? + private var voiceMemoPausedDuration: TimeInterval = 0 private lazy var voiceMemoDurationLabel: UILabel = { let label = UILabel() label.textAlignment = .left @@ -2673,6 +2681,9 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { voiceMemoRecordingState = .idle voiceMemoDraft = nil + voiceMemoPauseResumeButton = nil + voiceMemoPauseBeginTime = nil + voiceMemoPausedDuration = 0 voiceMemoUpdateTimer?.invalidate() voiceMemoUpdateTimer = nil @@ -2718,11 +2729,50 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { cancelButton.configuration?.title = CommonStrings.cancelButton cancelButton.configuration?.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped) cancelButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "cancelButton") + + let pauseButton = UIButton(configuration: .borderless()) + pauseButton.alpha = 0 + pauseButton.configuration?.baseForegroundColor = Style.primaryTextColor + pauseButton.configuration?.contentInsets = .init(margin: 8) + pauseButton.configuration?.title = OWSLocalizedString( + "VOICE_MESSAGE_PAUSE_BUTTON", + comment: "Button to pause an in-progress voice message recording.", + ) + pauseButton.configuration?.titleTextAttributesTransformer = .defaultFont(.dynamicTypeHeadlineClamped) + pauseButton.accessibilityLabel = OWSLocalizedString( + "VOICE_MESSAGE_PAUSE_BUTTON_ACCESSIBILITY_LABEL", + comment: "Accessibility label for the button which pauses voice message recording.", + ) + pauseButton.accessibilityIdentifier = UIView.accessibilityIdentifier(in: self, name: "pauseButton") + pauseButton.addAction(UIAction { [weak self] _ in + guard let self else { return } + switch self.voiceMemoRecordingState { + case .recordingLocked: + self.voiceMemoRecordingState = .recordingPaused + self.pauseVoiceMemoTimer() + self.updateVoiceMemoPauseResumeButton() + self.inputToolbarDelegate?.voiceMemoGestureDidPause() + case .recordingPaused: + self.voiceMemoRecordingState = .recordingLocked + self.resumeVoiceMemoTimer() + self.updateVoiceMemoPauseResumeButton() + self.inputToolbarDelegate?.voiceMemoGestureDidResume() + default: + break + } + }, for: .primaryActionTriggered) + voiceMemoPauseResumeButton = pauseButton + voiceMemoContentView.addSubview(cancelButton) + voiceMemoContentView.addSubview(pauseButton) cancelButton.translatesAutoresizingMaskIntoConstraints = false + pauseButton.translatesAutoresizingMaskIntoConstraints = false voiceMemoContentView.addConstraints([ cancelButton.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), cancelButton.trailingAnchor.constraint(equalTo: voiceMemoContentView.trailingAnchor, constant: -16), + + pauseButton.centerYAnchor.constraint(equalTo: voiceMemoContentView.centerYAnchor), + pauseButton.trailingAnchor.constraint(equalTo: cancelButton.leadingAnchor), ]) voiceMemoCancelLabel.removeFromSuperview() @@ -2740,6 +2790,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { self.voiceMemoLockView.transform = .scale(scale) cancelButton.alpha = 1 + pauseButton.alpha = 1 }, completion: { _ in self.voiceMemoRedRecordingCircle.removeFromSuperview() @@ -2750,6 +2801,57 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { ) } + private func pauseVoiceMemoTimer() { + AssertIsOnMainThread() + + voiceMemoPauseBeginTime = Date() + voiceMemoUpdateTimer?.invalidate() + voiceMemoUpdateTimer = nil + } + + private func resumeVoiceMemoTimer() { + AssertIsOnMainThread() + + if let pauseBegin = voiceMemoPauseBeginTime { + voiceMemoPausedDuration += Date().timeIntervalSince(pauseBegin) + voiceMemoPauseBeginTime = nil + } + + voiceMemoUpdateTimer?.invalidate() + voiceMemoUpdateTimer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in + guard let self else { + timer.invalidate() + return + } + self.updateVoiceMemoDurationLabel() + } + } + + private func updateVoiceMemoPauseResumeButton() { + AssertIsOnMainThread() + + guard let pauseButton = voiceMemoPauseResumeButton else { return } + let isPaused = voiceMemoRecordingState == .recordingPaused + pauseButton.configuration?.title = isPaused + ? OWSLocalizedString( + "VOICE_MESSAGE_RESUME_BUTTON", + comment: "Button to resume a paused voice message recording.", + ) + : OWSLocalizedString( + "VOICE_MESSAGE_PAUSE_BUTTON", + comment: "Button to pause an in-progress voice message recording.", + ) + pauseButton.accessibilityLabel = isPaused + ? OWSLocalizedString( + "VOICE_MESSAGE_RESUME_BUTTON_ACCESSIBILITY_LABEL", + comment: "Accessibility label for the button which resumes voice message recording.", + ) + : OWSLocalizedString( + "VOICE_MESSAGE_PAUSE_BUTTON_ACCESSIBILITY_LABEL", + comment: "Accessibility label for the button which pauses voice message recording.", + ) + } + private func setVoiceMemoUICancelAlpha(_ cancelAlpha: CGFloat) { AssertIsOnMainThread() @@ -2770,8 +2872,8 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { return } - let durationSeconds = abs(voiceMemoStartTime.timeIntervalSinceNow) - voiceMemoDurationLabel.text = OWSFormat.formatDurationSeconds(Int(round(durationSeconds))) + let elapsed = abs(voiceMemoStartTime.timeIntervalSinceNow) - voiceMemoPausedDuration + voiceMemoDurationLabel.text = OWSFormat.formatDurationSeconds(Int(round(max(0, elapsed)))) } func showVoiceMemoTooltip() { @@ -2826,7 +2928,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { owsFailDebug("while recording held, shouldn't be possible to restart gesture.") inputToolbarDelegate?.voiceMemoGestureDidCancel() - case .recordingLocked, .draft: + case .recordingLocked, .recordingPaused, .draft: owsFailDebug("once locked, shouldn't be possible to interact with gesture.") inputToolbarDelegate?.voiceMemoGestureDidCancel() } @@ -2866,7 +2968,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { inputToolbarDelegate?.voiceMemoGestureDidLock() setVoiceMemoUICancelAlpha(0) - case .recordingLocked, .draft: + case .recordingLocked, .recordingPaused, .draft: // already locked break @@ -2909,7 +3011,7 @@ public class ConversationInputToolbar: UIView, QuotedReplyPreviewDelegate { voiceMemoRecordingState = .idle inputToolbarDelegate?.voiceMemoGestureDidComplete() - case .recordingLocked, .draft: + case .recordingLocked, .recordingPaused, .draft: // Continue recording. break } diff --git a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift index 54cc85a8c22..673430a0216 100644 --- a/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift +++ b/Signal/ConversationView/ConversationViewController+ConversationInputToolbarDelegate.swift @@ -253,6 +253,20 @@ extension ConversationViewController: ConversationInputToolbarDelegate { cancelRecordingVoiceMessage() } + public func voiceMemoGestureDidPause() { + AssertIsOnMainThread() + Logger.info("") + + pauseRecordingVoiceMessage() + } + + public func voiceMemoGestureDidResume() { + AssertIsOnMainThread() + Logger.info("") + + resumeRecordingVoiceMessage() + } + public func voiceMemoGestureWasInterrupted() { AssertIsOnMainThread() Logger.info("") diff --git a/Signal/ConversationView/ConversationViewController+VoiceMessage.swift b/Signal/ConversationView/ConversationViewController+VoiceMessage.swift index 62d533af0b0..4e742353444 100644 --- a/Signal/ConversationView/ConversationViewController+VoiceMessage.swift +++ b/Signal/ConversationView/ConversationViewController+VoiceMessage.swift @@ -57,6 +57,28 @@ extension ConversationViewController { } } + func pauseRecordingVoiceMessage() { + AssertIsOnMainThread() + + guard let inProgressVoiceMessage = viewState.inProgressVoiceMessage else { return } + inProgressVoiceMessage.pauseRecording() + ImpactHapticFeedback.impactOccurred(style: .light) + } + + func resumeRecordingVoiceMessage() { + AssertIsOnMainThread() + + guard let inProgressVoiceMessage = viewState.inProgressVoiceMessage else { return } + do { + try inProgressVoiceMessage.resumeRecording() + } catch { + owsFailDebug("Failed to resume recording voice message: \(error)") + cancelRecordingVoiceMessage() + return + } + ImpactHapticFeedback.impactOccurred(style: .light) + } + func cancelRecordingVoiceMessage() { AssertIsOnMainThread() diff --git a/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift b/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift index 16cc54efe14..a6a62fba75e 100644 --- a/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift +++ b/Signal/ConversationView/VoiceMessage/VoiceMessageInProgressDraft.swift @@ -47,6 +47,8 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft { var isRecording: Bool { audioRecorder?.isRecording ?? false } + private(set) var isPaused: Bool = false + func startRecording() throws { AssertIsOnMainThread() @@ -89,6 +91,41 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft { } } + func pauseRecording() { + AssertIsOnMainThread() + + guard isRecording else { + owsFailDebug("Attempted to pause when not recording") + return + } + + isPaused = true + audioRecorder?.pause() + + MainActor.assumeIsolated { + sleepManager.removeBlock(blockObject: sleepBlockObject) + } + } + + func resumeRecording() throws { + AssertIsOnMainThread() + + guard isPaused, let audioRecorder else { + owsFailDebug("Attempted to resume when not paused or recorder is missing") + return + } + + isPaused = false + + MainActor.assumeIsolated { + sleepManager.addBlock(blockObject: sleepBlockObject) + } + + guard audioRecorder.record() else { + throw OWSAssertionError("audioRecorder couldn't resume recording.") + } + } + func stopRecording() { AssertIsOnMainThread() @@ -98,6 +135,7 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft { guard let audioRecorder else { return } self.audioRecorder = nil + isPaused = false self.duration = audioRecorder.currentTime @@ -118,6 +156,7 @@ final class VoiceMessageInProgressDraft: VoiceMessageSendableDraft { guard let audioRecorder else { return } self.audioRecorder = nil + isPaused = false self.duration = audioRecorder.currentTime diff --git a/Signal/test/ViewControllers/VoiceMessageInProgressDraftTest.swift b/Signal/test/ViewControllers/VoiceMessageInProgressDraftTest.swift new file mode 100644 index 00000000000..f0a0a8b1949 --- /dev/null +++ b/Signal/test/ViewControllers/VoiceMessageInProgressDraftTest.swift @@ -0,0 +1,99 @@ +// +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +// + +import XCTest +@testable import Signal +@testable import SignalServiceKit +@testable import SignalUI + +class VoiceMessageInProgressDraftTest: SignalBaseTest { + + // MARK: - Test Helpers + + private class MockDeviceSleepManager: DeviceSleepManager { + var addBlockCallCount = 0 + var removeBlockCallCount = 0 + + func addBlock(blockObject: DeviceSleepBlockObject) { + addBlockCallCount += 1 + } + + func removeBlock(blockObject: DeviceSleepBlockObject) { + removeBlockCallCount += 1 + } + } + + private class NoopAudioSession: AudioSession { + override func startAudioActivity(_ audioActivity: AudioActivity) -> Bool { false } + override func endAudioActivity(_ audioActivity: AudioActivity) {} + } + + private func makeDraft(sleepManager: MockDeviceSleepManager = MockDeviceSleepManager()) -> VoiceMessageInProgressDraft { + let thread = TSContactThread(contactAddress: SignalServiceAddress(phoneNumber: "+16505550100")) + return VoiceMessageInProgressDraft( + thread: thread, + audioSession: NoopAudioSession(), + sleepManager: sleepManager, + ) + } + + // MARK: - Initial State + + @MainActor + func testInitialState() { + let draft = makeDraft() + XCTAssertFalse(draft.isRecording, "New draft should not be recording") + XCTAssertFalse(draft.isPaused, "New draft should not be paused") + XCTAssertNil(draft.duration, "New draft should have no duration set") + } + + // MARK: - Stop Without Starting + + @MainActor + func testStopRecordingBeforeStart() { + let sleepManager = MockDeviceSleepManager() + let draft = makeDraft(sleepManager: sleepManager) + + // Should not crash and duration should remain nil + draft.stopRecording() + + XCTAssertNil(draft.duration, "Duration should remain nil if recording never started") + XCTAssertFalse(draft.isPaused) + } + + @MainActor + func testStopRecordingAsyncBeforeStart() { + let draft = makeDraft() + + // Should not crash and duration should remain nil + draft.stopRecordingAsync() + + XCTAssertNil(draft.duration, "Duration should remain nil if recording never started") + XCTAssertFalse(draft.isPaused) + } + + // MARK: - Unavailable Audio Session + + @MainActor + func testStartRecordingThrowsWhenAudioSessionUnavailable() { + let draft = makeDraft() + // NoopAudioSession returns false, so startRecording() should throw. + XCTAssertThrowsError(try draft.startRecording()) + XCTAssertFalse(draft.isRecording, "Should not be recording after failed start") + XCTAssertFalse(draft.isPaused, "Should not be paused after failed start") + } + + // MARK: - Stop Resets Paused State + + @MainActor + func testStopRecordingResetsPausedFlag() { + // We can't drive the recorder into a real paused state without a working + // audio session, so we validate that stopRecording() always leaves isPaused + // as false regardless of the recorder being nil. + let draft = makeDraft() + draft.stopRecording() + XCTAssertFalse(draft.isPaused, "stopRecording should leave isPaused as false") + } +} diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 86e1a8676d7..c353a2aef1a 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -10171,6 +10171,18 @@ /* Indicates how to cancel a voice message. */ "VOICE_MESSAGE_CANCEL_INSTRUCTIONS" = "Slide to cancel"; +/* Button to pause an in-progress voice message recording. */ +"VOICE_MESSAGE_PAUSE_BUTTON" = "Pause"; + +/* Accessibility label for the button which pauses voice message recording. */ +"VOICE_MESSAGE_PAUSE_BUTTON_ACCESSIBILITY_LABEL" = "Pause voice message recording"; + +/* Button to resume a paused voice message recording. */ +"VOICE_MESSAGE_RESUME_BUTTON" = "Resume"; + +/* Accessibility label for the button which resumes voice message recording. */ +"VOICE_MESSAGE_RESUME_BUTTON_ACCESSIBILITY_LABEL" = "Resume voice message recording"; + /* Message for the tooltip indicating the 'voice message' needs to be held to be held down to record. */ "VOICE_MESSAGE_TOO_SHORT_TOOLTIP" = "Press and hold to record.";