From 109cb6cdb6c2dd1509239dc19463c853138c63a8 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Wed, 12 Jul 2017 15:15:59 -0400 Subject: [PATCH 1/8] rename for clarity // FREEBIE --- Signal/src/ViewControllers/CallViewController.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Signal/src/ViewControllers/CallViewController.swift b/Signal/src/ViewControllers/CallViewController.swift index 456e3c4f8..991160711 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -742,17 +742,17 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R } } - func didPressSpeakerphone(sender speakerphoneButton: UIButton) { + 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) + callUIAdapter.setIsSpeakerphoneEnabled(call: call, isEnabled: button.isSelected) } 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) From 9bd68ed4904441eec234c695ae46cdcb0cacfca4 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Wed, 12 Jul 2017 15:51:07 -0400 Subject: [PATCH 2/8] 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 --- .../Contents.json | 21 ++ ..._speaker_bluetooth_inactive_audio_mode.png | Bin 0 -> 16273 bytes .../Contents.json | 21 ++ ..._speaker_bluetooth_inactive_video_mode.png | Bin 0 -> 6135 bytes Signal/src/UserInterface/Strings.swift | 5 + .../ViewControllers/CallViewController.swift | 178 ++++++++++++++++- Signal/src/call/CallAudioService.swift | 180 +++++++++++++++++- .../call/UserInterface/CallUIAdapter.swift | 2 +- .../translations/en.lproj/Localizable.strings | 6 +- 9 files changed, 399 insertions(+), 14 deletions(-) create mode 100644 Signal/Images.xcassets/ic_speaker_bluetooth_inactive_audio_mode.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_speaker_bluetooth_inactive_audio_mode.imageset/ic_speaker_bluetooth_inactive_audio_mode.png create mode 100644 Signal/Images.xcassets/ic_speaker_bluetooth_inactive_video_mode.imageset/Contents.json create mode 100644 Signal/Images.xcassets/ic_speaker_bluetooth_inactive_video_mode.imageset/ic_speaker_bluetooth_inactive_video_mode.png 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 0000000000000000000000000000000000000000..657ad527cdfa5bd46085e9c9f53ce7684cab44c8 GIT binary patch literal 16273 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%0AP?5lHfg94|2 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..5cb7ff12c2713cd8ed4d1dbf623fc625e4af0e3f GIT binary patch literal 6135 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 D3@Lv` literal 0 HcmV?d00001 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. */ From 20a8e7219881a159dadfd760069f1f9debf9918f Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 13 Jul 2017 14:38:32 -0400 Subject: [PATCH 3/8] disable audio source images until we have icons // FREEBIE --- Signal/src/ViewControllers/CallViewController.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/Signal/src/ViewControllers/CallViewController.swift b/Signal/src/ViewControllers/CallViewController.swift index 01d48c9e6..a8b123dde 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -404,11 +404,12 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R self.audioSource = audioSource } - // HACK private API to create checkmark for active audio source. + // 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") + // 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) } From a59eb25aef09d65f94f79f529d132630ef7b27d1 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 13 Jul 2017 14:53:24 -0400 Subject: [PATCH 4/8] extract dismiss string -> CommonStrings.dismissButton // FREEBIE --- Signal/src/UserInterface/Strings.swift | 2 +- .../ViewControllers/CallViewController.swift | 3 +-- .../CodeVerificationViewController.m | 2 +- .../ConversationView/MessagesViewController.m | 22 +++++++--------- ...ExperienceUpgradesPageViewController.swift | 2 +- .../FingerprintViewScanController.m | 3 +-- Signal/src/ViewControllers/InviteFlow.swift | 25 ++++++++++--------- .../OWSLinkedDevicesTableViewController.m | 8 +++--- .../RegistrationViewController.m | 3 +-- .../util/UIViewController+CameraPermissions.m | 2 +- Signal/src/views/OWSAlerts.swift | 3 +-- .../translations/en.lproj/Localizable.strings | 3 +-- 12 files changed, 35 insertions(+), 43 deletions(-) diff --git a/Signal/src/UserInterface/Strings.swift b/Signal/src/UserInterface/Strings.swift index 2dfe5c59a..6c77c4411 100644 --- a/Signal/src/UserInterface/Strings.swift +++ b/Signal/src/UserInterface/Strings.swift @@ -9,7 +9,7 @@ import Foundation */ @objc class CommonStrings: NSObject { - static let dismissActionText = NSLocalizedString("DISMISS_BUTTON_TEXT", comment: "Short text to dismiss current modal / actionsheet / screen") + static let dismissButton = NSLocalizedString("DISMISS_BUTTON_TEXT", comment: "Short text to dismiss current modal / actionsheet / screen") } @objc class CallStrings: NSObject { diff --git a/Signal/src/ViewControllers/CallViewController.swift b/Signal/src/ViewControllers/CallViewController.swift index a8b123dde..cf8d01788 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -371,7 +371,6 @@ 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: audioRouteButton, imageName: "audio-call-speaker-active") ongoingCallView = createContainerForCallControls(controlGroups : [ [audioModeMuteButton, audioRouteButton, audioModeVideoButton ], @@ -392,7 +391,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R let actionSheetController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) - let dismissAction = UIAlertAction(title: CommonStrings.dismissActionText, style: .cancel, handler: nil) + let dismissAction = UIAlertAction(title: CommonStrings.dismissButton, style: .cancel, handler: nil) actionSheetController.addAction(dismissAction) let currentAudioSource = callUIAdapter.audioService.currentAudioSource(call: self.call) diff --git a/Signal/src/ViewControllers/CodeVerificationViewController.m b/Signal/src/ViewControllers/CodeVerificationViewController.m index ab919977d..4036ee98d 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 84e9da8ac..73a046098 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 f960be65b..a2e41dbae 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 c49b42c2c..b8561db6b 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 3513af574..50eb09603 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 e08cbf6d4..32ad55e42 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 989ed2004..6feaba7cf 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/util/UIViewController+CameraPermissions.m b/Signal/src/util/UIViewController+CameraPermissions.m index 58abd9685..18a63f33b 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 ce3644a49..308c6ea09 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 10096b7f2..49be66d5d 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -379,8 +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 */ +/* Short text to dismiss current modal / actionsheet / screen */ "DISMISS_BUTTON_TEXT" = "Dismiss"; /* Section title for the 'domain fronting country' view. */ From 4e11e90ebba7c8e8306ebdbdc54f03d166a99f7b Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 13 Jul 2017 15:03:42 -0400 Subject: [PATCH 5/8] cleanup - remove dead code - rename vars - add coments // FREEBIE --- .../ViewControllers/CallViewController.swift | 101 +++++++----------- Signal/src/call/CallAudioService.swift | 51 --------- 2 files changed, 37 insertions(+), 115 deletions(-) diff --git a/Signal/src/ViewControllers/CallViewController.swift b/Signal/src/ViewControllers/CallViewController.swift index cf8d01788..b4394695f 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -41,8 +41,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R var ongoingCallView: UIView! var hangUpButton: UIButton! - var audioRouteButton: UIButton! - var soundRouteButton: UIButton! + var audioSourceButton: UIButton! var audioModeMuteButton: UIButton! var audioModeVideoButton: UIButton! var videoModeMuteButton: UIButton! @@ -87,28 +86,21 @@ 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)") + // 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 allAvailableAudioRoutes.count > 2 + return allAudioSources.count > 2 } - var allAvailableAudioRoutes: Set + var allAudioSources: Set - var availableAudioRoutes: Set { + var appropriateAudioSources: Set { if call.hasLocalVideo { - let forVideo = allAvailableAudioRoutes.filter { audioSource in + let appropriateForVideo = allAudioSources.filter { audioSource in if audioSource.isBuiltInSpeaker { return true } else { @@ -116,12 +108,14 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R 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(forVideo) + return Set(appropriateForVideo) } else { - return allAvailableAudioRoutes + return allAudioSources } } @@ -150,7 +144,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R required init?(coder aDecoder: NSCoder) { contactsManager = Environment.getCurrent().contactsManager callUIAdapter = Environment.getCurrent().callUIAdapter - allAvailableAudioRoutes = Set(callUIAdapter.audioService.availableInputs) + allAudioSources = Set(callUIAdapter.audioService.availableInputs) super.init(coder: aDecoder) observeNotifications() } @@ -158,7 +152,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R required init() { contactsManager = Environment.getCurrent().contactsManager callUIAdapter = Environment.getCurrent().callUIAdapter - allAvailableAudioRoutes = Set(callUIAdapter.audioService.availableInputs) + allAudioSources = Set(callUIAdapter.audioService.availableInputs) super.init(nibName: nil, bundle: nil) observeNotifications() } @@ -354,8 +348,8 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R // textMessageButton = createButton(imageName:"message-active-wide", // action:#selector(didPressTextMessage)) - audioRouteButton = createButton(imageName:"audio-call-speaker-inactive", - action:#selector(didPressAudioRoute)) + 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", @@ -373,7 +367,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R setButtonSelectedImage(button: videoModeVideoButton, imageName: "video-video-selected") ongoingCallView = createContainerForCallControls(controlGroups : [ - [audioModeMuteButton, audioRouteButton, audioModeVideoButton ], + [audioModeMuteButton, audioSourceButton, audioModeVideoButton ], [videoModeMuteButton, hangUpButton, videoModeVideoButton ] ]) } @@ -382,10 +376,10 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R AssertIsOnMainThread() // TODO unnecessary? let availableInputs = callUIAdapter.audioService.availableInputs - self.allAvailableAudioRoutes.formUnion(availableInputs) + self.allAudioSources.formUnion(availableInputs) } - func presentAudioRoutePicker() { + func presentAudioSourcePicker() { Logger.info("\(TAG) in \(#function)") AssertIsOnMainThread() @@ -395,7 +389,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R actionSheetController.addAction(dismissAction) let currentAudioSource = callUIAdapter.audioService.currentAudioSource(call: self.call) - for audioSource in self.availableAudioRoutes { + for audioSource in self.appropriateAudioSources { // TODO add image let routeAudioAction = UIAlertAction(title: audioSource.localizedName, style: .default) { _ in // Disable any speakerphone @@ -413,27 +407,9 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R 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) { let image = UIImage(named:imageName) assert(image != nil) @@ -806,33 +782,30 @@ 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") - + // 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". - audioRouteButton.isSelected = false + audioSourceButton.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) + 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 { - audioRouteButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_audio_mode"), for: .normal) - audioRouteButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_audio_mode"), for: .selected) + audioSourceButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_audio_mode"), for: .normal) + audioSourceButton.setImage(#imageLiteral(resourceName: "ic_speaker_bluetooth_inactive_audio_mode"), for: .selected) } - audioRouteButton.isHidden = false + audioSourceButton.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) + 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. - audioRouteButton.isHidden = hasLocalVideo + audioSourceButton.isHidden = hasLocalVideo } // Dismiss Handling @@ -892,11 +865,11 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R } } - func didPressAudioRoute(sender button: UIButton) { + func didPressAudioSource(sender button: UIButton) { Logger.info("\(TAG) called \(#function)") - if self.hasAlternateAudioRoutes { - presentAudioRoutePicker() + if self.hasAlternateAudioSources { + presentAudioSourcePicker() } else { didPressSpeakerphone(sender: button) } diff --git a/Signal/src/call/CallAudioService.swift b/Signal/src/call/CallAudioService.swift index b0ccc7d3c..8fab5723e 100644 --- a/Signal/src/call/CallAudioService.swift +++ b/Signal/src/call/CallAudioService.swift @@ -9,17 +9,11 @@ public let CallAudioServiceSessionChanged = Notification.Name("CallAudioServiceS 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 @@ -385,69 +379,24 @@ struct AudioSource: Hashable { // 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 From 03f1bbca62b5c406aac56e5eaf7ed77b39236518 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 13 Jul 2017 15:37:01 -0400 Subject: [PATCH 6/8] Move state from CallViewController -> Call I think whenever reasonable we prefer to consodlidate state on the call // FREEBIE --- .../ViewControllers/CallViewController.swift | 34 +++++-------------- Signal/src/call/CallAudioService.swift | 11 +++++- Signal/src/call/CallService.swift | 2 +- Signal/src/call/SignalCall.swift | 17 +++++++--- .../call/UserInterface/CallUIAdapter.swift | 8 ++--- 5 files changed, 35 insertions(+), 37 deletions(-) diff --git a/Signal/src/ViewControllers/CallViewController.swift b/Signal/src/ViewControllers/CallViewController.swift index b4394695f..a7d1b4ddd 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -119,26 +119,6 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R } } - 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) { @@ -390,11 +370,8 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R let currentAudioSource = callUIAdapter.audioService.currentAudioSource(call: self.call) for audioSource in self.appropriateAudioSources { - // 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 + self.callUIAdapter.setAudioSource(call: self.call, audioSource: audioSource) } // HACK: private API to create checkmark for active audio source. @@ -879,7 +856,12 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R Logger.info("\(TAG) called \(#function)") button.isSelected = !button.isSelected if let call = self.call { - callUIAdapter.setIsSpeakerphoneEnabled(call: call, isEnabled: button.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") } @@ -993,7 +975,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/call/CallAudioService.swift b/Signal/src/call/CallAudioService.swift index 8fab5723e..5a752442c 100644 --- a/Signal/src/call/CallAudioService.swift +++ b/Signal/src/call/CallAudioService.swift @@ -139,10 +139,19 @@ struct AudioSource: Hashable { 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) { diff --git a/Signal/src/call/CallService.swift b/Signal/src/call/CallService.swift index 5dfc2b572..86dbc6c0e 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 82a7cd44c..908291d12 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 bf737b576..e47c287a0 100644 --- a/Signal/src/call/UserInterface/CallUIAdapter.swift +++ b/Signal/src/call/UserInterface/CallUIAdapter.swift @@ -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. From b495b234206a0bf75f7b3bd155c1c54664fa8b75 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 13 Jul 2017 16:04:39 -0400 Subject: [PATCH 7/8] more cleanup and commenting // FREEBIE --- .../ViewControllers/CallViewController.swift | 11 ++++++++-- Signal/src/call/CallAudioService.swift | 20 +++++++++++-------- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/Signal/src/ViewControllers/CallViewController.swift b/Signal/src/ViewControllers/CallViewController.swift index a7d1b4ddd..4e5ac5714 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -354,13 +354,20 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R func didChangeAudioSession() { AssertIsOnMainThread() - // TODO unnecessary? + + // 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() { - Logger.info("\(TAG) in \(#function)") AssertIsOnMainThread() let actionSheetController = UIAlertController(title: nil, message: nil, preferredStyle: .actionSheet) diff --git a/Signal/src/call/CallAudioService.swift b/Signal/src/call/CallAudioService.swift index 5a752442c..59b6238ea 100644 --- a/Signal/src/call/CallAudioService.swift +++ b/Signal/src/call/CallAudioService.swift @@ -407,16 +407,20 @@ struct AudioSource: Hashable { } 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 - } + if let audioSource = call.audioSource { + return audioSource + } - return AudioSource(portDescription: portDescription) + // 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?) { From 90c2324f99eb24b226e58a660a12046ddd1e43c0 Mon Sep 17 00:00:00 2001 From: Michael Kirk Date: Thu, 13 Jul 2017 16:22:30 -0400 Subject: [PATCH 8/8] pixel cleanup in bluetooth speaker image // FREEBIE --- ..._speaker_bluetooth_inactive_video_mode.png | Bin 6135 -> 6016 bytes 1 file changed, 0 insertions(+), 0 deletions(-) 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 index 5cb7ff12c2713cd8ed4d1dbf623fc625e4af0e3f..ee27e62432648a97a233a0e4cb267406a9dbf310 100644 GIT binary patch literal 6016 zcmZu#2{_bU+y7g_$d;+Bp(jxa6O)GQkt`!wM)oz4eJA_=MpAae*s{wKAxn%T$!;EG zAB5~gT6!gfAg7ypqoC@VF)3Kb1`;%7{>HZT~HA^8Jl=%ZlmUTMu}d<&-a!QZZq@nzy#x3N)_8v%v&3 znXs9Wyb5yG!I^)_*SxZ)|0&5-{*j+`G-#&y%mZGe z+~eTIxL$=0G#Y*8@ZjJ_RX#&-b7pl-O`)`$wWdz+;H6MLvjyun4-|4WUkJY>QD!MW z4*m~G9?B0|P9=;S&TuR40dF^g4yeF>6ibL#hxGE^B-8hhC*XcRj8eDW8i5P*NW#!X zs)32PUP?1qk-9vbfxc7YT26`J&YG+RJw3f5A0Hp;b4qVc9c6(83{4KV&dklt?fvu5 z^Pa4*u8|zg!UqyKAra=xMc|4%j43UYF9^nzemZ1>oG3=`pN4-oTTxUk);b3gd69)e zWWjO0ya+O;G-*a)x1elwb(QqziIp#X>+&VxFgz~3_PFoclch{aRVsLRLwTM|o%ZUlcU zCpooOd@5~rW)ziss_I#iP>r4Ca^zxkH7AMs4kvDEPX%Zko%&Y@hJwVz_rJcjm}CX> zPWv#=RTxONSK~yBcnlSwj0|lCuOM2c5W4Kpnl4s58A>y@mzaxYN`}Um(&QRxAqQgo zXMt~~P+-)ED>*zcbz}>pi+$HdNi34T@nf2?r&|%SOT}WJ>3LKwvk(#~3Uo|NOgcQ| zpDA1O#kB`)R#7-vObryNY|@#C2aaP?Qfv-p0?>@;5f^A%L`KGB!_B(dW#b}bU$-P* znGjb4NeU8cO%x0mGT&gUe&IaWz#D>moB$l!gedtS?&^gP^UyUa+(wMn6LOM%m2xf& z?;6|w^UP2fpAKK(MQmn2B}9GRLGI2cJ;=K1ekfo4dkT_W6`LJC)$)!9q>aUtN1Y42 zPZK8ps!(i+-DTOFGV@$@bsAjI{cIOzIg<|RDivG0h%Nac#|9bd*@z^;gbTDEUstLc z&gr!jf*U&#@nlssHKI(`>OKq~$Jb}tZc9iMnZA}eThv7r&iC*He}g#+t)8f73SwhGz0sltwdrbT}yAH-ytjAns#cnBh4Axy{p`Dom>Uk*P>xyz!4(n!(hrEBrDs}(!$QhW`=0!%N|`! z4~af%BP*i~laFjQXXoIsKolU!9-m`~IQ1k(`ru#9Jm9%+a4_%9n{zlx@7QLNauk={ z7ny^NldpkEE0awo>t2R!G2BmqqGy?*kmz?(Lusi{tNE=<(!R;3+J^^+hkip(I8jcr z;bsM8Wie;*ErQR>(@$7=l!2~^?|RtI^6*T3kFnoaMSXqh+2*&&P6|eT$NTf_oSa*J z2}UFm&)O&c7Q~-@ea)s`?-Ut_X1Ifzws&`z zI#Wa?F?(sUJehI)51+ZZYAv|w0~C|XlA=CNZ{pG}2G?rwFQl+R#|=fBE~KNaOa%vju7AUtz)oUFy2jd;Qu4xrV2eOgh7 zN4RD_Og<=K!HaA&DWKkEHZr@fK#ib%?fl64{OHJtC2P1!Zmj>wZ%-lKvT#0^sWXI632Oup{-J z!W6#{WvSV|M!D@KQH-|&YJFn3r23ba-+|3wQM)bey^-^Mw!(jZmgPc}B2{1XinmD0 zPjBH2=cR)VDW9ER3xxbv&mVfOP3T-Z-wz%JMn~&sdUPMw_#SK(_Aek~4Eobs7K#o{ z>I4x!K0fXTTQ)e9^JnKZduQiI`@Y5x(Lq znLLoftgK!hUqW&9LtRcQBe%m^%%WprMn{N1;a-xk$=tMCcwi1~0$=({z`|Gj^1{L! zGuEC7AFw^u2yQ=Lyw)u>d?c1yC8;|+ILJpp-jSZ?X;g8vmKH5-vX}^xoYej9fyAWNRDjHzIgHCM^l@gJ{1S! zfc-kWuQ=*PLgZWhZI#??JF#L}9#ijbm&ryyki~?-!9<6NhW4-}o)7cV({tNNQc_YP zXxFwGWKl^S!GXcv(b4wF6UEfj)av6nl=F0G?#jGu?h6T*!-I|oyxF@GK0O;pwzp9f zL4g+f+s(~#V7D2NeXOX+=D6cvZDA1-m8=5qnhu+9Q_YQC9j||C>l*c}cr`TjUPwrY z2wh7*wrie36sInlzW@nO&S&*+AzBdWI))COyQ@aZu{PM}yW^hrXP(}eM^RHz(Wrrp zXt#6;zdVfF+3`^Ie84dxO&M~qHxpx2n`WI)e5WlSg4Xi$^Q+c(FWg@`)v?6pmEaAH zjCAqwy2CN8`D%GZEYnn%}X%p;kQ&oiB z)8NjtVNOQVb-PK}`@E{uJ4>{+X@{xF6Fhg;CVP00wK_E%X@|RONoK6{Mz7qKf81gV z&#Ia9AKkFUU@&%$=5+G#g6k-)!uZ6*M9xRfTz5Ux@@z(EI|l}Io>c0!NwreS!#^wv zQ$GEabOqqiXepvtP&{bCx7~7sJPX!yCVxD#(zscVzLSEQ;Rl17zROTD4|1v&^J$Pc z*2VesHe!YIHO2^|U|OfOSGy569!)%m)Y3#Rp=`aO%%&z2Fjy%qaLsGIbA-!%sv~ z>ZtN@c(q(jO1`Z901v+IAWW_6ygDJIQr}D@LfM+Ul%ZSZc<=30Qxv}+4H~c2yiaTn z3CARlZLQ}4ynOv-+;y*WWwhq%Z$I7f?%2!4K`AP^*6mV7fahF2Q-GZ6AgbjP+)Gd? zEnAENXbtDpis_$y55QD)oY=#&JSV*K=V;^bTP!24d(tS;l$RJ?UHc{vKR;ak&rm+1 z49MyP75h}*fddI9Y~T%@-InFW#fsk}uU;{L9dT#2Tnt6+iY1l2Rf-vzVBCB%&@#f? zm#1YzPMLS1OOlH=1L{UqAk_Z}f z=E5sZ@qQBio%yS{UP?pmWRIS$LZ#MrPQZ#kR$RS9*Rmllx6PnF2lyaf=Yo|aSDCG8 zV#kw`l9J(46bz7HCq-2Ueng2R^Zw6v`0S>)lRm_wr~^ zZCzcF%*hd-gG?03aGT#Sx*K?I0bfQzc`xK{3AsW~Is~dMW(L{@0{q6;R z&jFhrG9_`}y>d`sOiVIL@bBDxYFb+rNAKc2Jv;=sV&H%;6T9)9mLl~M+2+p9eICUV z0|TvpO`!5>SzEV46(zsxX{QeSJ0++7A~-hit-;3qOOM`v{8(ToW_vit^w;Ze_;*~v zgWoJX9LMW&0U#;GaOwHe|NSblK`$b6yulmP;Sr3JNZZ2aUNv53vhcSa0D|bqgZAZH zQ`#+`(W>ja3!qIv71OCTwBlhOi;ani8A3)3e@i?ckFAA8%Z6=TT^$mIDgY2CWJPVJPMFawjQ2lUR{{l2H{cbBEm)ody@6^i)9Q+= z0khothyp;RK&|g6^WOUP=Clgmu$HsVW&qe;xYl^6e&fcC(qqn!j*jn^B4zaI{GDcv zDm+XYl3b@1gPhDZN}Ef@|DB=G)7rZGQSz?V*4IA2_9A@$9+{V)e@S#+e~swY&pFp2 zeMGt!{qp7GM!Apg-)9*LJuac73g#Slhm zFo>>!L27P>@Wm*Ve8Ry>&E&_&mm;q!Nyn??ZvE=J;ZP@hOC@>%yI`Lc+pJLnZ<4mZm$qyMWBvtWA7M zq!0zF4#>Ab$vclfeck2GN_oIbqshJT z{dqTLadkD(j8)?KR>ADh*hlAkfIR}bUyRnePH7qI^$gQqx^(HZ)Z+1hpi`rt z0boKg+%i^>5f_$p3rt1K0LfnUNwg2ZtKHhht2v*6#FS7z;}_oQw(?o8r`JD*M??t9 ztW-8Myc-_Y8*d88Aj|0wO+xh4R`&Hh^4scDN~CZFMS9pSQDwq5t6gZdz&w3(+x&ep zT}%14m!)*Z{Q3g{wAZE0w396`!&B;io>uQ*-~2sD%i_s^6I`?F3vkj!WW6vlOW$3)uWd>Tq4qwQ_fO+mry z89l`s*PI^h-9nPx1(kLS?5YEg^i2?6;IO{F{gkP2}OY!AiKJ{y2Lkl)jnK9k~Qep8e)W!2!JKC z(#4c(@-ky(>8q=&<9p^4I%Zkv1kn0!M6^Icr2pZ{9iVp$6oWvVVWY2JiduN&!Xc&fw(p@$PmsjkQdtx5W7v9l20-|(*1dai76>Bs z($W&hFl-ZL%uIt=`pHtCVm`JKoyiu?9hk8!fSh#8lz>ou{r#E+PqJW;gXrzs zRmFGx>KV|9++>+j)DUBl(i;}H?;K>102(4`B?7?AzN*p<{VE*gTu+^(u1fcq`oN(GR`guqO;2oqXn~KT$8T`mQejZXeRrpRXpTd z(;!vWCU9)}T8|1Ztp91Qe%3}SM&}3|8x55qdv7gNo|Kh8Jdi*^IG2z|prV8f&tXqo zgUToau?nnAvk4y-eq{V3EwoI-R3@6>m=j!}c;c=Tsp@r9j#&|1ej-9uRrPXBgYM9H zR-O)Zfd~v84Cg@@{qd_`&Z_XdtSK{AOnVGTP3|Sft2s&SHg*6-6=&2b=W4!jlQ8<} zLREk$L<(AOSMNec7m`aEyB1lPqVLPhYEsewHk|D`-~C&Ajrg-%ED-N|;o%DJ7fs$k zJTKCIdX7ofv_E|mAuVleF3v1#s%bMw8jfw}rOu`Rq|k2Rg)~=Ar?Rm!N#Eg2qaL`}PgT9Ke-h>t^ai3)6F?WKpAHOD z4=m?JUV8HUxdb>ae}4&7nXuY8eCp@^A01D>j(EHiBybPFLWd3&nUxRQZ}O>Gh@qxa5Hk? zmt?lY5FFd9pxRVsDbh|4eFmlqq+u#dSRPq94d(xl;9B?c$j$Nb@u~LqcES6>y`d3J zKC54}(BMUa^;_~-`ypBD;Aimyzyf>sy?LH4Ugj5~zHfPua;XnnX~5+;Ug!Ai@ut-K TcflM6;1N<*R97gKHx2$DV9IVv literal 6135 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 D3@Lv`