diff --git a/Signal/src/ViewControllers/CallViewController.swift b/Signal/src/ViewControllers/CallViewController.swift index 4fb6ab977..4035b2d8e 100644 --- a/Signal/src/ViewControllers/CallViewController.swift +++ b/Signal/src/ViewControllers/CallViewController.swift @@ -89,7 +89,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R // MARK: Audio Source var hasAlternateAudioSources: Bool { - Logger.info("\(TAG) available audio routes count: \(allAudioSources.count)") + Logger.info("\(TAG) available audio sources: \(allAudioSources)") // 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? diff --git a/Signal/src/call/CallAudioService.swift b/Signal/src/call/CallAudioService.swift index 133292192..342839755 100644 --- a/Signal/src/call/CallAudioService.swift +++ b/Signal/src/call/CallAudioService.swift @@ -12,19 +12,34 @@ struct AudioSource: Hashable { let image: UIImage let localizedName: String let portDescription: AVAudioSessionPortDescription? + + // The built-in loud speaker / aka speakerphone let isBuiltInSpeaker: Bool - init(localizedName: String, image: UIImage, isBuiltInSpeaker: Bool, portDescription: AVAudioSessionPortDescription? = nil) { + // The built-in quiet speaker, aka the normal phone handset receiver earpiece + let isBuiltInEarPiece: Bool + + init(localizedName: String, image: UIImage, isBuiltInSpeaker: Bool, isBuiltInEarPiece: Bool, portDescription: AVAudioSessionPortDescription? = nil) { self.localizedName = localizedName self.image = image self.isBuiltInSpeaker = isBuiltInSpeaker + self.isBuiltInEarPiece = isBuiltInEarPiece self.portDescription = portDescription } init(portDescription: AVAudioSessionPortDescription) { - self.init(localizedName: portDescription.portName, + + let isBuiltInEarPiece = portDescription.portType == AVAudioSessionPortBuiltInMic + + // portDescription.portName works well for BT linked devices, but if we are using + // the built in mic, we have "iPhone Microphone" which is a little awkward. + // In that case, instead we prefer just the model name e.g. "iPhone" or "iPad" + let localizedName = isBuiltInEarPiece ? UIDevice.current.localizedModel : portDescription.portName + + self.init(localizedName: localizedName, image:#imageLiteral(resourceName: "button_phone_white"), // TODO isBuiltInSpeaker: false, + isBuiltInEarPiece: isBuiltInEarPiece, portDescription: portDescription) } @@ -32,7 +47,8 @@ struct AudioSource: Hashable { 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) + isBuiltInSpeaker: true, + isBuiltInEarPiece: false) } // MARK: Hashable @@ -143,15 +159,6 @@ struct AudioSource: Hashable { AssertIsOnMainThread() ensureProperAudioSession(call: call) - - // It's importent to set preferred input *after* ensuring properAudioSession - // because some sources are only valid for certain category/option combinations. - 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) { @@ -169,32 +176,53 @@ struct AudioSource: Hashable { return } + // Disallow bluetooth while (and only while) the user has explicitly chosen the built in receiver. + // + // NOTE: I'm actually not sure why this is required - it seems like we should just be able + // to setPreferredInput to call.audioSource.portDescription in this case, + // but in practice I'm seeing the call revert to the bluetooth headset. + // Presumably something else (in WebRTC?) is touching our shared AudioSession. - mjk + let options: AVAudioSessionCategoryOptions = call.audioSource?.isBuiltInEarPiece == true ? [] : [.allowBluetooth] + if call.state == .localRinging { // SoloAmbient plays through speaker, but respects silent switch setAudioSession(category: AVAudioSessionCategorySoloAmbient, mode: AVAudioSessionModeDefault) } else if call.hasLocalVideo { - // Don't allow bluetooth for local video if speakerphone has been explicitly chosen by the user. - let options: AVAudioSessionCategoryOptions = call.isSpeakerphoneEnabled ? [.defaultToSpeaker] : [.defaultToSpeaker, .allowBluetooth] - + // Apple Docs say that setting mode to AVAudioSessionModeVideoChat has the + // side effect of setting options: .allowBluetooth, when I remove the (seemingly unnecessary) + // option, and inspect AVAudioSession.sharedInstance.categoryOptions == 0. And availableInputs + // does not include my linked bluetooth device setAudioSession(category: AVAudioSessionCategoryPlayAndRecord, mode: AVAudioSessionModeVideoChat, options: options) } else { + // Apple Docs say that setting mode to AVAudioSessionModeVoiceChat has the + // side effect of setting options: .allowBluetooth, when I remove the (seemingly unnecessary) + // option, and inspect AVAudioSession.sharedInstance.categoryOptions == 0. And availableInputs + // does not include my linked bluetooth device setAudioSession(category: AVAudioSessionCategoryPlayAndRecord, mode: AVAudioSessionModeVoiceChat, - options: [.allowBluetooth]) + options: options) } let session = AVAudioSession.sharedInstance() do { + // It's important to set preferred input *after* ensuring properAudioSession + // because some sources are only valid for certain category/option combinations. + let existingPreferredInput = session.preferredInput + if existingPreferredInput != call.audioSource?.portDescription { + Logger.info("\(TAG) changing preferred input: \(String(describing: existingPreferredInput)) -> \(String(describing: call.audioSource?.portDescription))") + try session.setPreferredInput(call.audioSource?.portDescription) + } + if call.isSpeakerphoneEnabled { try session.overrideOutputAudioPort(.speaker) } else { try session.overrideOutputAudioPort(.none) } } catch { - owsFail("\(TAG) failed overrideing output audio. isSpeakerPhoneEnabled: \(call.isSpeakerphoneEnabled)") + owsFail("\(TAG) failed setting audio source with error: \(error) isSpeakerPhoneEnabled: \(call.isSpeakerphoneEnabled)") } } @@ -211,6 +239,11 @@ struct AudioSource: Hashable { Logger.verbose("\(TAG) in \(#function) new state: \(call.state)") + // Stop playing sounds while switching audio session so we don't + // get any blips across a temporary unintended route. + stopPlayingAnySounds() + self.ensureProperAudioSession(call: call) + switch call.state { case .idle: handleIdle(call: call) case .dialing: handleDialing(call: call) @@ -233,8 +266,6 @@ struct AudioSource: Hashable { Logger.debug("\(TAG) \(#function)") AssertIsOnMainThread() - ensureProperAudioSession(call: call) - // HACK: Without this async, dialing sound only plays once. I don't really understand why. Does the audioSession // need some time to settle? Is somethign else interrupting our session? DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 0.2) { @@ -245,17 +276,12 @@ struct AudioSource: Hashable { private func handleAnswering(call: SignalCall) { Logger.debug("\(TAG) \(#function)") AssertIsOnMainThread() - - stopPlayingAnySounds() - self.ensureProperAudioSession(call: call) } private func handleRemoteRinging(call: SignalCall) { Logger.debug("\(TAG) \(#function)") AssertIsOnMainThread() - stopPlayingAnySounds() - // FIXME if you toggled speakerphone before this point, the outgoing ring does not play through speaker. Why? self.play(sound: Sound.outgoingRing) } @@ -264,27 +290,18 @@ struct AudioSource: Hashable { Logger.debug("\(TAG) in \(#function)") AssertIsOnMainThread() - stopPlayingAnySounds() - ensureProperAudioSession(call: call) startRinging(call: call) } private func handleConnected(call: SignalCall) { Logger.debug("\(TAG) \(#function)") AssertIsOnMainThread() - - stopPlayingAnySounds() - - // start recording to transmit call audio. - ensureProperAudioSession(call: call) } private func handleLocalFailure(call: SignalCall) { Logger.debug("\(TAG) \(#function)") AssertIsOnMainThread() - stopPlayingAnySounds() - play(sound: Sound.failure) } @@ -308,9 +325,8 @@ struct AudioSource: Hashable { Logger.debug("\(TAG) \(#function)") AssertIsOnMainThread() - stopPlayingAnySounds() - play(sound: Sound.busy) + // Let the busy sound play for 4 seconds. The full file is longer than necessary DispatchQueue.main.asyncAfter(deadline: DispatchTime.now() + 4.0) { self.handleCallEnded(call: call) @@ -321,8 +337,6 @@ struct AudioSource: Hashable { Logger.debug("\(TAG) \(#function)") AssertIsOnMainThread() - stopPlayingAnySounds() - // Stop solo audio, revert to default. setAudioSession(category: AVAudioSessionCategoryAmbient) } @@ -431,17 +445,6 @@ struct AudioSource: Hashable { 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)) { diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 49be66d5d..df3932cd0 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -140,7 +140,7 @@ "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"; +"AUDIO_ROUTE_BUILT_IN_SPEAKER" = "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.";