diff --git a/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_audio_mode.imageset/Contents.json b/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_audio_mode.imageset/Contents.json new file mode 100644 index 0000000000..9a7786cf26 --- /dev/null +++ b/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_audio_mode.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_speaker_bluetooth_inactive_audio_mode.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_audio_mode.imageset/ic_speaker_bluetooth_inactive_audio_mode.png b/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_audio_mode.imageset/ic_speaker_bluetooth_inactive_audio_mode.png new file mode 100644 index 0000000000..657ad527cd Binary files /dev/null and b/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_audio_mode.imageset/ic_speaker_bluetooth_inactive_audio_mode.png differ diff --git a/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_video_mode.imageset/Contents.json b/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_video_mode.imageset/Contents.json new file mode 100644 index 0000000000..cc8674b064 --- /dev/null +++ b/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_video_mode.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "filename" : "ic_speaker_bluetooth_inactive_video_mode.png", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "version" : 1, + "author" : "xcode" + } +} \ No newline at end of file diff --git a/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_video_mode.imageset/ic_speaker_bluetooth_inactive_video_mode.png b/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_video_mode.imageset/ic_speaker_bluetooth_inactive_video_mode.png new file mode 100644 index 0000000000..ee27e62432 Binary files /dev/null and b/Signal/Images.xcassets/ic_speaker_bluetooth_inactive_video_mode.imageset/ic_speaker_bluetooth_inactive_video_mode.png differ diff --git a/Signal/src/UserInterface/Strings.swift b/Signal/src/UserInterface/Strings.swift index 358a9314c5..6c77c44111 100644 --- a/Signal/src/UserInterface/Strings.swift +++ b/Signal/src/UserInterface/Strings.swift @@ -7,6 +7,11 @@ import Foundation /** * Strings re-used in multiple places should be added here. */ + +@objc class CommonStrings: NSObject { + static let dismissButton = NSLocalizedString("DISMISS_BUTTON_TEXT", comment: "Short text to dismiss current modal / actionsheet / screen") +} + @objc class CallStrings: NSObject { static let callStatusFormat = NSLocalizedString("CALL_STATUS_FORMAT", comment: "embeds {{Call Status}} in call screen label. For ongoing calls, {{Call Status}} is a seconds timer like 01:23, otherwise {{Call Status}} is a short text like 'Ringing', 'Busy', or 'Failed Call'") diff --git a/Signal/src/ViewControllers/CallViewController.swift b/Signal/src/ViewControllers/CallViewController.swift index 456e3c4f8c..4e5ac5714f 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -41,7 +41,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R var ongoingCallView: UIView! var hangUpButton: UIButton! - var speakerPhoneButton: UIButton! + var audioSourceButton: UIButton! var audioModeMuteButton: UIButton! var audioModeVideoButton: UIButton! var videoModeMuteButton: UIButton! @@ -86,11 +86,45 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R var settingsNagView: UIView! var settingsNagDescriptionLabel: UILabel! + // MARK: Audio Source + + var hasAlternateAudioSources: Bool { + Logger.info("\(TAG) available audio routes count: \(allAudioSources.count)") + // internal mic and speakerphone will be the first two, any more than one indicates e.g. an attached bluetooth device. + + // TODO is this sufficient? Are their devices w/ bluetooth but no external speaker? e.g. ipod? + return allAudioSources.count > 2 + } + + var allAudioSources: Set + + var appropriateAudioSources: Set { + if call.hasLocalVideo { + let appropriateForVideo = allAudioSources.filter { audioSource in + if audioSource.isBuiltInSpeaker { + return true + } else { + guard let portDescription = audioSource.portDescription else { + owsFail("Only built in speaker should be lacking a port description.") + return false + } + + // Don't use receiver when video is enabled. Only bluetooth or speaker + return portDescription.portType != AVAudioSessionPortBuiltInMic + } + } + return Set(appropriateForVideo) + } else { + return allAudioSources + } + } + // MARK: Initializers required init?(coder aDecoder: NSCoder) { contactsManager = Environment.getCurrent().contactsManager callUIAdapter = Environment.getCurrent().callUIAdapter + allAudioSources = Set(callUIAdapter.audioService.availableInputs) super.init(coder: aDecoder) observeNotifications() } @@ -98,6 +132,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R required init() { contactsManager = Environment.getCurrent().contactsManager callUIAdapter = Environment.getCurrent().callUIAdapter + allAudioSources = Set(callUIAdapter.audioService.availableInputs) super.init(nibName: nil, bundle: nil) observeNotifications() } @@ -107,6 +142,11 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R selector:#selector(didBecomeActive), name:NSNotification.Name.UIApplicationDidBecomeActive, object:nil) + + NotificationCenter.default.addObserver(forName: CallAudioServiceSessionChanged, object: nil, queue: nil) { _ in + self.didChangeAudioSession() + } + } deinit { @@ -157,7 +197,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R // Subscribe for future call updates call.addObserverAndSyncState(observer: self) - Environment.getCurrent().callService.addObserverAndSyncState(observer:self) + Environment.getCurrent().callService.addObserverAndSyncState(observer: self) } // MARK: - Create Views @@ -288,8 +328,8 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R // textMessageButton = createButton(imageName:"message-active-wide", // action:#selector(didPressTextMessage)) - speakerPhoneButton = createButton(imageName:"audio-call-speaker-inactive", - action:#selector(didPressSpeakerphone)) + audioSourceButton = createButton(imageName:"audio-call-speaker-inactive", + action:#selector(didPressAudioSource)) hangUpButton = createButton(imageName:"hangup-active-wide", action:#selector(didPressHangup)) audioModeMuteButton = createButton(imageName:"audio-call-mute-inactive", @@ -305,12 +345,53 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R setButtonSelectedImage(button: videoModeMuteButton, imageName: "video-mute-selected") setButtonSelectedImage(button: audioModeVideoButton, imageName: "audio-call-video-active") setButtonSelectedImage(button: videoModeVideoButton, imageName: "video-video-selected") - setButtonSelectedImage(button: speakerPhoneButton, imageName: "audio-call-speaker-active") ongoingCallView = createContainerForCallControls(controlGroups : [ - [audioModeMuteButton, speakerPhoneButton, audioModeVideoButton ], + [audioModeMuteButton, audioSourceButton, audioModeVideoButton ], [videoModeMuteButton, hangUpButton, videoModeVideoButton ] - ]) + ]) + } + + func didChangeAudioSession() { + AssertIsOnMainThread() + + // Which sources are available depends on the state of your Session. + // When the audio session is not yet in PlayAndRecord none are available + // Then if we're in speakerphone, bluetooth isn't available. + // So we acrew all possible audio sources in a set, and that list lives as longs as the CallViewController + // The downside of this is that if you e.g. unpair your bluetooth mid call, it will still appear as an option + // until your next call. + // FIXME: There's got to be a better way, but this is where I landed after a bit of work, and seems to work + // pretty well in practrice. + let availableInputs = callUIAdapter.audioService.availableInputs + self.allAudioSources.formUnion(availableInputs) + } + + func presentAudioSourcePicker() { + AssertIsOnMainThread() + + let actionSheetController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + + let dismissAction = UIAlertAction(title: CommonStrings.dismissButton, style: .cancel, handler: nil) + actionSheetController.addAction(dismissAction) + + let currentAudioSource = callUIAdapter.audioService.currentAudioSource(call: self.call) + for audioSource in self.appropriateAudioSources { + let routeAudioAction = UIAlertAction(title: audioSource.localizedName, style: .default) { _ in + self.callUIAdapter.setAudioSource(call: self.call, audioSource: audioSource) + } + + // HACK: private API to create checkmark for active audio source. + routeAudioAction.setValue(currentAudioSource == audioSource, forKey: "checked") + + // TODO: pick some icons. Leaving out for MVP + // HACK: private API to add image to actionsheet + // routeAudioAction.setValue(audioSource.image, forKey: "image") + + actionSheetController.addAction(routeAudioAction) + } + + self.present(actionSheetController, animated: true) } func setButtonSelectedImage(button: UIButton, imageName: String) { @@ -653,7 +734,6 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R videoModeMuteButton.isSelected = call.isMuted audioModeVideoButton.isSelected = call.hasLocalVideo videoModeVideoButton.isSelected = call.hasLocalVideo - speakerPhoneButton.isSelected = call.isSpeakerphoneEnabled // Show Incoming vs. Ongoing call controls let isRinging = callState == .localRinging @@ -668,7 +748,8 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R // Rework control state if local video is available. let hasLocalVideo = !localVideoView.isHidden - for subview in [speakerPhoneButton, audioModeMuteButton, audioModeVideoButton] { + + for subview in [audioModeMuteButton, audioModeVideoButton] { subview?.isHidden = hasLocalVideo } for subview in [videoModeMuteButton, videoModeVideoButton] { @@ -685,6 +766,32 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R callStatusLabel.isHidden = false } + // Audio Source Handling (bluetooth) + if self.hasAlternateAudioSources { + // With bluetooth, button does not stay selected. Pressing it pops an actionsheet + // and the button should immediately "unselect". + audioSourceButton.isSelected = false + + if hasLocalVideo { + audioSourceButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_video_mode"), for: .normal) + audioSourceButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_video_mode"), for: .selected) + } else { + audioSourceButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_audio_mode"), for: .normal) + audioSourceButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_audio_mode"), for: .selected) + } + audioSourceButton.isHidden = false + } else { + // No bluetooth audio detected + + audioSourceButton.isSelected = call.isSpeakerphoneEnabled + audioSourceButton.setImage(#imageLiteral(resourceName: "audio-call-speaker-inactive"), for: .normal) + audioSourceButton.setImage(#imageLiteral(resourceName: "audio-call-speaker-active"), for: .selected) + + // If there's no bluetooth, we always use speakerphone, so no need for + // a button, giving more screen back for the video. + audioSourceButton.isHidden = hasLocalVideo + } + // Dismiss Handling switch callState { case .remoteHangup, .remoteBusy, .localFailure: @@ -742,17 +849,32 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R } } - func didPressSpeakerphone(sender speakerphoneButton: UIButton) { + func didPressAudioSource(sender button: UIButton) { Logger.info("\(TAG) called \(#function)") - speakerphoneButton.isSelected = !speakerphoneButton.isSelected + + if self.hasAlternateAudioSources { + presentAudioSourcePicker() + } else { + didPressSpeakerphone(sender: button) + } + } + + func didPressSpeakerphone(sender button: UIButton) { + Logger.info("\(TAG) called \(#function)") + button.isSelected = !button.isSelected if let call = self.call { - callUIAdapter.setIsSpeakerphoneEnabled(call: call, isEnabled: speakerphoneButton.isSelected) + if button.isSelected { + callUIAdapter.setAudioSource(call: call, audioSource: AudioSource.builtInSpeaker) + } else { + // use default audio source + callUIAdapter.setAudioSource(call: call, audioSource: nil) + } } else { Logger.warn("\(TAG) pressed mute, but call was unexpectedly nil") } } - func didPressTextMessage(sender speakerphoneButton: UIButton) { + func didPressTextMessage(sender button: UIButton) { Logger.info("\(TAG) called \(#function)") dismissIfPossible(shouldDelay:false) @@ -860,7 +982,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R self.updateCallUI(callState: call.state) } - internal func speakerphoneDidChange(call: SignalCall, isEnabled: Bool) { + internal func audioSourceDidChange(call: SignalCall, audioSource: AudioSource?) { AssertIsOnMainThread() self.updateCallUI(callState: call.state) } diff --git a/Signal/src/ViewControllers/CodeVerificationViewController.m b/Signal/src/ViewControllers/CodeVerificationViewController.m index ab919977d7..4036ee98d8 100644 --- a/Signal/src/ViewControllers/CodeVerificationViewController.m +++ b/Signal/src/ViewControllers/CodeVerificationViewController.m @@ -301,7 +301,7 @@ NS_ASSUME_NONNULL_BEGIN message:error.localizedDescription preferredStyle:UIAlertControllerStyleAlert]; } - UIAlertAction *dismissAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"DISMISS_BUTTON_TEXT", nil) + UIAlertAction *dismissAction = [UIAlertAction actionWithTitle:CommonStrings.dismissButton style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [_challengeTextField becomeFirstResponder]; diff --git a/Signal/src/ViewControllers/ConversationView/MessagesViewController.m b/Signal/src/ViewControllers/ConversationView/MessagesViewController.m index 84e9da8ace..73a046098c 100644 --- a/Signal/src/ViewControllers/ConversationView/MessagesViewController.m +++ b/Signal/src/ViewControllers/ConversationView/MessagesViewController.m @@ -872,13 +872,11 @@ typedef enum : NSUInteger { }]; [actionSheetController addAction:verifyAction]; - UIAlertAction *dismissAction = - [UIAlertAction actionWithTitle:NSLocalizedString(@"DISMISS_BUTTON_TEXT", - @"Generic short text for button to dismiss a dialog") - style:UIAlertActionStyleCancel - handler:^(UIAlertAction *_Nonnull action) { - [weakSelf resetVerificationStateToDefault]; - }]; + UIAlertAction *dismissAction = [UIAlertAction actionWithTitle:CommonStrings.dismissButton + style:UIAlertActionStyleCancel + handler:^(UIAlertAction *_Nonnull action) { + [weakSelf resetVerificationStateToDefault]; + }]; [actionSheetController addAction:dismissAction]; [self presentViewController:actionSheetController animated:YES completion:nil]; @@ -2950,9 +2948,8 @@ typedef enum : NSUInteger { @"Alert body when picking a document fails because user picked a directory/bundle") preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *dismissAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"DISMISS_BUTTON_TEXT", nil) - style:UIAlertActionStyleCancel - handler:nil]; + UIAlertAction *dismissAction = + [UIAlertAction actionWithTitle:CommonStrings.dismissButton style:UIAlertActionStyleCancel handler:nil]; [alertController addAction:dismissAction]; dispatch_async(dispatch_get_main_queue(), ^{ @@ -2978,9 +2975,8 @@ typedef enum : NSUInteger { message:nil preferredStyle:UIAlertControllerStyleAlert]; - UIAlertAction *dismissAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"DISMISS_BUTTON_TEXT", nil) - style:UIAlertActionStyleCancel - handler:nil]; + UIAlertAction *dismissAction = + [UIAlertAction actionWithTitle:CommonStrings.dismissButton style:UIAlertActionStyleCancel handler:nil]; [alertController addAction:dismissAction]; dispatch_async(dispatch_get_main_queue(), ^{ diff --git a/Signal/src/ViewControllers/ExperienceUpgradesPageViewController.swift b/Signal/src/ViewControllers/ExperienceUpgradesPageViewController.swift index f960be65b2..a2e41dbae9 100644 --- a/Signal/src/ViewControllers/ExperienceUpgradesPageViewController.swift +++ b/Signal/src/ViewControllers/ExperienceUpgradesPageViewController.swift @@ -194,7 +194,7 @@ class ExperienceUpgradesPageViewController: UIViewController, UIPageViewControll // Dismiss button let dismissButton = UIButton() view.addSubview(dismissButton) - dismissButton.setTitle(NSLocalizedString("DISMISS_BUTTON_TEXT", comment: ""), for: .normal) + dismissButton.setTitle(CommonStrings.dismissButton, for: .normal) dismissButton.setTitleColor(UIColor.white, for: .normal) dismissButton.isUserInteractionEnabled = true dismissButton.addTarget(self, action:#selector(didTapDismissButton), for: .touchUpInside) diff --git a/Signal/src/ViewControllers/FingerprintViewScanController.m b/Signal/src/ViewControllers/FingerprintViewScanController.m index c49b42c2c0..b8561db6bd 100644 --- a/Signal/src/ViewControllers/FingerprintViewScanController.m +++ b/Signal/src/ViewControllers/FingerprintViewScanController.m @@ -189,7 +189,6 @@ NS_ASSUME_NONNULL_BEGIN DDLogInfo(@"%@ Successfully verified safety numbers.", tag); NSString *successTitle = NSLocalizedString(@"SUCCESSFUL_VERIFICATION_TITLE", nil); - NSString *dismissText = NSLocalizedString(@"DISMISS_BUTTON_TEXT", nil); NSString *descriptionFormat = NSLocalizedString( @"SUCCESSFUL_VERIFICATION_DESCRIPTION", @"Alert body after verifying privacy with {{other user's name}}"); NSString *successDescription = [NSString stringWithFormat:descriptionFormat, contactName]; @@ -209,7 +208,7 @@ NS_ASSUME_NONNULL_BEGIN [viewController dismissViewControllerAnimated:true completion:nil]; }]]; UIAlertAction *dismissAction = - [UIAlertAction actionWithTitle:dismissText + [UIAlertAction actionWithTitle:CommonStrings.dismissButton style:UIAlertActionStyleDefault handler:^(UIAlertAction *action) { [viewController dismissViewControllerAnimated:true completion:nil]; diff --git a/Signal/src/ViewControllers/InviteFlow.swift b/Signal/src/ViewControllers/InviteFlow.swift index 3513af5747..50eb096034 100644 --- a/Signal/src/ViewControllers/InviteFlow.swift +++ b/Signal/src/ViewControllers/InviteFlow.swift @@ -1,5 +1,6 @@ -// Created by Michael Kirk on 11/18/16. -// Copyright © 2016 Open Whisper Systems. All rights reserved. +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// import Foundation import Social @@ -31,7 +32,7 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos super.init() actionSheetController.addAction(dismissAction()) - + if #available(iOS 9.0, *) { if let messageAction = messageAction() { actionSheetController.addAction(messageAction) @@ -72,7 +73,7 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos twitterViewController.add(#imageLiteral(resourceName: "twitter_sharing_image")) let tweetTitle = NSLocalizedString("SHARE_ACTION_TWEET", comment:"action sheet item") - return UIAlertAction(title: tweetTitle, style: .default) { action in + return UIAlertAction(title: tweetTitle, style: .default) { _ in Logger.debug("\(self.TAG) Chose tweet") self.presentingViewController.present(twitterViewController, animated: true, completion: nil) @@ -80,7 +81,7 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos } func dismissAction() -> UIAlertAction { - return UIAlertAction(title: NSLocalizedString("DISMISS_BUTTON_TEXT", comment:""), style: .cancel) + return UIAlertAction(title: CommonStrings.dismissButton, style: .cancel) } // MARK: ContactsPickerDelegate @@ -134,10 +135,10 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos } let messageTitle = NSLocalizedString("SHARE_ACTION_MESSAGE", comment: "action sheet item to open native messages app") - return UIAlertAction(title: messageTitle, style: .default) { action in + return UIAlertAction(title: messageTitle, style: .default) { _ in Logger.debug("\(self.TAG) Chose message.") self.channel = .message - let picker = ContactsPicker(delegate: self, multiSelection: true, subtitleCellType: .phoneNumber) + let picker = ContactsPicker(delegate: self, multiSelection: true, subtitleCellType: .phoneNumber) let navigationController = UINavigationController(rootViewController: picker) self.presentingViewController.present(navigationController, animated: true) } @@ -173,7 +174,7 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos switch result { case .failed: let warning = UIAlertController(title: nil, message: NSLocalizedString("SEND_INVITE_FAILURE", comment:"Alert body after invite failed"), preferredStyle: .alert) - warning.addAction(UIAlertAction(title: NSLocalizedString("DISMISS_BUTTON_TEXT", comment:""), style: .default, handler: nil)) + warning.addAction(UIAlertAction(title: CommonStrings.dismissButton, style: .default, handler: nil)) self.presentingViewController.present(warning, animated: true, completion: nil) case .sent: Logger.debug("\(self.TAG) user successfully invited their friends via SMS.") @@ -192,7 +193,7 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos } let mailActionTitle = NSLocalizedString("SHARE_ACTION_MAIL", comment: "action sheet item to open native mail app") - return UIAlertAction(title: mailActionTitle, style: .default) { action in + return UIAlertAction(title: mailActionTitle, style: .default) { _ in Logger.debug("\(self.TAG) Chose mail.") self.channel = .mail @@ -216,8 +217,8 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos mailComposeViewController.setMessageBody(body, isHTML: false) self.presentingViewController.dismiss(animated: true) { - self.presentingViewController.navigationController?.present(mailComposeViewController, animated:true) { - UIUtil.applySignalAppearence(); + self.presentingViewController.navigationController?.present(mailComposeViewController, animated:true) { + UIUtil.applySignalAppearence() } } } @@ -230,7 +231,7 @@ class InviteFlow: NSObject, MFMessageComposeViewControllerDelegate, MFMailCompos switch result { case .failed: let warning = UIAlertController(title: nil, message: NSLocalizedString("SEND_INVITE_FAILURE", comment:"Alert body after invite failed"), preferredStyle: .alert) - warning.addAction(UIAlertAction(title: NSLocalizedString("DISMISS_BUTTON_TEXT", comment:""), style: .default, handler: nil)) + warning.addAction(UIAlertAction(title: CommonStrings.dismissButton, style: .default, handler: nil)) self.presentingViewController.present(warning, animated: true, completion: nil) case .sent: Logger.debug("\(self.TAG) user successfully invited their friends via mail.") diff --git a/Signal/src/ViewControllers/OWSLinkedDevicesTableViewController.m b/Signal/src/ViewControllers/OWSLinkedDevicesTableViewController.m index e08cbf6d43..32ad55e426 100644 --- a/Signal/src/ViewControllers/OWSLinkedDevicesTableViewController.m +++ b/Signal/src/ViewControllers/OWSLinkedDevicesTableViewController.m @@ -5,6 +5,7 @@ #import "OWSLinkedDevicesTableViewController.h" #import "OWSDeviceTableViewCell.h" #import "OWSLinkDeviceViewController.h" +#import "Signal-Swift.h" #import "UIViewController+CameraPermissions.h" #import #import @@ -166,10 +167,9 @@ int const OWSLinkedDevicesTableViewControllerSectionAddDevice = 1; }]; [alertController addAction:retryAction]; - NSString *dismissTitle - = NSLocalizedString(@"DISMISS_BUTTON_TEXT", @"Generic short text for button to dismiss a dialog"); - UIAlertAction *dismissAction = - [UIAlertAction actionWithTitle:dismissTitle style:UIAlertActionStyleCancel handler:nil]; + UIAlertAction *dismissAction = [UIAlertAction actionWithTitle:CommonStrings.dismissButton + style:UIAlertActionStyleCancel + handler:nil]; [alertController addAction:dismissAction]; dispatch_async(dispatch_get_main_queue(), ^{ diff --git a/Signal/src/ViewControllers/RegistrationViewController.m b/Signal/src/ViewControllers/RegistrationViewController.m index 989ed20046..6feaba7cfe 100644 --- a/Signal/src/ViewControllers/RegistrationViewController.m +++ b/Signal/src/ViewControllers/RegistrationViewController.m @@ -379,8 +379,7 @@ NSString *const kKeychainKey_LastRegisteredPhoneNumber = @"kKeychainKey_LastRegi - (void)presentInvalidCountryCodeError { [OWSAlerts showAlertWithTitle:NSLocalizedString(@"REGISTER_CC_ERR_ALERT_VIEW_TITLE", @"") message:NSLocalizedString(@"REGISTER_CC_ERR_ALERT_VIEW_MESSAGE", @"") - buttonTitle:NSLocalizedString( - @"DISMISS_BUTTON_TEXT", @"Generic short text for button to dismiss a dialog")]; + buttonTitle:CommonStrings.dismissButton]; } #pragma mark - CountryCodeViewControllerDelegate diff --git a/Signal/src/call/CallAudioService.swift b/Signal/src/call/CallAudioService.swift index 4ee9ba511d..59b6238ea3 100644 --- a/Signal/src/call/CallAudioService.swift +++ b/Signal/src/call/CallAudioService.swift @@ -5,6 +5,71 @@ import Foundation import AVFoundation +public let CallAudioServiceSessionChanged = Notification.Name("CallAudioServiceSessionChanged") + +struct AudioSource: Hashable { + + let image: UIImage + let localizedName: String + let portDescription: AVAudioSessionPortDescription? + let isBuiltInSpeaker: Bool + + init(localizedName: String, image: UIImage, isBuiltInSpeaker: Bool, portDescription: AVAudioSessionPortDescription? = nil) { + self.localizedName = localizedName + self.image = image + self.isBuiltInSpeaker = isBuiltInSpeaker + self.portDescription = portDescription + } + + init(portDescription: AVAudioSessionPortDescription) { + self.init(localizedName: portDescription.portName, + image:#imageLiteral(resourceName: "button_phone_white"), // TODO + isBuiltInSpeaker: false, + portDescription: portDescription) + } + + // Speakerphone is handled separately from the other audio routes as it doesn't appear as an "input" + static var builtInSpeaker: AudioSource { + return self.init(localizedName: NSLocalizedString("AUDIO_ROUTE_BUILT_IN_SPEAKER", comment: "action sheet button title to enable built in speaker during a call"), + image: #imageLiteral(resourceName: "button_phone_white"), //TODO + isBuiltInSpeaker: true) + } + + // MARK: Hashable + + static func ==(lhs: AudioSource, rhs: AudioSource) -> Bool { + // Simply comparing the `portDescription` vs the `portDescription.uid` + // caused multiple instances of the built in mic to turn up in a set. + if lhs.isBuiltInSpeaker && rhs.isBuiltInSpeaker { + return true + } + + if lhs.isBuiltInSpeaker || rhs.isBuiltInSpeaker { + return false + } + + guard let lhsPortDescription = lhs.portDescription else { + owsFail("only the built in speaker should lack a port description") + return false + } + + guard let rhsPortDescription = rhs.portDescription else { + owsFail("only the built in speaker should lack a port description") + return false + } + + return lhsPortDescription.uid == rhsPortDescription.uid + } + + var hashValue: Int { + guard let portDescription = self.portDescription else { + assert(self.isBuiltInSpeaker) + return "Built In Speaker".hashValue + } + return portDescription.uid.hash + } +} + @objc class CallAudioService: NSObject, CallObserver { private let TAG = "[CallAudioService]" @@ -74,10 +139,19 @@ import AVFoundation Logger.verbose("\(TAG) in \(#function) is no-op") } - internal func speakerphoneDidChange(call: SignalCall, isEnabled: Bool) { + internal func audioSourceDidChange(call: SignalCall, audioSource: AudioSource?) { AssertIsOnMainThread() ensureProperAudioSession(call: call) + + // It's importent to set preferred input *after* ensuring properAudioSession + // because some sources are only valid for certain categories. + let session = AVAudioSession.sharedInstance() + do { + try session.setPreferredInput(audioSource?.portDescription) + } catch { + owsFail("\(TAG) setPreferredInput in \(#function) failed with error: \(error)") + } } internal func hasLocalVideoDidChange(call: SignalCall, hasLocalVideo: Bool) { @@ -98,14 +172,17 @@ import AVFoundation setAudioSession(category: AVAudioSessionCategorySoloAmbient, mode: AVAudioSessionModeDefault) } else if call.hasLocalVideo { - // Auto-enable speakerphone when local video is enabled. + // Don't allow bluetooth for local video if speakerphone has been explicitly chosen by the user. + let options: AVAudioSessionCategoryOptions = call.isSpeakerphoneEnabled ? [.defaultToSpeaker] : [.defaultToSpeaker, .allowBluetooth] + setAudioSession(category: AVAudioSessionCategoryPlayAndRecord, mode: AVAudioSessionModeVideoChat, - options: [.defaultToSpeaker, .allowBluetooth]) + options: options) } else if call.isSpeakerphoneEnabled { + // Ensure no bluetooth if user has specified speakerphone setAudioSession(category: AVAudioSessionCategoryPlayAndRecord, mode: AVAudioSessionModeVoiceChat, - options: [.defaultToSpeaker, .allowBluetooth]) + options: [.defaultToSpeaker]) } else { setAudioSession(category: AVAudioSessionCategoryPlayAndRecord, mode: AVAudioSessionModeVoiceChat, @@ -308,11 +385,61 @@ import AVFoundation AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } + // MARK - AudioSession MGMT + // TODO move this to CallAudioSession? + + // Note this method is sensitive to the current audio session configuration. + // Specifically if you call it while speakerphone is enabled you won't see + // any connected bluetooth routes. + var availableInputs: [AudioSource] { + let session = AVAudioSession.sharedInstance() + + guard let availableInputs = session.availableInputs else { + // I'm not sure when this would happen. + owsFail("No available inputs or inputs not ready") + return [AudioSource.builtInSpeaker] + } + + Logger.info("\(TAG) in \(#function) availableInputs: \(availableInputs)") + return [AudioSource.builtInSpeaker] + availableInputs.map { portDescription in + return AudioSource(portDescription: portDescription) + } + } + + func currentAudioSource(call: SignalCall) -> AudioSource? { + if let audioSource = call.audioSource { + return audioSource + } + + // Before the user has specified an audio source on the call, we rely on the existing + // system state to determine the current audio source. + // If a bluetooth is connected, this will be bluetooth, otherwise + // this will be the receiver. + let session = AVAudioSession.sharedInstance() + guard let portDescription = session.currentRoute.inputs.first else { + return nil + } + + return AudioSource(portDescription: portDescription) + } + + public func setPreferredInput(call: SignalCall, audioSource: AudioSource?) { + let session = AVAudioSession.sharedInstance() + do { + Logger.debug("\(TAG) in \(#function) audioSource: \(String(describing: audioSource))") + try session.setPreferredInput(audioSource?.portDescription) + } catch { + owsFail("\(TAG) failed with error: \(error)") + } + self.ensureProperAudioSession(call: call) + } + private func setAudioSession(category: String, mode: String? = nil, options: AVAudioSessionCategoryOptions = AVAudioSessionCategoryOptions(rawValue: 0)) { let session = AVAudioSession.sharedInstance() + var audioSessionChanged = false do { if #available(iOS 10.0, *), let mode = mode { let oldCategory = session.category @@ -323,6 +450,8 @@ import AVFoundation return } + audioSessionChanged = true + if oldCategory != category { Logger.debug("\(self.TAG) audio session changed category: \(oldCategory) -> \(category) ") } @@ -342,6 +471,8 @@ import AVFoundation return } + audioSessionChanged = true + if oldCategory != category { Logger.debug("\(self.TAG) audio session changed category: \(oldCategory) -> \(category) ") } @@ -355,5 +486,10 @@ import AVFoundation let message = "\(self.TAG) in \(#function) failed to set category: \(category) mode: \(String(describing: mode)), options: \(options) with error: \(error)" owsFail(message) } + + if audioSessionChanged { + Logger.info("\(TAG) in \(#function)") + NotificationCenter.default.post(name: CallAudioServiceSessionChanged, object: nil) + } } } diff --git a/Signal/src/call/CallService.swift b/Signal/src/call/CallService.swift index 5dfc2b5725..86dbc6c0ec 100644 --- a/Signal/src/call/CallService.swift +++ b/Signal/src/call/CallService.swift @@ -1442,7 +1442,7 @@ protocol CallServiceObserver: class { // Do nothing } - internal func speakerphoneDidChange(call: SignalCall, isEnabled: Bool) { + internal func audioSourceDidChange(call: SignalCall, audioSource: AudioSource?) { AssertIsOnMainThread() // Do nothing } diff --git a/Signal/src/call/SignalCall.swift b/Signal/src/call/SignalCall.swift index 82a7cd44c4..908291d128 100644 --- a/Signal/src/call/SignalCall.swift +++ b/Signal/src/call/SignalCall.swift @@ -26,7 +26,7 @@ protocol CallObserver: class { func stateDidChange(call: SignalCall, state: CallState) func hasLocalVideoDidChange(call: SignalCall, hasLocalVideo: Bool) func muteDidChange(call: SignalCall, isMuted: Bool) - func speakerphoneDidChange(call: SignalCall, isEnabled: Bool) + func audioSourceDidChange(call: SignalCall, audioSource: AudioSource?) } /** @@ -104,18 +104,25 @@ protocol CallObserver: class { } } - var isSpeakerphoneEnabled = false { + var audioSource: AudioSource? = nil { didSet { AssertIsOnMainThread() - - Logger.debug("\(TAG) isSpeakerphoneEnabled changed: \(oldValue) -> \(self.isSpeakerphoneEnabled)") + Logger.debug("\(TAG) audioSource changed: \(String(describing:oldValue)) -> \(String(describing: audioSource))") for observer in observers { - observer.value?.speakerphoneDidChange(call: self, isEnabled: isSpeakerphoneEnabled) + observer.value?.audioSourceDidChange(call: self, audioSource: audioSource) } } } + var isSpeakerphoneEnabled: Bool { + guard let audioSource = self.audioSource else { + return false + } + + return audioSource.isBuiltInSpeaker + } + var isOnHold = false var connectedDate: NSDate? diff --git a/Signal/src/call/UserInterface/CallUIAdapter.swift b/Signal/src/call/UserInterface/CallUIAdapter.swift index 445d3e5a6d..e47c287a0b 100644 --- a/Signal/src/call/UserInterface/CallUIAdapter.swift +++ b/Signal/src/call/UserInterface/CallUIAdapter.swift @@ -80,7 +80,7 @@ extension CallUIAdaptee { let TAG = "[CallUIAdapter]" private let adaptee: CallUIAdaptee private let contactsManager: OWSContactsManager - private let audioService: CallAudioService + internal let audioService: CallAudioService required init(callService: CallService, contactsManager: OWSContactsManager, notificationsAdapter: CallNotificationsAdapter) { AssertIsOnMainThread() @@ -207,13 +207,13 @@ extension CallUIAdaptee { adaptee.setHasLocalVideo(call: call, hasLocalVideo: hasLocalVideo) } - internal func setIsSpeakerphoneEnabled(call: SignalCall, isEnabled: Bool) { + internal func setAudioSource(call: SignalCall, audioSource: AudioSource?) { AssertIsOnMainThread() - // Speakerphone is not handled by CallKit (e.g. there is no CXAction), so we handle it w/o going through the - // adaptee, relying on the AudioService CallObserver to put the system in a state consistent with the call's + // AudioSource is not handled by CallKit (e.g. there is no CXAction), so we handle it w/o going through the + // adaptee, relying on the AudioService CallObserver to put the system in a state consistent with the call's // assigned property. - call.isSpeakerphoneEnabled = isEnabled + call.audioSource = audioSource } // CallKit handles ringing state on it's own. But for non-call kit we trigger ringing start/stop manually. diff --git a/Signal/src/util/UIViewController+CameraPermissions.m b/Signal/src/util/UIViewController+CameraPermissions.m index 58abd96852..18a63f33bb 100644 --- a/Signal/src/util/UIViewController+CameraPermissions.m +++ b/Signal/src/util/UIViewController+CameraPermissions.m @@ -44,7 +44,7 @@ NS_ASSUME_NONNULL_BEGIN }]; [alert addAction:openSettingsAction]; - UIAlertAction *dismissAction = [UIAlertAction actionWithTitle:NSLocalizedString(@"DISMISS_BUTTON_TEXT", nil) + UIAlertAction *dismissAction = [UIAlertAction actionWithTitle:CommonStrings.dismissButton style:UIAlertActionStyleCancel handler:^(UIAlertAction *action) { failureCallback(); diff --git a/Signal/src/views/OWSAlerts.swift b/Signal/src/views/OWSAlerts.swift index ce3644a493..308c6ea095 100644 --- a/Signal/src/views/OWSAlerts.swift +++ b/Signal/src/views/OWSAlerts.swift @@ -12,8 +12,7 @@ import Foundation let alertTitle = NSLocalizedString("CALL_AUDIO_PERMISSION_TITLE", comment:"Alert title when calling and permissions for microphone are missing") let alertMessage = NSLocalizedString("CALL_AUDIO_PERMISSION_MESSAGE", comment:"Alert message when calling and permissions for microphone are missing") let alertController = UIAlertController(title: alertTitle, message: alertMessage, preferredStyle: .alert) - let dismiss = NSLocalizedString("DISMISS_BUTTON_TEXT", comment: "Generic short text for button to dismiss a dialog") - let dismissAction = UIAlertAction(title: dismiss, style: .cancel) + let dismissAction = UIAlertAction(title: CommonStrings.dismissButton, style: .cancel) let settingsString = NSLocalizedString("OPEN_SETTINGS_BUTTON", comment: "Button text which opens the settings app") let settingsAction = UIAlertAction(title: settingsString, style: .default) { _ in UIApplication.shared.openSystemSettings() diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 3515065219..49be66d5d1 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -139,6 +139,9 @@ /* Short text label for a voice message attachment, used for thread preview and on lockscreen */ "ATTACHMENT_TYPE_VOICE_MESSAGE" = "Voice Message"; +/* action sheet button title to enable built in speaker during a call */ +"AUDIO_ROUTE_BUILT_IN_SPEAKER" = "Built in Speaker"; + /* An explanation of the consequences of blocking another user. */ "BLOCK_BEHAVIOR_EXPLANATION" = "Blocked users will not be able to call you or send you messages."; @@ -376,7 +379,7 @@ /* Accessibility label for disappearing messages */ "DISAPPEARING_MESSAGES_LABEL" = "Disappearing messages settings"; -/* Generic short text for button to dismiss a dialog */ +/* Short text to dismiss current modal / actionsheet / screen */ "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Section title for the 'domain fronting country' view. */