Merge branch 'mkirk/bt-audio-2'

pull/1/head
Michael Kirk 8 years ago
commit 77fb1dbf45

@ -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"
}
}

@ -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"
}
}

@ -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'")

@ -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<AudioSource>
var appropriateAudioSources: Set<AudioSource> {
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)")
if self.hasAlternateAudioSources {
presentAudioSourcePicker()
} else {
didPressSpeakerphone(sender: button)
}
}
func didPressSpeakerphone(sender button: UIButton) {
Logger.info("\(TAG) called \(#function)")
speakerphoneButton.isSelected = !speakerphoneButton.isSelected
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)
}

@ -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];

@ -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(), ^{

@ -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)

@ -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];

@ -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.")

@ -5,6 +5,7 @@
#import "OWSLinkedDevicesTableViewController.h"
#import "OWSDeviceTableViewCell.h"
#import "OWSLinkDeviceViewController.h"
#import "Signal-Swift.h"
#import "UIViewController+CameraPermissions.h"
#import <SignalServiceKit/NSTimer+OWS.h>
#import <SignalServiceKit/OWSDevice.h>
@ -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(), ^{

@ -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

@ -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)
}
}
}

@ -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
}

@ -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?

@ -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.

@ -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();

@ -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()

@ -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. */

Loading…
Cancel
Save