diff --git a/Signal/src/Calls/Individual/IndividualCall.swift b/Signal/src/Calls/Individual/IndividualCall.swift index 62b61eb657..681805ac60 100644 --- a/Signal/src/Calls/Individual/IndividualCall.swift +++ b/Signal/src/Calls/Individual/IndividualCall.swift @@ -194,6 +194,34 @@ public class IndividualCall: NSObject { } } + /// `JoinState` is a group call concept, but we want to bridge + /// between the two call types. + /// TODO: Continue to tweak this as we unify the individual and + /// group call UIs. + var joinState: JoinState { + switch self.state { + case .idle, + .remoteHangup, + .remoteHangupNeedPermission, + .localHangup, + .remoteRinging, + .localRinging_Anticipatory, + .localRinging_ReadyToAnswer, + .remoteBusy, + .localFailure, + .busyElsewhere, + .answeredElsewhere, + .declinedElsewhere: + return .notJoined + case .connected, + .accepting, + .answering, + .reconnecting, + .dialing: + return .joined + } + } + public var offerMediaType: TSRecentCallOfferType = .audio // We start out muted if the record permission isn't granted. This should generally diff --git a/Signal/src/Calls/SignalCall.swift b/Signal/src/Calls/SignalCall.swift index 69c88f259c..b0ed63a757 100644 --- a/Signal/src/Calls/SignalCall.swift +++ b/Signal/src/Calls/SignalCall.swift @@ -119,6 +119,27 @@ public class SignalCall: NSObject, CallManagerCallReference { } } + public var isOutgoingVideoMuted: Bool { + switch mode { + case .individual(let call): return !call.hasLocalVideo + case .group(let call): return call.isOutgoingVideoMuted + } + } + + public var joinState: JoinState { + switch mode { + case .individual(let call): return call.joinState + case .group(let call): return call.localDeviceState.joinState + } + } + + public var canJoin: Bool { + switch mode { + case .individual(_): return true + case .group(let call): return !call.isFull + } + } + /// Returns the remote party for an incoming 1:1 call, or the ringer for a group call ring. /// /// Returns `nil` for an outgoing 1:1 call, a manually-entered group call, @@ -165,8 +186,16 @@ public class SignalCall: NSObject, CallManagerCallReference { internal var ringRestrictions: RingRestrictions { didSet { AssertIsOnMainThread() - if ringRestrictions != oldValue && groupCall.localDeviceState.joinState == .notJoined { + if + isGroupCall, + ringRestrictions != oldValue, + joinState == .notJoined + { // Use a fake local state change to refresh the call controls. + // + // If we ever introduce ringing restrictions for 1:1 calls, + // a similar affordance will be needed to refresh the call + // controls. self.groupCall(onLocalDeviceStateChanged: groupCall) } } diff --git a/Signal/src/Calls/UserInterface/CallControls.swift b/Signal/src/Calls/UserInterface/CallControls.swift index 10ee5aca8c..69a274a7a3 100644 --- a/Signal/src/Calls/UserInterface/CallControls.swift +++ b/Signal/src/Calls/UserInterface/CallControls.swift @@ -19,6 +19,7 @@ protocol CallControlsDelegate: AnyObject { } class CallControls: UIView { + private lazy var topStackView = createTopStackView() private lazy var hangUpButton: CallButton = { let button = createButton( iconName: "phone-down-fill-28", @@ -90,19 +91,21 @@ class CallControls: UIView { return view }() - private lazy var topStackView = createTopStackView() - private weak var delegate: CallControlsDelegate! - private let call: SignalCall + private let viewModel: CallControlsViewModel - init(call: SignalCall, delegate: CallControlsDelegate) { - self.call = call + init( + call: SignalCall, + callService: CallService, + delegate: CallControlsDelegate + ) { + self.viewModel = CallControlsViewModel(call: call, callService: callService) self.delegate = delegate super.init(frame: .zero) - call.addObserverAndSyncState(observer: self) - - callService.audioService.delegate = self + viewModel.refreshView = { [weak self] in + self?.updateControls() + } addSubview(gradientView) gradientView.autoPinEdgesToSuperviewEdges() @@ -113,7 +116,10 @@ class CallControls: UIView { joinButton.autoPinWidthToSuperviewMargins(relation: .lessThanOrEqual) joinButton.autoPinHeightToSuperview() - let controlsStack = UIStackView(arrangedSubviews: [topStackView, joinButtonContainer]) + let controlsStack = UIStackView(arrangedSubviews: [ + topStackView, + joinButtonContainer + ]) controlsStack.axis = .vertical controlsStack.spacing = 40 controlsStack.alignment = .center @@ -129,11 +135,6 @@ class CallControls: UIView { updateControls() } - deinit { - call.removeObserver(self) - callService.audioService.delegate = nil - } - func createTopStackView() -> UIStackView { let stackView = UIStackView() stackView.axis = .horizontal @@ -150,99 +151,59 @@ class CallControls: UIView { } private func updateControls() { - let hasExternalAudioInputs = callService.audioService.hasExternalInputs - let isLocalVideoMuted = call.groupCall.isOutgoingVideoMuted - let joinState = call.groupCall.localDeviceState.joinState + // Top row + audioSourceButton.isHidden = viewModel.audioSourceButtonIsHidden + hangUpButton.isHidden = viewModel.hangUpButtonIsHidden + muteButton.isHidden = viewModel.muteButtonIsHidden + videoButton.isHidden = viewModel.videoButtonIsHidden + flipCameraButton.isHidden = viewModel.flipCameraButtonIsHidden + ringButton.isHidden = viewModel.ringButtonIsHidden - flipCameraButton.isHidden = isLocalVideoMuted - videoButton.isSelected = !isLocalVideoMuted - muteButton.isSelected = call.groupCall.isOutgoingAudioMuted + // Bottom row + joinButton.superview?.isHidden = viewModel.joinButtonIsHidden - ringButton.isHidden = joinState == .joined || call.ringRestrictions.intersects([.notApplicable, .callInProgress]) - // Leave the button visible but locked if joining, like the "join call" button. - ringButton.isUserInteractionEnabled = joinState == .notJoined - if call.ringRestrictions.isEmpty, case .shouldRing = call.groupCallRingState { - ringButton.isSelected = true - } else { - ringButton.isSelected = false - } - // Leave the button enabled so we can present an explanatory toast, but show it disabled. - ringButton.shouldDrawAsDisabled = !call.ringRestrictions.isEmpty - - hangUpButton.isHidden = joinState != .joined - - if !UIDevice.current.isIPad { - // Use small controls if video is enabled and we have external - // audio inputs, because we have five buttons now. - [audioSourceButton, flipCameraButton, videoButton, muteButton, ringButton, hangUpButton].forEach { - let isSmall = hasExternalAudioInputs && !isLocalVideoMuted - $0.isSmall = isSmall - if UIDevice.current.isNarrowerThanIPhone6 { - topStackView.spacing = isSmall ? 12 : 16 - } + // Sizing and spacing + let controlCount = topStackView.arrangedSubviews.filter({!$0.isHidden}).count + topStackView.spacing = viewModel.controlSpacing(controlCount: controlCount) + let shouldControlButtonsBeSmall = viewModel.shouldControlButtonsBeSmall(controlCount: controlCount) + for view in topStackView.arrangedSubviews { + if let button = view as? CallButton { + button.isSmall = shouldControlButtonsBeSmall } } - // Audio Source Handling - if hasExternalAudioInputs, let audioSource = callService.audioService.currentAudioSource { - audioSourceButton.showDropdownArrow = true - audioSourceButton.isHidden = false - - if audioSource.isBuiltInEarPiece { - audioSourceButton.iconName = "phone-fill-28" - } else if audioSource.isBuiltInSpeaker { - audioSourceButton.iconName = "speaker-fill-28" - } else { - audioSourceButton.iconName = "speaker-bt-fill-28" - } - } else if UIDevice.current.isIPad { - // iPad *only* supports speaker mode, if there are no external - // devices connected, so we don't need to show the button unless - // we have alternate audio sources. - audioSourceButton.isHidden = true - } else { - // If there are no external audio sources, and video is enabled, - // speaker mode is always enabled so we don't need to show the button. - audioSourceButton.isHidden = !isLocalVideoMuted - - // No bluetooth audio detected - audioSourceButton.iconName = "speaker-fill-28" - audioSourceButton.showDropdownArrow = false - } - // Show/hide the superview to adjust the containing stack. - joinButton.superview?.isHidden = joinState == .joined - gradientView.isHidden = joinState != .joined + gradientView.isHidden = viewModel.gradientViewIsHidden - if call.groupCall.isFull { - // Make the button look disabled, but don't actually disable it. - // We want to show a toast if the user taps anyway. - joinButton.setTitleColor(.ows_whiteAlpha40, for: .normal) - joinButton.adjustsImageWhenHighlighted = false + videoButton.isSelected = viewModel.videoButtonIsSelected + muteButton.isSelected = viewModel.muteButtonIsSelected - joinButton.setTitle( - OWSLocalizedString( - "GROUP_CALL_IS_FULL", - comment: "Text explaining the group call is full"), - for: .normal) + if !viewModel.audioSourceButtonIsHidden { + let config = viewModel.audioSourceButtonConfiguration + audioSourceButton.showDropdownArrow = config.showDropdownArrow + audioSourceButton.iconName = config.iconName + } - } else if joinState == .joining || joinState == .pending { - joinButton.isUserInteractionEnabled = false - joinButtonActivityIndicator.startAnimating() + if + !viewModel.ringButtonIsHidden, + let ringButtonConfig = viewModel.ringButtonConfiguration + { + ringButton.isUserInteractionEnabled = ringButtonConfig.isUserInteractionEnabled + ringButton.isSelected = ringButtonConfig.isSelected + ringButton.shouldDrawAsDisabled = ringButtonConfig.shouldDrawAsDisabled + } - joinButton.setTitle("", for: .normal) - - } else { - joinButton.setTitleColor(.white, for: .normal) - joinButton.adjustsImageWhenHighlighted = true - joinButton.isUserInteractionEnabled = true - joinButtonActivityIndicator.stopAnimating() - - let startCallText = OWSLocalizedString("GROUP_CALL_START_BUTTON", comment: "Button to start a group call") - let joinCallText = OWSLocalizedString("GROUP_CALL_JOIN_BUTTON", comment: "Button to join an ongoing group call") - - joinButton.setTitle(call.ringRestrictions.contains(.callInProgress) ? joinCallText : startCallText, - for: .normal) + if !viewModel.joinButtonIsHidden { + let joinButtonConfig = viewModel.joinButtonConfig + joinButton.setTitle(joinButtonConfig.label, for: .normal) + joinButton.setTitleColor(joinButtonConfig.color, for: .normal) + joinButton.adjustsImageWhenHighlighted = joinButtonConfig.adjustsImageWhenHighlighted + joinButton.isUserInteractionEnabled = joinButtonConfig.isUserInteractionEnabled + if viewModel.shouldJoinButtonActivityIndicatorBeAnimating { + joinButtonActivityIndicator.startAnimating() + } else { + joinButtonActivityIndicator.stopAnimating() + } } } @@ -260,31 +221,270 @@ class CallControls: UIView { } } -extension CallControls: CallObserver { +private class CallControlsViewModel { + private let call: SignalCall + private let callService: CallService + fileprivate var refreshView: (() -> Void)? + init(call: SignalCall, callService: CallService) { + self.call = call + self.callService = callService + call.addObserverAndSyncState(observer: self) + callService.audioService.delegate = self + } + + deinit { + call.removeObserver(self) + callService.audioService.delegate = nil + } + + private var hasExternalAudioInputsAndAudioSource: Bool { + let audioService = callService.audioService + return audioService.hasExternalInputs && audioService.currentAudioSource != nil + } + + var audioSourceButtonIsHidden: Bool { + if hasExternalAudioInputsAndAudioSource { + return false + } else if UIDevice.current.isIPad { + // iPad *only* supports speaker mode, if there are no external + // devices connected, so we don't need to show the button unless + // we have alternate audio sources. + return true + } else { + return !call.isOutgoingVideoMuted + } + } + + struct AudioSourceButtonConfiguration { + let showDropdownArrow: Bool + let iconName: String + } + + var audioSourceButtonConfiguration: AudioSourceButtonConfiguration { + let showDropdownArrow: Bool + let iconName: String + if + callService.audioService.hasExternalInputs, + let audioSource = callService.audioService.currentAudioSource + { + showDropdownArrow = true + if audioSource.isBuiltInEarPiece { + iconName = "phone-fill-28" + } else if audioSource.isBuiltInSpeaker { + iconName = "speaker-fill-28" + } else { + iconName = "speaker-bt-fill-28" + } + } else { + // No bluetooth audio detected + showDropdownArrow = false + iconName = "speaker-fill-28" + } + return AudioSourceButtonConfiguration(showDropdownArrow: showDropdownArrow, iconName: iconName) + } + + var hangUpButtonIsHidden: Bool { + switch call.mode { + case .individual(_): + return false + case .group(_): + return call.joinState != .joined + } + } + + var muteButtonIsHidden: Bool { + return false + } + + var videoButtonIsHidden: Bool { + return false + } + + var flipCameraButtonIsHidden: Bool { + return call.isOutgoingVideoMuted + } + + var joinButtonIsHidden: Bool { + switch call.mode { + case .individual(_): + // TODO: Introduce lobby for starting 1:1 video calls. + return true + case .group(let call): + return call.localDeviceState.joinState == .joined + } + } + + struct JoinButtonConfiguration { + let label: String + let color: UIColor + let adjustsImageWhenHighlighted: Bool + let isUserInteractionEnabled: Bool + } + + var joinButtonConfig: JoinButtonConfiguration { + if !call.canJoin { + // Make the button look disabled, but don't actually disable it. + // We want to show a toast if the user taps anyway. + return JoinButtonConfiguration( + label: OWSLocalizedString( + "GROUP_CALL_IS_FULL", + comment: "Text explaining the group call is full" + ), + color: .ows_whiteAlpha40, + adjustsImageWhenHighlighted: false, + isUserInteractionEnabled: true + ) + } else if call.joinState == .joining || call.joinState == .pending { + return JoinButtonConfiguration( + label: "", + color: .ows_whiteAlpha40, + adjustsImageWhenHighlighted: false, + isUserInteractionEnabled: false + ) + } else { + let startCallText = OWSLocalizedString( + "CALL_START_BUTTON", + comment: "Button to start a call" + ) + let label: String + switch call.mode { + case .individual(_): + // We only show a lobby for 1:1 calls when the call is being initiated. + // TODO: The work of adding the lobby for 1:1 calls in the unified call view + // controller (currently GroupCallViewController) is not yet complete. + label = startCallText + case .group(_): + let joinCallText = OWSLocalizedString( + "GROUP_CALL_JOIN_BUTTON", + comment: "Button to join an ongoing group call" + ) + label = call.ringRestrictions.contains(.callInProgress) ? joinCallText : startCallText + } + return JoinButtonConfiguration( + label: label, + color: .white, + adjustsImageWhenHighlighted: true, + isUserInteractionEnabled: true + ) + } + } + + var shouldJoinButtonActivityIndicatorBeAnimating: Bool { + return (call.joinState == .joining || call.joinState == .pending) && !joinButtonIsHidden + } + + var ringButtonIsHidden: Bool { + switch call.mode { + case .individual(_): + return true + case .group(_): + return call.joinState == .joined || call.ringRestrictions.intersects([.notApplicable, .callInProgress]) + } + } + + struct RingButtonConfiguration { + let isUserInteractionEnabled: Bool + let isSelected: Bool + let shouldDrawAsDisabled: Bool + } + + var ringButtonConfiguration: RingButtonConfiguration? { + switch call.mode { + case .individual(_): + // We never show the ring button for 1:1 calls. + return nil + case .group(_): + // Leave the button visible but locked if joining, like the "join call" button. + let isUserInteractionEnabled = call.joinState == .notJoined + let isSelected: Bool + if + call.ringRestrictions.isEmpty, + case .shouldRing = call.groupCallRingState + { + isSelected = true + } else { + isSelected = false + } + // Leave the button enabled so we can present an explanatory toast, but show it disabled. + let shouldDrawAsDisabled = !call.ringRestrictions.isEmpty + return RingButtonConfiguration( + isUserInteractionEnabled: isUserInteractionEnabled, + isSelected: isSelected, + shouldDrawAsDisabled: shouldDrawAsDisabled + ) + } + } + + var gradientViewIsHidden: Bool { + return call.joinState != .joined + } + + var videoButtonIsSelected: Bool { + return !call.isOutgoingVideoMuted + } + + var muteButtonIsSelected: Bool { + return call.isOutgoingAudioMuted + } + + func controlSpacing(controlCount: Int) -> CGFloat { + return (UIDevice.current.isNarrowerThanIPhone6 && controlCount > 4) ? 12 : 16 + } + + func shouldControlButtonsBeSmall(controlCount: Int) -> Bool { + return UIDevice.current.isIPad ? false : controlCount > 4 + } +} + +extension CallControlsViewModel: CallObserver { func groupCallLocalDeviceStateChanged(_ call: SignalCall) { owsAssertDebug(call.isGroupCall) - updateControls() + refreshView?() } func groupCallPeekChanged(_ call: SignalCall) { - updateControls() + refreshView?() } func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { - updateControls() + refreshView?() } func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) { - updateControls() + refreshView?() + } + + func individualCallStateDidChange(_ call: SignalCall, state: CallState) { + refreshView?() + } + + func individualCallLocalVideoMuteDidChange(_ call: SignalCall, isVideoMuted: Bool) { + refreshView?() + } + + func individualCallLocalAudioMuteDidChange(_ call: SignalCall, isAudioMuted: Bool) { + refreshView?() + } + + func individualCallHoldDidChange(_ call: SignalCall, isOnHold: Bool) { + refreshView?() + } + + func individualCallRemoteVideoMuteDidChange(_ call: SignalCall, isVideoMuted: Bool) { + refreshView?() + } + + func individualCallRemoteSharingScreenDidChange(_ call: SignalCall, isRemoteSharingScreen: Bool) { + refreshView?() } } -extension CallControls: CallAudioServiceDelegate { +extension CallControlsViewModel: CallAudioServiceDelegate { func callAudioServiceDidChangeAudioSession(_ callAudioService: CallAudioService) { - updateControls() + refreshView?() } func callAudioServiceDidChangeAudioSource(_ callAudioService: CallAudioService, audioSource: AudioSource?) { - updateControls() + refreshView?() } } diff --git a/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift b/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift index 7281137954..540c87d004 100644 --- a/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift +++ b/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift @@ -14,7 +14,7 @@ import SignalUI class GroupCallViewController: UIViewController { private let call: SignalCall private var groupCall: GroupCall { call.groupCall } - private lazy var callControls = CallControls(call: call, delegate: self) + private lazy var callControls = CallControls(call: call, callService: callService, delegate: self) private lazy var incomingCallControls = IncomingCallControls(video: true, delegate: self) private var incomingCallControlsConstraint: NSLayoutConstraint? private lazy var noVideoIndicatorView: UIStackView = createNoVideoIndicatorView() @@ -740,7 +740,7 @@ extension GroupCallViewController: CallViewControllerWindowReference { existingSheet.dismiss(animated: false) } - let startCallString = OWSLocalizedString("GROUP_CALL_START_BUTTON", comment: "Button to start a group call") + let startCallString = OWSLocalizedString("CALL_START_BUTTON", comment: "Button to start a call") let joinCallString = OWSLocalizedString("GROUP_CALL_JOIN_BUTTON", comment: "Button to join an ongoing group call") let continueCallString = OWSLocalizedString("GROUP_CALL_CONTINUE_BUTTON", comment: "Button to continue an ongoing group call") let leaveCallString = OWSLocalizedString("GROUP_CALL_LEAVE_BUTTON", comment: "Button to leave a group call") diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index f819d0fe53..f8728a2d4a 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -736,6 +736,9 @@ /* Call setup status label after outgoing call times out */ "CALL_SCREEN_STATUS_NO_ANSWER" = "No Answer"; +/* Button to start a call */ +"CALL_START_BUTTON" = "Start Call"; + /* 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' */ "CALL_STATUS_FORMAT" = "Signal %@"; @@ -2701,9 +2704,6 @@ /* Toast view text informing user about swiping to speaker view */ "GROUP_CALL_SPEAKER_VIEW_TOAST" = "Swipe up to change views"; -/* Button to start a group call */ -"GROUP_CALL_START_BUTTON" = "Start Call"; - /* Tooltip highlighting group calls. */ "GROUP_CALL_START_TOOLTIP" = "Tap here to start a group call";