Skip to content

Commit

Permalink
#4090 - Add voice message controller, audio recorder and toolbar view…
Browse files Browse the repository at this point in the history
… links. Working audio file sending and cancellation.
  • Loading branch information
stefanceriu committed Jun 7, 2021
1 parent ff537e8 commit f0c9138
Show file tree
Hide file tree
Showing 9 changed files with 273 additions and 19 deletions.
23 changes: 22 additions & 1 deletion Riot/Modules/Room/RoomViewController.m
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,7 @@
@interface RoomViewController () <UISearchBarDelegate, UIGestureRecognizerDelegate, UIScrollViewAccessibilityDelegate, RoomTitleViewTapGestureDelegate, RoomParticipantsViewControllerDelegate, MXKRoomMemberDetailsViewControllerDelegate, ContactsTableViewControllerDelegate, MXServerNoticesDelegate, RoomContextualMenuViewControllerDelegate,
ReactionsMenuViewModelCoordinatorDelegate, EditHistoryCoordinatorBridgePresenterDelegate, MXKDocumentPickerPresenterDelegate, EmojiPickerCoordinatorBridgePresenterDelegate,
ReactionHistoryCoordinatorBridgePresenterDelegate, CameraPresenterDelegate, MediaPickerCoordinatorBridgePresenterDelegate,
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate>
RoomDataSourceDelegate, RoomCreationModalCoordinatorBridgePresenterDelegate, RoomInfoCoordinatorBridgePresenterDelegate, DialpadViewControllerDelegate, RemoveJitsiWidgetViewDelegate, VoiceMessageControllerDelegate>
{

// The preview header
Expand Down Expand Up @@ -240,6 +240,8 @@ @interface RoomViewController () <UISearchBarDelegate, UIGestureRecognizerDelega
@property (nonatomic, getter=isActivitiesViewExpanded) BOOL activitiesViewExpanded;
@property (nonatomic, getter=isScrollToBottomHidden) BOOL scrollToBottomHidden;

@property (nonatomic, strong, readonly) VoiceMessageController *voiceMessageController;

@end

@implementation RoomViewController
Expand Down Expand Up @@ -313,6 +315,9 @@ - (void)finalizeInit

// Show / hide actions button in document preview according BuildSettings
self.allowActionsInDocumentPreview = BuildSettings.messageDetailsAllowShare;

_voiceMessageController = [[VoiceMessageController alloc] initWithThemeService:ThemeService.shared];
self.voiceMessageController.delegate = self;
}

- (void)viewDidLoad
Expand Down Expand Up @@ -1114,6 +1119,9 @@ - (void)updateRoomInputToolbarViewClassIfNeeded
if (!self.inputToolbarView || ![self.inputToolbarView isMemberOfClass:roomInputToolbarViewClass])
{
[super setRoomInputToolbarViewClass:roomInputToolbarViewClass];

[(RoomInputToolbarView *)self.inputToolbarView setVoiceMessageToolbarView:self.voiceMessageController.voiceMessageToolbarView];

[self updateInputToolBarViewHeight];
}
}
Expand Down Expand Up @@ -6153,4 +6161,17 @@ - (void)removeJitsiWidgetViewDidCompleteSliding:(RemoveJitsiWidgetView *)view
}];
}

#pragma mark - VoiceMessageControllerDelegate

- (void)voiceMessageController:(VoiceMessageController *)voiceMessageController didRequestSendForFileAtURL:(NSURL *)url completion:(void (^)(BOOL))completion
{
[self.roomDataSource sendAudioFile:url mimeType:@"audio/mp4" success:^(NSString *eventId) {
MXLogDebug(@"Success with event id %@", eventId);
completion(YES);
} failure:^(NSError *error) {
MXLogError(@"Failed sending voice message");
completion(NO);
}];
}

@end
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ typedef enum : NSUInteger
@property (weak, nonatomic) IBOutlet UILabel *inputContextLabel;
@property (weak, nonatomic) IBOutlet UIButton *inputContextButton;
@property (weak, nonatomic) IBOutlet RoomActionsBar *actionsBar;
@property (weak, nonatomic) UIView *voiceMessageToolbarView;

/**
Tell whether the filled data will be sent encrypted. NO by default.
Expand Down
15 changes: 7 additions & 8 deletions Riot/Modules/Room/Views/InputToolbar/RoomInputToolbarView.m
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,6 @@ @interface RoomInputToolbarView()
UIAlertController *actionSheet;
}

@property (nonatomic, strong) VoiceMessageToolbarView *voiceMessageToolbarView;

@end

@implementation RoomInputToolbarView
Expand Down Expand Up @@ -78,16 +76,19 @@ - (void)awakeFromNib

self.isEncryptionEnabled = _isEncryptionEnabled;

self.voiceMessageToolbarView = [VoiceMessageToolbarView instanceFromNib];
[self _updateUIWithTextMessage:nil animated:NO];
}

- (void)setVoiceMessageToolbarView:(UIView *)voiceMessageToolbarView
{
_voiceMessageToolbarView = voiceMessageToolbarView;
self.voiceMessageToolbarView.translatesAutoresizingMaskIntoConstraints = NO;
[self addSubview:self.voiceMessageToolbarView];

[NSLayoutConstraint activateConstraints:@[[self.mainToolbarView.topAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.topAnchor],
[self.mainToolbarView.leftAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.leftAnchor],
[self.mainToolbarView.bottomAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.bottomAnchor],
[self.mainToolbarView.rightAnchor constraintEqualToAnchor:self.voiceMessageToolbarView.rightAnchor]]];

[self _updateUIWithTextMessage:nil animated:NO];
}

#pragma mark - Override MXKView
Expand Down Expand Up @@ -140,8 +141,6 @@ -(void)customizeViewRendering
self.inputContextLabel.textColor = ThemeService.shared.theme.textSecondaryColor;
self.inputContextButton.tintColor = ThemeService.shared.theme.textSecondaryColor;
[self.actionsBar updateWithTheme:ThemeService.shared.theme];

[self.voiceMessageToolbarView updateWithTheme:ThemeService.shared.theme];
}

#pragma mark -
Expand Down
17 changes: 17 additions & 0 deletions Riot/Modules/Room/VoiceMessages/AudioPlayer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
82 changes: 82 additions & 0 deletions Riot/Modules/Room/VoiceMessages/AudioRecorder.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation
import AVFoundation

protocol AudioRecorderDelegate: AnyObject {
func audioRecorderDidStartRecording(_ audioRecorder: AudioRecorder)
func audioRecorderDidFinishRecording(_ audioRecorder: AudioRecorder)
func audioRecorder(_ audioRecorder: AudioRecorder, didFailWithError: Error)
}

enum AudioRecorderError: Error {
case genericError
}

class AudioRecorder: NSObject, AVAudioRecorderDelegate {

private(set) var isRecording: Bool = false
private(set) var currentTime: TimeInterval = 0

var url: URL? {
return audioRecorder?.url
}
private var audioRecorder: AVAudioRecorder?

weak var delegate: AudioRecorderDelegate?

func recordWithOuputURL(_ url: URL) {

let settings = [AVFormatIDKey: Int(kAudioFormatMPEG4AAC),
AVSampleRateKey: 12000,
AVNumberOfChannelsKey: 1,
AVEncoderAudioQualityKey: AVAudioQuality.high.rawValue]

do {
try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default)
audioRecorder = try AVAudioRecorder(url: url, settings: settings)
audioRecorder?.delegate = self
audioRecorder?.record()
delegate?.audioRecorderDidStartRecording(self)
} catch {
delegate?.audioRecorder(self, didFailWithError: AudioRecorderError.genericError)
}

}

func stopRecording() {
audioRecorder?.stop()
}

// MARK: - AVAudioRecorderDelegate

func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully success: Bool) {
if success {
delegate?.audioRecorderDidFinishRecording(self)
} else {
delegate?.audioRecorder(self, didFailWithError: AudioRecorderError.genericError)
}
}

func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) {
delegate?.audioRecorder(self, didFailWithError: AudioRecorderError.genericError)
}
}

extension String: LocalizedError {
public var errorDescription: String? { return self }
}
109 changes: 109 additions & 0 deletions Riot/Modules/Room/VoiceMessages/VoiceMessageController.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
//
// Copyright 2021 New Vector Ltd
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

@objc public protocol VoiceMessageControllerDelegate: AnyObject {
func voiceMessageController(_ voiceMessageController: VoiceMessageController, didRequestSendForFileAtURL url: URL, completion: @escaping (Bool) -> Void)
}

public class VoiceMessageController: NSObject, VoiceMessageToolbarViewDelegate, AudioRecorderDelegate {

private let themeService: ThemeService
private let _voiceMessageToolbarView: VoiceMessageToolbarView

private var audioRecorder: AudioRecorder?

@objc public weak var delegate: VoiceMessageControllerDelegate?

@objc public var voiceMessageToolbarView: UIView {
return _voiceMessageToolbarView
}

@objc public init(themeService: ThemeService) {
_voiceMessageToolbarView = VoiceMessageToolbarView.instanceFromNib()
self.themeService = themeService

super.init()

_voiceMessageToolbarView.delegate = self

self._voiceMessageToolbarView.update(theme: self.themeService.theme)
NotificationCenter.default.addObserver(self, selector: #selector(handleThemeDidChange), name: .themeServiceDidChangeTheme, object: nil)
}

// MARK: - VoiceMessageToolbarViewDelegate

func voiceMessageToolbarViewDidRequestRecordingStart(_ toolbarView: VoiceMessageToolbarView) {
let temporaryDirectoryURL = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
let temporaryFileURL = temporaryDirectoryURL.appendingPathComponent(ProcessInfo().globallyUniqueString)

audioRecorder = AudioRecorder()
audioRecorder?.delegate = self
audioRecorder?.recordWithOuputURL(temporaryFileURL)
}

func voiceMessageToolbarViewDidRequestRecordingFinish(_ toolbarView: VoiceMessageToolbarView) {
audioRecorder?.stopRecording()

guard let url = audioRecorder?.url else {
MXLog.error("Invalid audio recording URL")
return
}

delegate?.voiceMessageController(self, didRequestSendForFileAtURL: url) { [weak self] success in
self?.deleteRecordingAtURL(url)
}
}

func voiceMessageToolbarViewDidRequestRecordingCancel(_ toolbarView: VoiceMessageToolbarView) {
audioRecorder?.stopRecording()
deleteRecordingAtURL(audioRecorder?.url)
}

// MARK: - AudioRecorderDelegate

func audioRecorderDidStartRecording(_ audioRecorder: AudioRecorder) {
_voiceMessageToolbarView.state = .recording
}

func audioRecorderDidFinishRecording(_ audioRecorder: AudioRecorder) {
_voiceMessageToolbarView.state = .idle
}

func audioRecorder(_ audioRecorder: AudioRecorder, didFailWithError: Error) {
MXLog.error("Failed recording voice message.")
_voiceMessageToolbarView.state = .idle
}

// MARK: - Private

private func deleteRecordingAtURL(_ url: URL?) {
guard let url = url else {
return
}

do {
try FileManager.default.removeItem(at: url)
} catch {
MXLog.error(error)
}
}

@objc private func handleThemeDidChange() {
self._voiceMessageToolbarView.update(theme: self.themeService.theme)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,20 @@

import UIKit

private enum VoiceMessageToolbarViewState {
protocol VoiceMessageToolbarViewDelegate: AnyObject {
func voiceMessageToolbarViewDidRequestRecordingStart(_ toolbarView: VoiceMessageToolbarView)
func voiceMessageToolbarViewDidRequestRecordingCancel(_ toolbarView: VoiceMessageToolbarView)
func voiceMessageToolbarViewDidRequestRecordingFinish(_ toolbarView: VoiceMessageToolbarView)
}

enum VoiceMessageToolbarViewState {
case idle
case recording
}

class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDelegate {

weak var delegate: VoiceMessageToolbarViewDelegate?

@IBOutlet private var backgroundView: UIView!

Expand All @@ -36,14 +44,22 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel

private var cancelLabelToRecordButtonDistance: CGFloat = 0.0

private var state: VoiceMessageToolbarViewState = .idle {
private var currentTheme: Theme? {
didSet {
updateUIAnimated(true)
}
}

private var currentTheme: Theme? {
var state: VoiceMessageToolbarViewState = .idle {
didSet {
switch state {
case .recording:
let convertedFrame = self.convert(slideToCancelLabel.frame, from: slideToCancelContainerView)
cancelLabelToRecordButtonDistance = recordButtonsContainerView.frame.minX - convertedFrame.maxX
case .idle:
cancelDrag()
}

updateUIAnimated(true)
}
}
Expand Down Expand Up @@ -90,13 +106,11 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel
@objc private func handleLongPress(_ gestureRecognizer: UILongPressGestureRecognizer) {
switch gestureRecognizer.state {
case UIGestureRecognizer.State.began:
state = .recording

let convertedFrame = self.convert(slideToCancelLabel.frame, from: slideToCancelContainerView)
cancelLabelToRecordButtonDistance = recordButtonsContainerView.frame.minX - convertedFrame.maxX

delegate?.voiceMessageToolbarViewDidRequestRecordingStart(self)
case UIGestureRecognizer.State.ended:
state = .idle
delegate?.voiceMessageToolbarViewDidRequestRecordingFinish(self)
case UIGestureRecognizer.State.cancelled:
delegate?.voiceMessageToolbarViewDidRequestRecordingCancel(self)
default:
break
}
Expand All @@ -111,6 +125,17 @@ class VoiceMessageToolbarView: PassthroughView, Themable, UIGestureRecognizerDel

recordButtonsContainerView.transform = CGAffineTransform(translationX: min(translation.x, 0.0), y: 0.0)
slideToCancelContainerView.transform = CGAffineTransform(translationX: min(translation.x + cancelLabelToRecordButtonDistance, 0.0), y: 0.0)

if abs(translation.x) > self.bounds.width / 2.0 {
cancelDrag()
}
}

private func cancelDrag() {
recordButtonsContainerView.gestureRecognizers?.forEach { gestureRecognizer in
gestureRecognizer.isEnabled = false
gestureRecognizer.isEnabled = true
}
}

private func updateUIAnimated(_ animated: Bool) {
Expand Down
Loading

0 comments on commit f0c9138

Please sign in to comment.