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 000000000..9a7786cf2 --- /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 000000000..657ad527c 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 000000000..cc8674b06 --- /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 000000000..5cb7ff12c 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 358a9314c..2dfe5c59a 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 dismissActionText = 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 991160711..01d48c9e6 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -41,7 +41,8 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R var ongoingCallView: UIView! var hangUpButton: UIButton! - var speakerPhoneButton: UIButton! + var audioRouteButton: UIButton! + var soundRouteButton: UIButton! var audioModeMuteButton: UIButton! var audioModeVideoButton: UIButton! var videoModeMuteButton: UIButton! @@ -86,11 +87,70 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R var settingsNagView: UIView! var settingsNagDescriptionLabel: UILabel! + // MARK: Audio Routing + +// var hasAlternateAudioRoutes = false { +// didSet { +// if oldValue != hasAlternateAudioRoutes { +// updateCallUI(callState: call.state) +// } +// } +// } + // TODO use "audioSource" terminalogy rather than input/output/route + var hasAlternateAudioRoutes: Bool { + Logger.info("\(TAG) available audio routes count: \(allAvailableAudioRoutes.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 allAvailableAudioRoutes.count > 2 + } + + var allAvailableAudioRoutes: Set + + var availableAudioRoutes: Set { + if call.hasLocalVideo { + let forVideo = allAvailableAudioRoutes.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 + } + return portDescription.portType != AVAudioSessionPortBuiltInMic + } + } + return Set(forVideo) + } else { + return allAvailableAudioRoutes + } + } + + var audioSource: AudioSource? { + didSet { + if audioSource != oldValue { + if let audioSource = audioSource { + if audioSource.isBuiltInSpeaker { + // TODO seems like CVC knows too much about AudioSource. + // Maybe these conditionals belong in the callUIAdapter? Or audioService? + // self.callUIAdapter.audioService.setPreferredInput(audioSource: audioSource) + + self.callUIAdapter.setIsSpeakerphoneEnabled(call: self.call, isEnabled: true) + return + } + } + + self.callUIAdapter.setIsSpeakerphoneEnabled(call: self.call, isEnabled: false) + self.callUIAdapter.audioService.setPreferredInput(call: self.call, audioSource: audioSource) + } + } + } + // MARK: Initializers required init?(coder aDecoder: NSCoder) { contactsManager = Environment.getCurrent().contactsManager callUIAdapter = Environment.getCurrent().callUIAdapter + allAvailableAudioRoutes = Set(callUIAdapter.audioService.availableInputs) super.init(coder: aDecoder) observeNotifications() } @@ -98,6 +158,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R required init() { contactsManager = Environment.getCurrent().contactsManager callUIAdapter = Environment.getCurrent().callUIAdapter + allAvailableAudioRoutes = Set(callUIAdapter.audioService.availableInputs) super.init(nibName: nil, bundle: nil) observeNotifications() } @@ -107,6 +168,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 +223,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 +354,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)) + audioRouteButton = createButton(imageName:"audio-call-speaker-inactive", + action:#selector(didPressAudioRoute)) hangUpButton = createButton(imageName:"hangup-active-wide", action:#selector(didPressHangup)) audioModeMuteButton = createButton(imageName:"audio-call-mute-inactive", @@ -305,12 +371,67 @@ 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") +// setButtonSelectedImage(button: audioRouteButton, imageName: "audio-call-speaker-active") ongoingCallView = createContainerForCallControls(controlGroups : [ - [audioModeMuteButton, speakerPhoneButton, audioModeVideoButton ], + [audioModeMuteButton, audioRouteButton, audioModeVideoButton ], [videoModeMuteButton, hangUpButton, videoModeVideoButton ] - ]) + ]) + } + + func didChangeAudioSession() { + AssertIsOnMainThread() + // TODO unnecessary? + let availableInputs = callUIAdapter.audioService.availableInputs + self.allAvailableAudioRoutes.formUnion(availableInputs) + } + + func presentAudioRoutePicker() { + Logger.info("\(TAG) in \(#function)") + AssertIsOnMainThread() + + let actionSheetController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) + + let dismissAction = UIAlertAction(title: CommonStrings.dismissActionText, style: .cancel, handler: nil) + actionSheetController.addAction(dismissAction) + + let currentAudioSource = callUIAdapter.audioService.currentAudioSource(call: self.call) + for audioSource in self.availableAudioRoutes { + // TODO add image + let routeAudioAction = UIAlertAction(title: audioSource.localizedName, style: .default) { _ in + // Disable any speakerphone + // TODO will this update the UI appropriately? + self.audioSource = audioSource + } + + // HACK private API to create checkmark for active audio source. + routeAudioAction.setValue(currentAudioSource == audioSource, forKey: "checked") + + // HACK private API to add image to actionsheet + routeAudioAction.setValue(audioSource.image, forKey: "image") + + actionSheetController.addAction(routeAudioAction) + } + +// if let builtInMicrophoneSource = self.callUIAdapter.audioService.builtInMicrophoneSource { + // Speakerphone is handled separately from the other audio routes as it doesn't appear as an "input" +// let speakerphoneAction = UIAlertAction(title: +// style: .default) { _ in +// self.updateAudioOutput(audioSource: builtInMicrophoneSource) +// +// } +// actionSheetController.addAction(speakerphoneAction) +// } else { +// owsFail("unable to find built in microphone source") +// } + + self.present(actionSheetController, animated: true) + } + + func updateAudioOutput(audioSource: AudioSource) { + Logger.info("\(TAG) in \(#function) with audioSource: \(audioSource)") + // This seems like overreach. audioservice as property on CVC? + } func setButtonSelectedImage(button: UIButton, imageName: String) { @@ -653,7 +774,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 +788,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 +806,35 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R callStatusLabel.isHidden = false } + // Handle audio source picking interface (blue tooth) + if self.hasAlternateAudioRoutes { + // TODO proper image + Logger.info("\(TAG) in \(#function) setting alternate audio route image") + + // With bluetooth, button does not stay selected. Pressing it pops an actionsheet + // and the button should immediately "unselect". + audioRouteButton.isSelected = false + + if hasLocalVideo { + audioRouteButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_video_mode"), for: .normal) + audioRouteButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_video_mode"), for: .selected) + } else { + audioRouteButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_audio_mode"), for: .normal) + audioRouteButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_audio_mode"), for: .selected) + } + audioRouteButton.isHidden = false + } else { + // No bluetooth audio detected + + audioRouteButton.isSelected = call.isSpeakerphoneEnabled + audioRouteButton.setImage(#imageLiteral(resourceName: "audio-call-speaker-inactive"), for: .normal) + audioRouteButton.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. + audioRouteButton.isHidden = hasLocalVideo + } + // Dismiss Handling switch callState { case .remoteHangup, .remoteBusy, .localFailure: @@ -742,6 +892,16 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R } } + func didPressAudioRoute(sender button: UIButton) { + Logger.info("\(TAG) called \(#function)") + + if self.hasAlternateAudioRoutes { + presentAudioRoutePicker() + } else { + didPressSpeakerphone(sender: button) + } + } + func didPressSpeakerphone(sender button: UIButton) { Logger.info("\(TAG) called \(#function)") button.isSelected = !button.isSelected diff --git a/Signal/src/call/CallAudioService.swift b/Signal/src/call/CallAudioService.swift index 4ee9ba511..b0ccc7d3c 100644 --- a/Signal/src/call/CallAudioService.swift +++ b/Signal/src/call/CallAudioService.swift @@ -5,6 +5,77 @@ import Foundation import AVFoundation +public let CallAudioServiceSessionChanged = Notification.Name("CallAudioServiceSessionChanged") + +struct AudioSource: Hashable { + +// let name: String + let image: UIImage + let localizedName: String + let portDescription: AVAudioSessionPortDescription? + let isBuiltInSpeaker: Bool + +// init(name: String, image: UIImage, isCurrentRoute: 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]" @@ -98,14 +169,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 +382,102 @@ import AVFoundation AudioServicesPlaySystemSound(kSystemSoundID_Vibrate) } + // MARK - AudioSession MGMT + // TODO move this to CallAudioSession? + + var hasAlternateAudioRoutes: Bool { +// let session = AVAudioSession.sharedInstance() + + // PROBLEM: doesn't list bluetooth when speakerphone is enabled. +// guard let availableInputs = session.availableInputs else { +// // I'm not sure when this would happen. +// owsFail("No available inputs or inputs not ready") +// return false +// } + + // +// let availableInputs = session.currentRoute.inputs + + Logger.info("\(TAG) in \(#function) availableInputs: \(availableInputs)") + for input in self.availableInputs { + if input.portDescription?.portType == AVAudioSessionPortBluetoothHFP { + return true + } + } + + return false + } + + // 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 availableOutputs = session.outputDataSources else { + + // Maybe... shows the bluetooth AND the receiver (but not speaker) + // PROBLEM: doesn't list bluetooth when speakerphone is enabled. + 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] + } + + // PROBLEM: doesn't list iphone internal + // PROBLEM: doesn't list bluetooth until toggling speakerphone on/off +// let availableInputs = session.currentRoute.inputs +// let availableInputs = session.currentRoute.outputs + + // NOPE. only shows the single active one. (e.g. blue tooth XOR receive) +// let availableOutputs = session.currentRoute.outputs + + Logger.info("\(TAG) in \(#function) availableInputs: \(availableInputs)") + return [AudioSource.builtInSpeaker] + availableInputs.map { portDescription in + // TODO get proper image + // TODO set isCurrentRoute correctly +// return AudioSource(name: output.dataSourceName, image:#imageLiteral(resourceName: "button_phone_white"), isCurrentRoute: false) +// return AudioSource(name: output.portName, image:#imageLiteral(resourceName: "button_phone_white"), isCurrentRoute: false) + + return AudioSource(portDescription: portDescription) + } + } + +// var builtInMicrophoneSource: AudioSource? { +// availableInputs.first { source -> Bool in +// if source.uid = +// } +// } + + func currentAudioSource(call: SignalCall) -> AudioSource? { + if call.isSpeakerphoneEnabled { + return AudioSource.builtInSpeaker + } else { + 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 +488,8 @@ import AVFoundation return } + audioSessionChanged = true + if oldCategory != category { Logger.debug("\(self.TAG) audio session changed category: \(oldCategory) -> \(category) ") } @@ -342,6 +509,8 @@ import AVFoundation return } + audioSessionChanged = true + if oldCategory != category { Logger.debug("\(self.TAG) audio session changed category: \(oldCategory) -> \(category) ") } @@ -355,5 +524,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/UserInterface/CallUIAdapter.swift b/Signal/src/call/UserInterface/CallUIAdapter.swift index 445d3e5a6..bf737b576 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() diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 351506521..10096b7f2 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,8 @@ /* Accessibility label for disappearing messages */ "DISAPPEARING_MESSAGES_LABEL" = "Disappearing messages settings"; -/* Generic short text for button to dismiss a dialog */ +/* 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. */