|
|
|
|
Quelle CallViewController.swift
Sprache: JAVA
|
|
import AudioSessionManager
import CocoaLumberjackSwift
import Foundation
import ThreemaFramework
import ThreemaMacros
import WebRTC
protocol CallViewControllerDelegate: AnyObject {
func currentCallID() -> VoIPCallID
/// The route as it is *now*.
func currentAudioRoute() async -> AudioSessionRouteSnapshot
func isCallMuted() -> Bool
func isSpeakerActive() -> Bool
func startCaptureLocalVideo(
renderer: RTCVideoRenderer,
useBackCamera: Bool,
switchCamera: Bool
)
func endCaptureLocalVideo(switchCamera: Bool)
func localVideoRenderer() -> RTCVideoRenderer?
func renderRemoteVideo(to renderer: RTCVideoRenderer)
func remoteVideoRenderer() -> RTCVideoRenderer?
func endRemoteVideo()
func remoteVideoQualityProfile() -> CallsignalingProtocol.ThreemaVideoCallQualityProfile?
func networkIsRelayed() -> Bool
}
final class CallViewController: UIViewController {
@IBOutlet private var contentView: UIView!
@IBOutlet private var contactLabel: UILabel!
@IBOutlet private var verificationLevel: UIImageView!
@IBOutlet private var debugLabel: UILabel!
@IBOutlet private var acceptButton: UIButton!
@IBOutlet private var rejectButton: UIButton!
@IBOutlet private var hideButton: UIButton!
@IBOutlet private var cellularWarningButton: UIButton!
@IBOutlet private var timerLabel: UILabel!
@IBOutlet private var localVideoView: UIView!
@IBOutlet private var remoteVideoView: UIView!
@IBOutlet var muteButton: UIButton!
@IBOutlet var speakerButton: UIButton!
@IBOutlet var endButton: UIButton!
@IBOutlet var cameraButton: UIButton!
@IBOutlet var cameraSwitchButton: UIButton!
@IBOutlet var callInfoStackView: UIStackView!
@IBOutlet var callInfoStackViewTopConstraint: NSLayoutConstraint!
@IBOutlet var phoneButtonsStackView: UIStackView!
@IBOutlet var phoneButtonsStackViewBottomConstraint: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintHeight: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintWidth: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintLeft: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintRight: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintBottom: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintBottomNavigation: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintTop: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintTopNavigation: NSLayoutConstraint!
@IBOutlet var localVideoViewConstraintTopNavigationLabel: NSLayoutConstraint!
@IBOutlet var phoneButtonsGradientView: UIView!
@IBOutlet var callInfoGradientView: UIView!
private lazy var profilePictureView: ProfilePictureImageView = {
let profilePictureView = ProfilePictureImageView(iconConfiguration: .small)
profilePictureView.translatesAutoresizingMaskIntoConstraints = false
return profilePictureView
}()
var contactIdentity: String? {
didSet {
if let contactIdentity {
contact = BusinessInjector.ui.entityManager.entityFetcher.contactEntity(for: contactIdentity)
}
else {
contact = nil
}
}
}
var alreadyAccepted = false
var callStartedBySelf = false
var isTesting = false
var viewWasHidden = false
var threemaVideoCallAvailable = false
var isLocalVideoActive = false
var isReceivingRemoteVideo = false {
didSet {
if isReceivingRemoteVideo {
startRemoteVideo()
DispatchQueue.main.async {
let generator = UINotificationFeedbackGenerator()
generator.notificationOccurred(.warning)
}
}
else {
endRemoteVideo()
}
}
}
var isRemoteVideoPortrait = false
var shouldShowCellularCallWarning = false {
didSet {
DispatchQueue.main.async {
if let warningButton = self.cellularWarningButton,
warningButton.isHidden == self.shouldShowCellularCallWarning {
warningButton.isHidden = !self.shouldShowCellularCallWarning
if self.shouldShowCellularCallWarning {
self.playCellularCallWarningSound()
}
}
}
}
}
private var contact: ContactEntity?
private var statsTimer: Timer?
private var useBackCamera = false
private var myVolumeView: UIView?
private var didRotateDevice = false
private var audioPlayer: AVAudioPlayer?
/// The route last reported by the call's claim, via `VoIPCallService`.
private var currentAudioRoute: AudioSessionRouteSnapshot?
private var enterForegroundObserver: NSObjectProtocol?
weak var delegate: CallViewControllerDelegate?
required init?(coder aDecoder: NSCoder) {
super.init(coder: aDecoder)
}
deinit {
if let foregroundObserver = enterForegroundObserver {
NotificationCenter.default.removeObserver(foregroundObserver)
}
}
// MARK: - Audio route
func audioRouteChanged(to route: AudioSessionRouteSnapshot) {
currentAudioRoute = route
// Might not be presented yet, `viewDidLoad` redraws if needed
guard isViewLoaded else {
return
}
updateSpeakerButtonImage()
checkAndHandleAvailableBluetoothDevices()
}
/// Draw the speaker button for the route we were last told about.
private func updateSpeakerButtonImage() {
guard let route = currentAudioRoute else {
return
}
for output in route.outputs {
if output == AVAudioSession.Port.builtInSpeaker {
let speakerImage = UIImage(systemName: "speaker.wave.2.circle.fill")?
.applying(
pointSize: 36.0,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.black, .black, .white]
)
speakerButton.setImage(
speakerImage,
for: .normal
)
speakerButton.setImage(
speakerImage,
for: .highlighted
)
speakerButton.setImage(
speakerImage,
for: .selected
)
speakerButton.tag = 1
}
else if output == AVAudioSession.Port.headphones {
let headphonesImage = UIImage(systemName: "headphones.circle.fill")?
.applying(
pointSize: 36.0,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.black, .black, .white]
)
speakerButton.setImage(
headphonesImage,
for: .normal
)
speakerButton.setImage(
headphonesImage,
for: .highlighted
)
speakerButton.setImage(
headphonesImage,
for: .selected
)
speakerButton.tag = 2
}
else if [.bluetoothA2DP, .bluetoothHFP, .bluetoothLE].contains(output) {
let bluetoothImage = UIImage(resource: .threemaBluetoothCircleFill)
.applying(
pointSize: 36.0,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.black, .black, .white]
)
speakerButton.setImage(
bluetoothImage,
for: .normal
)
speakerButton.setImage(
bluetoothImage,
for: .highlighted
)
speakerButton.setImage(
bluetoothImage,
for: .selected
)
speakerButton.tag = 3
}
else {
let earpeaceImage = UIImage(systemName: "speaker.wave.2.circle")?
.applying(
pointSize: 36.0,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.white, .white, .white]
)
speakerButton.setImage(
earpeaceImage,
for: .normal
)
speakerButton.setImage(
earpeaceImage,
for: .highlighted
)
speakerButton.setImage(
earpeaceImage,
for: .selected
)
speakerButton.tag = 0
}
}
updateAccessibilityLabels()
}
override func viewDidLoad() {
super.viewDidLoad()
modalPresentationCapturesStatusBarAppearance = true
localVideoView.layer.cornerCurve = .continuous
localVideoView.layer.cornerRadius = 16
cellularWarningButton.isHidden = true
Task { @MainActor [weak self] in
guard let self, let route = await delegate?.currentAudioRoute() else {
return
}
audioRouteChanged(to: route)
}
contentView.insertSubview(profilePictureView, at: 0)
NSLayoutConstraint.activate([
profilePictureView.centerXAnchor.constraint(equalTo: contentView.centerXAnchor),
profilePictureView.centerYAnchor.constraint(equalTo: contentView.centerYAnchor),
profilePictureView.widthAnchor.constraint(equalTo: contentView.widthAnchor, multiplier: 0.8),
])
enterForegroundObserver = NotificationCenter.default.addObserver(
forName: UIApplication.willEnterForegroundNotification,
object: nil,
queue: .main
) { [unowned self] _ in
if !isNavigationVisible() {
localVideoViewConstraintBottom.isActive = true
}
}
#if DEBUG
let longPressRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(handleLongPress))
hideButton.addGestureRecognizer(longPressRecognizer)
#endif
let panGR = UIPanGestureRecognizer(target: self, action: #selector(didPan(gesture:)))
localVideoView.addGestureRecognizer(panGR)
let showHideNavigation = UITapGestureRecognizer(target: self, action: #selector(showHideNavigation(gesture:)))
remoteVideoView.addGestureRecognizer(showHideNavigation)
let switchVideoViews = UITapGestureRecognizer(target: self, action: #selector(switchVideoViews(gesture:)))
localVideoView.addGestureRecognizer(switchVideoViews)
updateConstraintsAfterRotation(size: CGSize(width: 80.0, height: 107.0))
}
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
NavigationBarPromptHandler.isCallActiveInBackground = false
muteButton.isSelected = delegate?.isCallMuted() ?? false
UIApplication.shared.isIdleTimerDisabled = true
setupView()
updateAccessibilityLabels()
if !isNavigationVisible() {
moveLocalVideoViewToCorrectPosition(moveNavigation: true)
}
checkAndHandleAvailableBluetoothDevices()
}
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
updateGradientBackground()
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
if !NavigationBarPromptHandler.isWebActive {
UIApplication.shared.isIdleTimerDisabled = false
}
switch VoIPCallStateManager.shared.currentCallState() {
case .ended, .remoteEnded, .rejected, .rejectedTimeout, .rejectedUnknown, .microphoneDisabled:
DispatchQueue.main.async {
self.removeAllSubviewsFromVideoViews()
}
case .idle, .sendOffer, .receivedOffer, .outgoingRinging, .incomingRinging, .sendAnswer, .receivedAnswer, .initializing, .calling, .reconnecting:
break
}
}
override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
didRotateDevice = true
}
override func viewDidLayoutSubviews() {
super.viewDidLayoutSubviews()
if didRotateDevice {
didRotateDevice = false
updateGradientBackground()
if let remoteRenderer = delegate?.remoteVideoRenderer() as? RTCMTLVideoView {
updateRemoteVideoContentMode(videoView: remoteRenderer)
}
}
}
override var supportedInterfaceOrientations: UIInterfaceOrientationMask {
.all
}
override var shouldAutorotate: Bool {
true
}
override var preferredStatusBarStyle: UIStatusBarStyle {
.lightContent
}
}
extension CallViewController {
// MARK: Public functions
func voIPCallStatusChanged(state: CallState, oldState: CallState) {
guard !isTesting, oldState != .ended else {
return
}
var timerString = ""
switch state {
case .idle:
timerString = #localize("call_status_wait_ringing")
case .sendOffer:
timerString = #localize("call_status_wait_ringing")
case .receivedOffer:
timerString = #localize("call_status_wait_ringing")
case .outgoingRinging:
timerString = #localize("call_status_ringing")
case .incomingRinging:
timerString = String.localizedStringWithFormat(
#localize("call_status_incom_ringing"),
TargetManager.localizedAppName
)
case .sendAnswer:
timerString = #localize("call_status_ringing")
case .receivedAnswer:
timerString = #localize("call_status_ringing")
case .initializing:
timerString = #localize("call_status_initializing")
case .calling:
timerString = #localize("call_status_calling")
case .reconnecting:
if oldState != .remoteEnded, oldState != .ended {
timerString = #localize("call_status_reconnecting")
}
case .ended, .remoteEnded:
timerString = #localize("call_end")
case .rejected:
timerString = #localize("call_rejected")
case .rejectedTimeout:
timerString = #localize("call_rejected_timeout")
case .rejectedUnknown:
timerString = #localize("call_rejected")
case .microphoneDisabled:
timerString = #localize("call_mic_access")
}
DispatchQueue.main.async {
self.timerLabel?.text = timerString
self.muteButton?.isEnabled = state == .calling || state == .reconnecting
}
updateView()
}
func voIPCallDurationChanged(_ time: Int) {
DispatchQueue.main.async {
if self.timerLabel != nil {
self.timerLabel.text = DateFormatter.timeFormatted(time)
}
}
}
func startDebugMode(connection: RTCPeerConnection?) {
guard let connection else {
return
}
// Hold the connection weakly: once the call is torn down the connection is released and
// the timer stops requesting stats
weak let weakConnection = connection
statsTimer?.invalidate()
statsTimer = nil
DispatchQueue.main.async {
var previousState: VoIPStatsState?
self.statsTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in
guard let connection = weakConnection else {
return
}
connection.statistics { report in
let options = VoIPStatsOptions()
options.selectedCandidatePair = true
options.transport = true
options.crypto = true
options.inboundRtp = true
options.outboundRtp = true
options.tracks = true
options.candidatePairsFlag = .OVERVIEW
let stats = VoIPStats(
report: report,
options: options,
transceivers: connection.transceivers,
previousState: previousState
)
previousState = stats.buildVoIPStatsState()
DispatchQueue.main.async {
if self.debugLabel != nil, self.debugLabel.isHidden == false {
var statsString = stats.getShortRepresentation()
if self.threemaVideoCallAvailable {
statsString +=
"\n\n\(CallsignalingProtocol.printDebugQualityProfiles(remoteProfile: self.delegate?.remoteVideoQualityProfile(), networkIsRelayed: self.delegate?.networkIsRelayed() ?? false))"
}
self.debugLabel.text = statsString
}
}
}
}
}
}
func resetStatsTimer() {
statsTimer?.invalidate()
statsTimer = nil
if let alert = presentedViewController as? UIAlertController {
alert.dismiss(animated: false)
}
}
func enableThreemaVideoCall() {
DispatchQueue.main.async {
self.threemaVideoCallAvailable = true
}
}
func disableThreemaVideoCall() {
DispatchQueue.main.async {
self.threemaVideoCallAvailable = false
self.endLocalVideo()
}
}
}
extension CallViewController {
// MARK: Private functions
private func setupView() {
contactLabel.text = contact?.displayName
contentView.backgroundColor = .black
if let contact {
let businessContact = Contact(contactEntity: contact)
profilePictureView.info = .contact(businessContact)
showProfilePicture(contact: contact)
verificationLevel.image = businessContact.verificationLevelImage
}
if isTesting == false {
debugLabel.isHidden = true
updateView()
timerLabel.isHidden = false
}
if UIDevice.current.userInterfaceIdiom == .pad {
speakerButton.isSelected = true
}
debugLabel.text = ""
let acceptButtonImage = UIImage(systemName: "phone.circle.fill")?
.applying(
pointSize: 50.0,
symbolWeight: .semibold,
symbolScale: .large,
paletteColors: [.white, .white, .systemGreen]
)
acceptButton.setImage(acceptButtonImage, for: .normal)
acceptButton.setImage(acceptButtonImage, for: .selected)
acceptButton.setImage(acceptButtonImage, for: .highlighted)
let rejectButtonImage = UIImage(systemName: "phone.down.circle.fill")?
.applying(
pointSize: 50.0,
symbolWeight: .semibold,
symbolScale: .large,
paletteColors: [.white, .white, .systemRed]
)
rejectButton.setImage(rejectButtonImage, for: .normal)
rejectButton.setImage(rejectButtonImage, for: .selected)
rejectButton.setImage(rejectButtonImage, for: .highlighted)
let endButtonImage = UIImage(systemName: "phone.down.circle.fill")?
.applying(
pointSize: 50.0,
symbolWeight: .semibold,
symbolScale: .large,
paletteColors: [.white, .white, .systemRed]
)
endButton.setImage(endButtonImage, for: .normal)
endButton.setImage(endButtonImage, for: .selected)
endButton.setImage(endButtonImage, for: .highlighted)
let cameraButtonImage = UIImage(resource: .threemaVideoCircle)
.applying(
pointSize: 40,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.white, .white, .white]
)
cameraButton.setImage(cameraButtonImage, for: .normal)
cameraButton.setImage(cameraButtonImage, for: .selected)
cameraButton.setImage(cameraButtonImage, for: .highlighted)
contactLabel.layer.shadowColor = UIColor.black.cgColor
contactLabel.layer.shadowOffset = CGSize(width: 0, height: 0)
contactLabel.layer.shadowRadius = 1.0
contactLabel.layer.shadowOpacity = 0.2
timerLabel.layer.shadowColor = UIColor.black.cgColor
timerLabel.layer.shadowOffset = CGSize(width: 0, height: 0)
timerLabel.layer.shadowRadius = 1.0
timerLabel.layer.shadowOpacity = 0.2
let cameraSwitchButtonImage = UIImage(resource: .threemaArrowTriangle2CirclepathCameraCircle)
.applying(
pointSize: 40.0,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.white, .white, .white]
)
cameraSwitchButton.setImage(cameraSwitchButtonImage, for: .normal)
cameraSwitchButton.setImage(cameraSwitchButtonImage, for: .selected)
cameraSwitchButton.setImage(cameraSwitchButtonImage, for: .highlighted)
let cellularWarningButtonImage = UIImage(resource: .threemaWifiExclamationmarkCircleFill)
.applying(
pointSize: 40.0,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.systemRed, .white, .white]
)
cellularWarningButton.setImage(cellularWarningButtonImage, for: .normal)
cellularWarningButton.setImage(cellularWarningButtonImage, for: .selected)
cellularWarningButton.setImage(cellularWarningButtonImage, for: .highlighted)
let hideButtonImage = UIImage(systemName: "chevron.left")?
.applying(
pointSize: 28,
symbolWeight: .semibold,
symbolScale: .large,
paletteColors: [.white]
)
hideButton.imageView?.contentMode = .scaleAspectFit
hideButton.setImage(hideButtonImage, for: .normal)
hideButton.setImage(hideButtonImage, for: .selected)
hideButton.setImage(hideButtonImage, for: .highlighted)
hideButton.layer.cornerRadius = hideButton.frame.width / 2
hideButton.layer.shadowColor = UIColor.black.cgColor
hideButton.layer.shadowOffset = CGSize(width: 0, height: 0)
hideButton.layer.shadowRadius = 1.0
hideButton.layer.shadowOpacity = 0.2
hideButton.layer.masksToBounds = false
if isTesting {
setupForIncomingCallTest()
}
}
private func showProfilePicture(contact: ContactEntity) {
profilePictureView.isHidden = false
}
private func updateView() {
if isTesting == false {
DispatchQueue.main.async {
if VoIPCallStateManager.shared.currentCallState() == .microphoneDisabled {
self.endButton?.isHidden = true
self.acceptButton?.isHidden = false
self.rejectButton?.isHidden = false
self.muteButton?.isHidden = true
self.phoneButtonsGradientView?.isHidden = true
self.speakerButton?.isHidden = true
self.cameraButton?.isHidden = true
self.cameraSwitchButton?.isHidden = true
self.localVideoView?.isHidden = true
self.remoteVideoView?.isHidden = true
self.endButton?.isEnabled = false
self.acceptButton?.isEnabled = false
self.rejectButton?.isEnabled = false
self.muteButton?.isEnabled = false
self.speakerButton?.isEnabled = false
self.cameraButton?.isEnabled = false
self.cameraSwitchButton?.isEnabled = false
self.voIPCallStatusChanged(state: .microphoneDisabled, oldState: .microphoneDisabled)
}
else {
self.endButton?.isHidden = !self.callStartedBySelf && !self.alreadyAccepted
self.acceptButton?.isHidden = self.callStartedBySelf || self.alreadyAccepted
self.rejectButton?.isHidden = self.callStartedBySelf || self.alreadyAccepted
self.muteButton?.isHidden = !self.callStartedBySelf && !self.alreadyAccepted
self.phoneButtonsGradientView?.isHidden = !self.callStartedBySelf && !self.alreadyAccepted
self.phoneButtonsGradientView?.isHidden = !self.callStartedBySelf && !self.alreadyAccepted
self.speakerButton?.isHidden = !self.callStartedBySelf && !self.alreadyAccepted
self.endButton?.isEnabled = true
self.acceptButton?.isEnabled = true
self.muteButton?.isEnabled = true
self.speakerButton?.isEnabled = true
self.updateVideoViews()
}
}
}
}
private func updateVideoViews() {
if contact != nil, threemaVideoCallAvailable == true {
let cameraButtonImage =
UIImage(named: isLocalVideoActive ? "threema.video.circle.fill" : "threema.video.circle")?
.applying(
pointSize: 40,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.white, .white, .white]
)
cameraButton?.setImage(cameraButtonImage, for: .normal)
cameraButton?.setImage(cameraButtonImage, for: .selected)
cameraButton?.setImage(cameraButtonImage, for: .highlighted)
cameraButton?
.accessibilityLabel = isLocalVideoActive ? #localize("call_camera_deactivate_button") :
#localize("call_camera_activate_button")
cameraButton?.alpha = 1.0
cameraButton?.isHidden = !callStartedBySelf && !alreadyAccepted
cameraSwitchButton?
.isHidden =
!(
isLocalVideoActive &&
(
AVCaptureDevice
.default(.builtInWideAngleCamera, for: AVMediaType.video, position: .back) != nil
)
)
cameraButton?.isEnabled = true
cameraSwitchButton?.isEnabled = true
localVideoView?.isHidden = !(isLocalVideoActive && isReceivingRemoteVideo)
remoteVideoView?.isHidden = !(isLocalVideoActive || isReceivingRemoteVideo)
}
else {
if alreadyAccepted, UserSettings.shared().enableVideoCall {
let cameraButtonImage =
UIImage(named: isLocalVideoActive ? "threema.video.circle.fill" : "threema.video.circle")?
.applying(
pointSize: 40,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.systemGray, .systemGray, .systemGray]
)
cameraButton?.setImage(cameraButtonImage, for: .normal)
cameraButton?.setImage(cameraButtonImage, for: .selected)
cameraButton?.setImage(cameraButtonImage, for: .highlighted)
cameraButton?.accessibilityLabel = #localize("call_camera_deactivate_button")
cameraButton?.isHidden = callStartedBySelf && !UserSettings.shared().enableVideoCall
cameraButton?.alpha = 0.9
}
else {
cameraButton?.alpha = 1.0
cameraButton?.isHidden = true
}
localVideoView?.isHidden = true
remoteVideoView?.isHidden = true
cameraSwitchButton?.isHidden = true
cameraButton?.isEnabled = true
cameraSwitchButton?.isEnabled = false
}
}
private func setupForIncomingCallTest() {
DispatchQueue.main.async {
self.endButton.isHidden = true
self.acceptButton.isHidden = false
self.rejectButton.isHidden = false
self.muteButton.isHidden = true
self.speakerButton.isHidden = true
self.timerLabel.isHidden = false
self.cameraButton.isHidden = true
self.cameraSwitchButton.isHidden = true
self.debugLabel.isHidden = true
self.cellularWarningButton.isHidden = true
self.timerLabel.text = String.localizedStringWithFormat(
#localize("call_status_incom_ringing"),
TargetManager.localizedAppName
)
}
}
private func setupForConnectedCallTest() {
debugLabel.isHidden = true
endButton.isHidden = false
acceptButton.isHidden = true
rejectButton.isHidden = true
muteButton.isHidden = false
speakerButton.isHidden = false
timerLabel.isHidden = false
cameraButton.isHidden = false
cameraSwitchButton.isHidden = true
cellularWarningButton.isHidden = true
endButton?.isEnabled = true
muteButton?.isEnabled = true
speakerButton?.isEnabled = true
cameraButton?.isEnabled = true
cameraSwitchButton.isEnabled = true
DispatchQueue.main.async {
self.timerLabel.text = "12:12"
}
let cameraButtonImage =
UIImage(named: isLocalVideoActive ? "threema.video.circle.fill" : "threema.video.circle")?
.applying(
pointSize: 40,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.white, .white, .white]
)
cameraButton?.setImage(cameraButtonImage, for: .normal)
cameraButton?.setImage(cameraButtonImage, for: .selected)
cameraButton?.setImage(cameraButtonImage, for: .highlighted)
}
private func setupForVideoCallTest() {
debugLabel.isHidden = true
endButton.isHidden = false
acceptButton.isHidden = true
rejectButton.isHidden = true
muteButton.isHidden = false
speakerButton.isHidden = false
timerLabel.isHidden = false
cameraButton.isHidden = false
cameraButton.isSelected = true
cameraSwitchButton.isHidden = false
cellularWarningButton.isHidden = true
endButton?.isEnabled = true
muteButton?.isEnabled = true
speakerButton?.isEnabled = true
cameraButton?.isEnabled = true
cameraSwitchButton.isEnabled = true
let cameraButtonImage = UIImage(resource: .threemaVideoCircleFill)
.applying(
pointSize: 40,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.white, .white, .white]
)
cameraButton?.setImage(cameraButtonImage, for: .normal)
cameraButton?.setImage(cameraButtonImage, for: .selected)
cameraButton?.setImage(cameraButtonImage, for: .highlighted)
DispatchQueue.main.async {
self.timerLabel.text = "12:12"
}
speakerButton.setImage(
UIImage(systemName: "speaker.wave.2.circle.fill")?
.applying(
pointSize: 36.0,
symbolWeight: .thin,
symbolScale: .large,
paletteColors: [.black, .black, .white]
),
for: .normal
)
localVideoView?.isHidden = false
remoteVideoView?.isHidden = false
let meImage = MyIdentityStore.shared().resolvedProfilePicture
let meImageView = UIImageView(image: meImage)
meImageView.contentMode = .scaleAspectFill
embedView(meImageView, into: localVideoView)
let remoteImageView = UIImageView()
if let contact {
let businessContact = Contact(contactEntity: contact)
remoteImageView.image = businessContact.profilePicture
}
remoteImageView.contentMode = .scaleAspectFill
embedView(remoteImageView, into: remoteVideoView)
}
private func startLocalVideo(useBackCamera: Bool = false, switchCamera: Bool = false) {
DispatchQueue.main.async {
self.profilePictureView.isHidden = true
let localRenderer = RTCMTLVideoView(frame: self.localVideoView?.frame ?? CGRect.zero)
localRenderer.videoContentMode = .scaleAspectFill
localRenderer.delegate = self
self.delegate?.startCaptureLocalVideo(
renderer: localRenderer,
useBackCamera: useBackCamera,
switchCamera: switchCamera
)
if !self.isReceivingRemoteVideo {
if let remoteVideoView = self.remoteVideoView {
self.embedView(localRenderer, into: remoteVideoView)
self.flipLocalRenderer()
self.updateVideoViews()
if switchCamera == false {
self.activateSpeakerForVideo()
}
}
}
else {
if let localVideoView = self.localVideoView {
if self.isRemoteRendererInRemoteView() {
self.embedView(localRenderer, into: localVideoView)
}
else {
self.embedView(localRenderer, into: self.remoteVideoView)
}
self.flipLocalRenderer()
self.updateVideoViews()
if switchCamera == false {
self.activateSpeakerForVideo()
}
}
}
}
}
private func endLocalVideo(switchCamera: Bool = false) {
delegate?.endCaptureLocalVideo(switchCamera: switchCamera)
DispatchQueue.main.async {
if self.isReceivingRemoteVideo {
if let remoteRenderer = self.delegate?.remoteVideoRenderer() {
if self.localVideoView.subviews.first == remoteRenderer as? UIView {
if !switchCamera {
self.moveEmbedView(
remoteRenderer as! UIView,
from: self.localVideoView,
into: self.remoteVideoView
)
}
else {
self.removeSubviewsFromRemoteView()
}
}
else if self.remoteVideoView.subviews.first == remoteRenderer as? UIView {
self.removeSubviewsFromLocalView()
}
}
}
else {
if let contact = self.contact {
self.showProfilePicture(contact: contact)
}
self.removeAllSubviewsFromVideoViews()
}
self.flipLocalRenderer()
self.updateVideoViews()
}
}
private func startRemoteVideo() {
func createNewRemoteVideoView() -> RTCMTLVideoView {
let remoteRenderer = RTCMTLVideoView(frame: remoteVideoView?.frame ?? CGRect.zero)
remoteRenderer.videoContentMode = .scaleAspectFill
remoteRenderer.delegate = self
delegate?.renderRemoteVideo(to: remoteRenderer)
DDLogNotice("[Call Debug] Starting remote video with frame: \(remoteVideoView?.frame ?? .zero)")
return remoteRenderer
}
DispatchQueue.main.async {
if self.viewIfLoaded?.window != nil {
self.profilePictureView.isHidden = true
let remoteRenderer = self.delegate?
.remoteVideoRenderer() as? RTCMTLVideoView ?? createNewRemoteVideoView()
if !self.isLocalVideoActive || self.localVideoView.subviews.first == self.delegate?
.localVideoRenderer() as? UIView {
if let remoteVideoView = self.remoteVideoView,
remoteVideoView.subviews.first != remoteRenderer {
self.embedView(remoteRenderer, into: remoteVideoView)
self.updateVideoViews()
}
}
else {
if let localRenderer = self.delegate?.localVideoRenderer() as? UIView,
let remoteVideoView = self.remoteVideoView,
localRenderer != self.localVideoView.subviews.first || remoteRenderer != remoteVideoView
.subviews.first,
remoteRenderer != self.localVideoView.subviews.first || localRenderer != remoteVideoView
.subviews.first {
self.moveEmbedView(
localRenderer,
from: self.remoteVideoView,
into: self.localVideoView
)
self.embedView(remoteRenderer, into: remoteVideoView)
self.flipLocalRenderer()
self.updateVideoViews()
}
else if let localRenderer = self.delegate?.localVideoRenderer() as? UIView,
let remoteVideoView = self.remoteVideoView,
let localVideoView = self.localVideoView {
if localVideoView.subviews.first == localRenderer {
self.embedView(remoteRenderer, into: remoteVideoView)
}
else {
self.embedView(remoteRenderer, into: localVideoView)
}
self.updateVideoViews()
}
}
}
}
}
private func endRemoteVideo() {
delegate?.endRemoteVideo()
DispatchQueue.main.async {
if self.viewIfLoaded?.window != nil {
if self.isLocalVideoActive {
self.moveOrRemoveLocalRenderer()
}
else {
if let contact = self.contact {
self.showProfilePicture(contact: contact)
}
self.removeAllSubviewsFromVideoViews()
}
self.flipLocalRenderer()
self.updateVideoViews()
}
}
}
private func moveOrRemoveLocalRenderer() {
if let localRenderer = delegate?.localVideoRenderer() {
if localVideoView.subviews.first == localRenderer as? UIView {
moveEmbedView(localRenderer as! UIView, from: localVideoView, into: remoteVideoView)
}
else if remoteVideoView.subviews.first == localRenderer as? UIView {
removeSubviewsFromLocalView()
}
}
}
private func embedView(_ view: UIView, into containerView: UIView) {
containerView.subviews.forEach { $0.removeFromSuperview() }
containerView.addSubview(view)
view.translatesAutoresizingMaskIntoConstraints = false
view.layer.masksToBounds = true
containerView.leftAnchor.constraint(equalTo: view.leftAnchor).isActive = true
containerView.rightAnchor.constraint(equalTo: view.rightAnchor).isActive = true
containerView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
containerView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
containerView.layoutIfNeeded()
}
private func moveEmbedView(_ view: UIView, from fromContainerView: UIView, into containerView: UIView) {
removeAllSubviewsFromVideoViews()
embedView(view, into: containerView)
}
private func switchCamera() {
useBackCamera = !useBackCamera
cameraSwitchButton
.accessibilityLabel = useBackCamera ? #localize("call_camera_switch_to_front_button") :
#localize("call_camera_switch_to_back_button")
endLocalVideo(switchCamera: true)
startLocalVideo(useBackCamera: useBackCamera, switchCamera: true)
}
private func isLocalRendererInLocalView() -> Bool {
if let localRenderer = delegate?.localVideoRenderer() as? UIView {
if localVideoView.subviews.first == localRenderer {
return true
}
}
return false
}
private func isLocalRendererInRemoteView() -> Bool {
if let localRenderer = delegate?.localVideoRenderer() as? UIView {
if remoteVideoView.subviews.first == localRenderer {
return true
}
}
return false
}
private func isRemoteRendererInRemoteView() -> Bool {
if let remoteRenderer = delegate?.remoteVideoRenderer() as? UIView {
if remoteVideoView.subviews.first == remoteRenderer {
return true
}
}
return false
}
private func isRemoteRendererInLocalView() -> Bool {
if let remoteRenderer = delegate?.remoteVideoRenderer() as? UIView {
if localVideoView.subviews.first == remoteRenderer {
return true
}
}
return false
}
private func removeSubviewsFromLocalView() {
guard localVideoView != nil else {
return
}
guard !localVideoView.subviews.isEmpty else {
return
}
localVideoView.subviews.forEach { $0.removeFromSuperview() }
}
private func removeSubviewsFromRemoteView() {
guard remoteVideoView != nil else {
return
}
guard !remoteVideoView.subviews.isEmpty else {
return
}
remoteVideoView.subviews.forEach { $0.removeFromSuperview() }
}
private func removeAllSubviewsFromVideoViews() {
removeSubviewsFromLocalView()
removeSubviewsFromRemoteView()
// show navigation if needed
if !isNavigationVisible() {
moveLocalVideoViewToCorrectPosition(moveNavigation: true)
}
}
private func flipLocalRenderer() {
if useBackCamera == false {
if isLocalRendererInLocalView() {
if localVideoView != nil {
localVideoView.layer.setAffineTransform(CGAffineTransform(scaleX: -1, y: 1))
}
}
else {
if localVideoView != nil {
localVideoView.layer.setAffineTransform(CGAffineTransform(scaleX: 1, y: 1))
}
}
if isLocalRendererInRemoteView() {
if remoteVideoView != nil {
remoteVideoView.layer.setAffineTransform(CGAffineTransform(scaleX: -1, y: 1))
}
}
else {
if remoteVideoView != nil {
remoteVideoView.layer.setAffineTransform(CGAffineTransform(scaleX: 1, y: 1))
}
}
}
else {
if localVideoView != nil {
localVideoView.layer.setAffineTransform(CGAffineTransform(scaleX: 1, y: 1))
}
if remoteVideoView != nil {
remoteVideoView.layer.setAffineTransform(CGAffineTransform(scaleX: 1, y: 1))
}
}
}
private func hasCameraAccess() -> Bool {
let access = AVCaptureDevice.authorizationStatus(for: .video)
if access == .authorized {
return true
}
else if access == .denied || access == .restricted {
showCameraAccessAlert()
}
else if access == .notDetermined {
AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) in
if granted {
self.isLocalVideoActive = !self.isLocalVideoActive
if self.isLocalVideoActive == true {
self.startLocalVideo()
}
else {
self.endLocalVideo()
}
}
else {
self.showCameraAccessAlert()
}
})
}
return false
}
private func showCameraAccessAlert() {
// Show access prompt
DispatchQueue.main.async {
UIAlertTemplate.showOpenSettingsAlert(
owner: self,
noAccessAlertType: .camera
)
}
}
private func activateSpeakerForVideo() {
guard let callID = delegate?.currentCallID() else {
DDLogWarn("VoipCallService: [cid=null)]: Activate speaker for video failed due to missing callID")
return
}
for output in currentAudioRoute?.outputs ?? [] {
if output == AVAudioSession.Port.builtInReceiver {
let action = VoIPCallUserAction(
action: .speakerOn,
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
VoIPCallStateManager.shared.addActionToCallQueue(action)
}
}
}
private func isNavigationVisible() -> Bool {
guard let navigationConstraint = phoneButtonsStackViewBottomConstraint else {
return true
}
return navigationConstraint.constant == 0.0
}
private func removeAllLocalVideoViewConstraints() {
localVideoViewConstraintLeft.isActive = false
localVideoViewConstraintRight.isActive = false
localVideoViewConstraintBottom.isActive = false
localVideoViewConstraintBottomNavigation.isActive = false
localVideoViewConstraintTop.isActive = false
localVideoViewConstraintTopNavigation.isActive = false
localVideoViewConstraintTopNavigationLabel.isActive = false
view.layoutIfNeeded()
}
private func moveLocalVideoViewToCorrectPosition(moveNavigation: Bool = false) {
let localVideoViewCenterX = localVideoView.center.x
let localVideoViewCenterY = localVideoView.center.y
let screenMiddleX = view.frame.size.width / 2
let screenMiddleY = view.frame.size.height / 2
removeAllLocalVideoViewConstraints()
guard let _ = phoneButtonsStackViewBottomConstraint,
let _ = callInfoStackViewTopConstraint,
let _ = phoneButtonsStackView,
let _ = callInfoStackView,
let phoneButtonsStackViewSuperView = phoneButtonsStackView.superview,
let callInfoStackViewSuperView = callInfoStackView.superview else {
return
}
if moveNavigation {
if isNavigationVisible() {
phoneButtonsStackViewBottomConstraint.constant = phoneButtonsStackView.frame.size
.height + phoneButtonsStackViewSuperView.layoutMargins.bottom + 50.0
callInfoStackViewTopConstraint
.constant = -(
callInfoStackView.frame.size.height + callInfoStackViewSuperView
.layoutMargins.top + 50.0
)
}
else {
phoneButtonsStackViewBottomConstraint.constant = 0
callInfoStackViewTopConstraint.constant = 16.0
}
}
if localVideoViewCenterX < screenMiddleX, localVideoViewCenterY < screenMiddleY {
addConstraintToTopLeftForLocalVideoView()
}
else if localVideoViewCenterX > screenMiddleX, localVideoViewCenterY < screenMiddleY {
addConstraintToTopRightForLocalVideoView()
}
else if localVideoViewCenterX < screenMiddleX, localVideoViewCenterY > screenMiddleY {
addConstraintToBottomLeftForLocalVideoView()
}
else if localVideoViewCenterX > screenMiddleX, localVideoViewCenterY > screenMiddleY {
addConstraintToBottomRightForLocalVideoView()
}
UIView.animate(withDuration: 0.35, animations: {
self.phoneButtonsGradientView.setNeedsLayout()
self.phoneButtonsGradientView.layoutIfNeeded()
self.callInfoGradientView.setNeedsLayout()
self.callInfoGradientView.layoutIfNeeded()
self.view.layoutIfNeeded()
})
}
private func addConstraintToTopLeftForLocalVideoView() {
localVideoViewConstraintLeft.isActive = true
if isNavigationVisible() {
localVideoViewConstraintTopNavigationLabel.isActive = true
}
else {
localVideoViewConstraintTop.isActive = true
}
}
private func addConstraintToTopRightForLocalVideoView() {
localVideoViewConstraintRight.isActive = true
if isNavigationVisible() {
localVideoViewConstraintTopNavigation.isActive = true
}
else {
localVideoViewConstraintTop.isActive = true
}
}
private func addConstraintToBottomLeftForLocalVideoView() {
localVideoViewConstraintLeft.isActive = true
if isNavigationVisible() {
localVideoViewConstraintBottomNavigation.isActive = true
}
else {
localVideoViewConstraintBottom.isActive = true
}
}
private func addConstraintToBottomRightForLocalVideoView() {
localVideoViewConstraintRight.isActive = true
if isNavigationVisible() {
localVideoViewConstraintBottomNavigation.isActive = true
}
else {
localVideoViewConstraintBottom.isActive = true
}
}
private func updateConstraintsAfterRotation(size: CGSize?) {
if isLocalRendererInLocalView() || isRemoteRendererInLocalView() {
var maxSize: CGFloat = 100
var newSize = size ?? CGSize(width: maxSize, height: 134.0)
if traitCollection.horizontalSizeClass == .regular {
maxSize = 200
newSize = size ?? CGSize(width: maxSize, height: 268.0)
}
if UIDevice.current.orientation == .landscapeLeft || UIDevice.current.orientation == .landscapeRight {
if traitCollection.horizontalSizeClass == .regular {
maxSize = 270.0
}
else {
maxSize = 150.0
}
}
let ratio = newSize.width / maxSize
localVideoViewConstraintHeight.constant = newSize.height / ratio
localVideoViewConstraintWidth.constant = newSize.width / ratio
view.layoutIfNeeded()
}
}
private func checkAndHandleAvailableBluetoothDevices() {
let bluetoothAvailable = currentAudioRoute?.hasBluetoothInput ?? false
if bluetoothAvailable {
if myVolumeView == nil {
myVolumeView =
AVRoutePickerView(frame: CGRect(
x: 0.0,
y: 0.0,
width: speakerButton.frame.size.width,
height: speakerButton.frame.size.height
))
(myVolumeView as! AVRoutePickerView).activeTintColor = UIColor.clear
(myVolumeView as! AVRoutePickerView).tintColor = UIColor.clear
(myVolumeView as! AVRoutePickerView).isOpaque = true
(myVolumeView as! AVRoutePickerView).alpha = 1.0
(myVolumeView as! AVRoutePickerView).delegate = self
if isLocalVideoActive || isReceivingRemoteVideo {
(myVolumeView as! AVRoutePickerView).prioritizesVideoDevices = true
}
speakerButton.addSubview(myVolumeView!)
}
}
else {
for subview in speakerButton.subviews {
if subview == myVolumeView {
subview.removeFromSuperview()
}
}
myVolumeView = nil
}
}
private func addGradientToView(
view: UIView,
startColor: UIColor,
middleColor: UIColor,
endColor: UIColor,
locations: [NSNumber]
) {
let gradientLayerMask = CAGradientLayer()
gradientLayerMask.colors = [startColor.cgColor, middleColor.cgColor, endColor.cgColor]
gradientLayerMask.locations = locations
gradientLayerMask.frame = view.bounds
view.layer.insertSublayer(gradientLayerMask, at: 0)
view.backgroundColor = .clear
}
private func updateGradientBackground() {
callInfoGradientView.setNeedsLayout()
callInfoGradientView.layoutIfNeeded()
phoneButtonsGradientView.setNeedsLayout()
phoneButtonsGradientView.layoutIfNeeded()
callInfoGradientView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
phoneButtonsGradientView.layer.sublayers?.forEach { $0.removeFromSuperlayer() }
addGradientToView(
view: callInfoGradientView,
startColor: UIColor.black.withAlphaComponent(0.2),
middleColor: UIColor.black.withAlphaComponent(0.1),
endColor: UIColor.white.withAlphaComponent(0.0),
locations: [0, 0.7, 1]
)
addGradientToView(
view: phoneButtonsGradientView,
startColor: UIColor.white.withAlphaComponent(0.0),
middleColor: UIColor.black.withAlphaComponent(0.1),
endColor: UIColor.black.withAlphaComponent(0.2),
locations: [0, 0.3, 1]
)
}
private func updateRemoteVideoContentMode(videoView: RTCVideoRenderer) {
if let remoteRenderer = delegate?.remoteVideoRenderer() as? RTCMTLVideoView,
remoteRenderer.isEqual(videoView) {
if UIApplication.shared.statusBarOrientation.isPortrait,
isRemoteVideoPortrait {
remoteRenderer.videoContentMode = .scaleAspectFill
}
else {
remoteRenderer.videoContentMode = .scaleAspectFit
}
}
}
private func updateAccessibilityLabels() {
muteButton.accessibilityLabel = muteButton.isSelected ? #localize("call_unmute") : #localize("call_mute")
speakerButton.accessibilityLabel = speakerButton
.tag == 1 ? #localize("call_earpiece") : #localize("call_speaker")
endButton.accessibilityLabel = #localize("call_end")
acceptButton.accessibilityLabel = #localize("call_accept")
rejectButton.accessibilityLabel = #localize("call_reject")
hideButton.accessibilityLabel = #localize("call_hide_call")
cameraButton
.accessibilityLabel = isLocalVideoActive ? #localize("call_camera_deactivate_button") :
#localize("call_camera_activate_button")
cameraSwitchButton.accessibilityLabel = #localize("call_camera_switch_to_back_button")
}
/// It will play the problem sound
private func playCellularCallWarningSound() {
audioPlayer?.stop()
do {
let soundFilePath = BundleUtil.path(forResource: "threema_problem", ofType: "mp3")
let soundURL = URL(fileURLWithPath: soundFilePath!)
let player = try AVAudioPlayer(contentsOf: soundURL, fileTypeHint: AVFileType.mp3.rawValue)
player.numberOfLoops = 2
audioPlayer = player
player.play()
}
catch {
print(error.localizedDescription)
}
}
}
extension CallViewController {
// MARK: Actions
@objc private func handleLongPress(gestureRecognizer: UIGestureRecognizer) {
if gestureRecognizer.state == .began {
// Toggle debug label
debugLabel.isHidden = !debugLabel.isHidden
if debugLabel.isHidden == true {
debugLabel.text = ""
}
}
updateViewConstraints()
}
@IBAction func hideView(sender: UIButton) {
if isTesting == true {
setupForConnectedCallTest()
}
else {
NavigationBarPromptHandler.isCallActiveInBackground = true
NavigationBarPromptHandler.name = contact?.displayName
NotificationCenter.default.post(
name: NSNotification.Name(rawValue: kNotificationNavigationItemPromptShouldChange),
object: nil
)
dismiss(animated: true) {
VoIPCallStateManager.shared.onCallViewHidden?()
if SharedAppProvider.isAppLocked {
SharedAppProvider.presentPasscodeView()
}
}
}
}
@IBAction func acceptAction(_ sender: UIButton, forEvent event: UIEvent) {
guard let callID = delegate?.currentCallID() else {
DDLogWarn("VoipCallService: [cid=null)]: Accept action failed due to missing callID")
return
}
let action = VoIPCallUserAction(
action: .accept,
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
VoIPCallStateManager.shared.addActionToCallQueue(action)
}
@IBAction func rejectAction(_ sender: UIButton, forEvent event: UIEvent) {
if isTesting == true {
dismiss(animated: true, completion: nil)
}
else {
guard let callID = delegate?.currentCallID() else {
DDLogWarn("VoipCallService: [cid=null)]: Reject action failed due to missing callID")
return
}
// User tapped end call. We send the reject message, so the other devices stop ringing.
let action = VoIPCallUserAction(
action: .reject(sendMessage: true),
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
VoIPCallStateManager.shared.addActionToCallQueue(action)
}
}
@IBAction func endAction(_ sender: UIButton, forEvent event: UIEvent) {
guard let callID = delegate?.currentCallID() else {
DDLogWarn("VoipCallService: [cid=null)]: Hangup action failed due to missing callID")
return
}
DDLogNotice(
"VoipCallService: [cid=\(callID)]: User pressed the hangup button"
)
let action = VoIPCallUserAction(
action: .end,
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
VoIPCallStateManager.shared.addActionToCallQueue(action)
}
@IBAction func muteAction(_ sender: UIButton, forEvent event: UIEvent) {
guard let callID = delegate?.currentCallID() else {
DDLogWarn("VoipCallService: [cid=null)]: Mute action failed due to missing callID")
return
}
let action = VoIPCallUserAction(
action: (delegate?.isCallMuted() ?? false) ? .unmuteAudio : .muteAudio,
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
muteButton.isSelected = action.action == .muteAudio
updateAccessibilityLabels()
VoIPCallStateManager.shared.addActionToCallQueue(action)
}
@IBAction func speakerAction(_ sender: UIButton, forEvent event: UIEvent) {
guard let callID = delegate?.currentCallID() else {
DDLogWarn("VoipCallService: [cid=null)]: Speaker action failed due to missing callID")
return
}
checkAndHandleAvailableBluetoothDevices()
for output in currentAudioRoute?.outputs ?? [] {
switch output {
case .builtInReceiver:
let action = VoIPCallUserAction(
action: .speakerOn,
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
VoIPCallStateManager.shared.addActionToCallQueue(action)
case .builtInSpeaker:
let action = VoIPCallUserAction(
action: .speakerOff,
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
VoIPCallStateManager.shared.addActionToCallQueue(action)
case .bluetoothA2DP, .bluetoothHFP, .bluetoothLE:
let action = VoIPCallUserAction(
action: .speakerOn,
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
VoIPCallStateManager.shared.addActionToCallQueue(action)
case .headphones:
let action = VoIPCallUserAction(
action: .speakerOn,
contactIdentity: contactIdentity!,
callID: callID,
completion: nil
)
VoIPCallStateManager.shared.addActionToCallQueue(action)
default:
break
}
}
updateAccessibilityLabels()
}
@IBAction func startVideoAction(_ sender: UIButton) {
if isTesting == true {
setupForVideoCallTest()
}
else {
if threemaVideoCallAvailable, hasCameraAccess() {
isLocalVideoActive = !isLocalVideoActive
if isLocalVideoActive == true {
startLocalVideo()
}
else {
endLocalVideo()
}
}
}
}
@IBAction func switchCameraAction(_ sender: UIButton, forEvent event: UIEvent) {
switchCamera()
}
@IBAction func showCellularWarningAction() {
UIAlertTemplate.showOKAlert(
owner: self,
title: #localize("call_threema_cellular_instead_of_wifi_title"),
message: #localize("call_threema_cellular_instead_of_wifi_text")
)
}
@objc private func switchVideoViews(gesture: UITapGestureRecognizer) {
DispatchQueue.main.async {
if self.isLocalRendererInLocalView(), self.isRemoteRendererInRemoteView() {
self.removeAllSubviewsFromVideoViews()
self.embedView(self.delegate?.remoteVideoRenderer()! as! UIView, into: self.localVideoView)
self.embedView(self.delegate?.localVideoRenderer() as! UIView, into: self.remoteVideoView)
self.flipLocalRenderer()
}
else if self.isLocalRendererInRemoteView(), self.isRemoteRendererInLocalView() {
self.removeAllSubviewsFromVideoViews()
self.embedView(self.delegate?.localVideoRenderer()! as! UIView, into: self.localVideoView)
self.embedView(self.delegate?.remoteVideoRenderer() as! UIView, into: self.remoteVideoView)
self.flipLocalRenderer()
}
}
}
@objc private func showHideNavigation(gesture: UITapGestureRecognizer) {
moveLocalVideoViewToCorrectPosition(moveNavigation: true)
}
@objc private func didPan(gesture: UIPanGestureRecognizer) {
guard let dragView = gesture.view else {
return
}
if gesture.state == .began {
dragView.center = gesture.location(in: view)
}
let newCenter: CGPoint = gesture.location(in: view)
let dX = newCenter.x - dragView.center.x
let dY = newCenter.y - dragView.center.y
dragView.center = CGPoint(x: dragView.center.x + dX, y: dragView.center.y + dY)
localVideoView.center = dragView.center
if gesture.state == .ended {
moveLocalVideoViewToCorrectPosition()
}
}
}
// MARK: - AVRoutePickerViewDelegate
extension CallViewController: AVRoutePickerViewDelegate {
func routePickerViewDidEndPresentingRoutes(_ routePickerView: AVRoutePickerView) {
checkAndHandleAvailableBluetoothDevices()
}
}
// MARK: - RTCVideoViewDelegate
extension CallViewController: RTCVideoViewDelegate {
func videoView(_ videoView: RTCVideoRenderer, didChangeVideoSize size: CGSize) {
isRemoteVideoPortrait = size.height > size.width
updateRemoteVideoContentMode(videoView: videoView)
if let remoteRenderer = delegate?.remoteVideoRenderer() as? RTCMTLVideoView,
remoteRenderer.isEqual(videoView) {
DDLogNotice("[Call Debug] Remote Video view did change size: \(size))")
}
if let localRenderer = delegate?.localVideoRenderer(),
localRenderer.isEqual(videoView) {
updateConstraintsAfterRotation(size: size)
}
}
}
¤ Dauer der Verarbeitung: 0.16 Sekunden
(vorverarbeitet am 2026-11-04)
¤
*© Formatika GbR, Deutschland
|
|
2026-11-01
|
|
|
|
|
Neuigkeiten |
| Aktuelles |
| Motto des Tages |
|
Open Source Software |
|
|
|
Jenseits des Üblichen ....
|
|
Besucherstatistik |
|
|
| Statistik |
| #Sources=1104857 |
| #Domains=418251 |
|
|