WIP: bluetooth shows audio route button instead of speakerphone

// FREEBIE

TODO

NEED
-[ ] icon in route picker
-[ ] commit cleanup

NICE
-[ ] present action sheet automatically when making outgoing bluetooth call
-[ ] left align icons
-[ ] audio is paused when switching between video mode (maybe existing behavior, not sure)
-[ ] Copy: iPhone/iPad/iPod instead of "iPhone Microphone"

DONE
-[x] remove "receiver" from options while in video mode
-[x] show available audio routes
-[x] select available audio routes
-[x] notification if availabe inputs change so we can update call screen
     mid call with available BT route
-[x] include speakerphone in choices
-[x] Enabled button shows active speakerphone. Should still show
     bluetooth picker.
-[x] toggle back and forth between audio devices
-[x] hide audio route button in video mode if no BT available
-[x] Fixed: When on speakerphone - switching to video mode goes back to bluetooth.
-[x] Fixed: When switching to video w/ bluetooth device connected there is no
     audio picker.
-[x] respect speakerphone/BT selection when in or toggling to/from video
-[x] do not hide audio route button when in video mode and bluetooth
     connected
-[x] Show which is currently selected audio route
-[x] switching to speakerphone no longer works
-[x] switching *back* to bluetooth no longer works
-[x] add proper bluetooth button for audio calls
-[x] add proper bluetooth button for video calls
This commit is contained in:
Michael Kirk 2017-07-12 15:51:07 -04:00
parent 109cb6cdb6
commit 9bd68ed490
9 changed files with 399 additions and 14 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

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

View File

@ -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<AudioSource>
var availableAudioRoutes: Set<AudioSource> {
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

View File

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

View File

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

View File

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