diff --git a/Signal/src/Signal-Bridging-Header.h b/Signal/src/Signal-Bridging-Header.h index fac87a093..3ba8ccb85 100644 --- a/Signal/src/Signal-Bridging-Header.h +++ b/Signal/src/Signal-Bridging-Header.h @@ -17,6 +17,7 @@ #import "OWSLogger.h" #import "OWSWebRTCDataProtos.pb.h" #import "PhoneManager.h" +#import "PrivacySettingsTableViewController.h" #import "PropertyListPreferences.h" #import "PushManager.h" #import "RPAccountManager.h" diff --git a/Signal/src/environment/PropertyListPreferences.h b/Signal/src/environment/PropertyListPreferences.h index 83b2a5e22..e92426637 100644 --- a/Signal/src/environment/PropertyListPreferences.h +++ b/Signal/src/environment/PropertyListPreferences.h @@ -70,8 +70,13 @@ extern NSString *const PropertyListPreferencesKeyEnableDebugLog; - (BOOL)isCallKitEnabled; - (void)setIsCallKitEnabled:(BOOL)flag; +// Returns YES IFF isCallKitEnabled has been set by user. +- (BOOL)isCallKitEnabledSet; + - (BOOL)isCallKitPrivacyEnabled; - (void)setIsCallKitPrivacyEnabled:(BOOL)flag; +// Returns YES IFF isCallKitPrivacyEnabled has been set by user. +- (BOOL)isCallKitPrivacySet; #pragma mark direct call connectivity (non-TURN) diff --git a/Signal/src/environment/PropertyListPreferences.m b/Signal/src/environment/PropertyListPreferences.m index 4266c2818..c154edf9b 100644 --- a/Signal/src/environment/PropertyListPreferences.m +++ b/Signal/src/environment/PropertyListPreferences.m @@ -187,6 +187,12 @@ NSString *const PropertyListPreferencesKeyCallsHideIPAddress = @"CallsHideIPAddr [self setValueForKey:PropertyListPreferencesKeyCallKitEnabled toValue:@(flag)]; } +- (BOOL)isCallKitEnabledSet +{ + NSNumber *preference = [self tryGetValueForKey:PropertyListPreferencesKeyCallKitEnabled]; + return preference != nil; +} + - (BOOL)isCallKitPrivacyEnabled { NSNumber *preference = [self tryGetValueForKey:PropertyListPreferencesKeyCallKitPrivacyEnabled]; @@ -198,6 +204,12 @@ NSString *const PropertyListPreferencesKeyCallsHideIPAddress = @"CallsHideIPAddr [self setValueForKey:PropertyListPreferencesKeyCallKitPrivacyEnabled toValue:@(flag)]; } +- (BOOL)isCallKitPrivacySet +{ + NSNumber *preference = [self tryGetValueForKey:PropertyListPreferencesKeyCallKitPrivacyEnabled]; + return preference != nil; +} + #pragma mark direct call connectivity (non-TURN) // Allow callers to connect directly, when desirable, vs. enforcing TURN only proxy connectivity diff --git a/Signal/src/view controllers/CallViewController.swift b/Signal/src/view controllers/CallViewController.swift index 06a4a049b..3e92c2d3f 100644 --- a/Signal/src/view controllers/CallViewController.swift +++ b/Signal/src/view controllers/CallViewController.swift @@ -22,6 +22,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R var thread: TSContactThread! var call: SignalCall! + var hasDismissed = false // MARK: Views @@ -74,6 +75,18 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R } } + // MARK: Settings Nag Views + + var isShowingSettingsNag = false { + didSet { + if oldValue != isShowingSettingsNag { + updateCallUI(callState: call.state) + } + } + } + var settingsNagView: UIView! + var settingsNagDescriptionLabel: UILabel! + // MARK: Initializers required init?(coder aDecoder: NSCoder) { @@ -160,6 +173,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R createContactViews() createOngoingCallControls() createIncomingCallControls() + createSettingsNagViews() } func didTouchRootView(sender: UIGestureRecognizer) { @@ -200,6 +214,62 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R self.view.addSubview(contactAvatarView) } + func createSettingsNagViews() { + settingsNagView = UIView() + settingsNagView.isHidden = true + self.view.addSubview(settingsNagView) + + let viewStack = UIView() + settingsNagView.addSubview(viewStack) + viewStack.autoPinWidthToSuperview() + viewStack.autoVCenterInSuperview() + + settingsNagDescriptionLabel = UILabel() + settingsNagDescriptionLabel.text = NSLocalizedString("CALL_VIEW_SETTINGS_NAG_DESCRIPTION_ALL", + comment: "Reminder to the user of the benefits of enabling CallKit and disabling CallKit privacy.") + settingsNagDescriptionLabel.font = UIFont.ows_regularFont(withSize:ScaleFromIPhone5To7Plus(16, 18)) + settingsNagDescriptionLabel.textColor = UIColor.white + settingsNagDescriptionLabel.numberOfLines = 0 + settingsNagDescriptionLabel.lineBreakMode = .byWordWrapping + viewStack.addSubview(settingsNagDescriptionLabel) + settingsNagDescriptionLabel.autoPinWidthToSuperview() + settingsNagDescriptionLabel.autoPinEdge(toSuperviewEdge:.top) + + let buttonHeight = ScaleFromIPhone5To7Plus(35, 45) + let buttonFont = UIFont.ows_regularFont(withSize:ScaleFromIPhone5To7Plus(14, 18)) + let buttonCornerRadius = CGFloat(4) + let descriptionVSpacingHeight = ScaleFromIPhone5To7Plus(30, 60) + + let callSettingsButton = UIButton() + callSettingsButton.setTitle(NSLocalizedString("CALL_VIEW_SETTINGS_NAG_SHOW_CALL_SETTINGS", + comment: "Label for button that shows the privacy settings"), for:.normal) + callSettingsButton.setTitleColor(UIColor.white, for:.normal) + callSettingsButton.titleLabel!.font = buttonFont + callSettingsButton.addTarget(self, action:#selector(didPressShowCallSettings), for:.touchUpInside) + callSettingsButton.backgroundColor = UIColor.ows_signalBrandBlue() + callSettingsButton.layer.cornerRadius = buttonCornerRadius + callSettingsButton.clipsToBounds = true + viewStack.addSubview(callSettingsButton) + callSettingsButton.autoSetDimension(.height, toSize:buttonHeight) + callSettingsButton.autoPinWidthToSuperview() + callSettingsButton.autoPinEdge(.top, to:.bottom, of:settingsNagDescriptionLabel, withOffset:descriptionVSpacingHeight) + + let notNowButton = UIButton() + notNowButton.setTitle(NSLocalizedString("CALL_VIEW_SETTINGS_NAG_NOT_NOW_BUTTON", + comment: "Label for button that dismiss the call view's settings nag."), for:.normal) + notNowButton.setTitleColor(UIColor.white, for:.normal) + notNowButton.titleLabel!.font = buttonFont + notNowButton.addTarget(self, action:#selector(didPressDismissNag), for:.touchUpInside) + notNowButton.backgroundColor = UIColor.ows_signalBrandBlue() + notNowButton.layer.cornerRadius = buttonCornerRadius + notNowButton.clipsToBounds = true + viewStack.addSubview(notNowButton) + notNowButton.autoSetDimension(.height, toSize:buttonHeight) + notNowButton.autoPinWidthToSuperview() + notNowButton.autoPinEdge(toSuperviewEdge:.bottom) + notNowButton.autoPinEdge(.top, to:.bottom, of:callSettingsButton, withOffset:12) + } + func buttonSize() -> CGFloat { return ScaleFromIPhone5To7Plus(84, 108) } @@ -356,8 +426,10 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R let contactVSpacing = CGFloat(3) let ongoingHMargin = ScaleFromIPhone5To7Plus(46, 72) let incomingHMargin = ScaleFromIPhone5To7Plus(46, 72) + let settingsNagHMargin = CGFloat(30) let ongoingBottomMargin = ScaleFromIPhone5To7Plus(23, 41) let incomingBottomMargin = CGFloat(41) + let settingsNagBottomMargin = CGFloat(41) let avatarTopSpacing = ScaleFromIPhone5To7Plus(25, 50) // The buttons have built-in 10% margins, so to appear centered // the avatar's bottom spacing should be a bit less. @@ -401,6 +473,11 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R incomingCallView.autoPinEdge(toSuperviewEdge:.bottom, withInset:incomingBottomMargin) incomingCallView.autoPinWidthToSuperview(withMargin:incomingHMargin) incomingCallView.setContentHuggingVerticalHigh() + + // Settings nag views + settingsNagView.autoPinEdge(toSuperviewEdge:.bottom, withInset:settingsNagBottomMargin) + settingsNagView.autoPinWidthToSuperview(withMargin:settingsNagHMargin) + settingsNagView.autoPinEdge(.top, to:.bottom, of:callStatusLabel, withOffset:+avatarTopSpacing) } updateRemoteVideoLayout() @@ -529,7 +606,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R return NSLocalizedString("END_CALL_RESPONDER_IS_BUSY", comment: "Call setup status label") case .localFailure: if let error = call.error { - switch (error) { + switch error { case .timeout(description: _): if self.call.direction == .outgoing { return NSLocalizedString("CALL_SCREEN_STATUS_NO_ANSWER", comment: "Call setup status label after outgoing call times out") @@ -555,6 +632,13 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R assert(Thread.isMainThread) updateCallStatusLabel(callState: callState) + if isShowingSettingsNag { + settingsNagView.isHidden = false + contactAvatarView.isHidden = true + ongoingCallView.isHidden = true + return + } + audioModeMuteButton.isSelected = call.isMuted videoModeMuteButton.isSelected = call.isMuted audioModeVideoButton.isSelected = call.hasLocalVideo @@ -595,13 +679,10 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R switch callState { case .remoteHangup, .remoteBusy, .localFailure: Logger.debug("\(TAG) dismissing after delay because new state is \(callState)") - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.dismiss(animated: true) - } + dismissIfPossible(shouldDelay:true) case .localHangup: Logger.debug("\(TAG) dismissing immediately from local hangup") - self.dismiss(animated: true) - + dismissIfPossible(shouldDelay:false) default: break } @@ -637,7 +718,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R Logger.warn("\(TAG) hung up, but call was unexpectedly nil") } - self.dismiss(animated: true) + dismissIfPossible(shouldDelay:false) } func didPressMute(sender muteButton: UIButton) { @@ -663,7 +744,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R func didPressTextMessage(sender speakerphoneButton: UIButton) { Logger.info("\(TAG) called \(#function)") - self.dismiss(animated: true) + dismissIfPossible(shouldDelay:false) } func didPressAnswerCall(sender: UIButton) { @@ -676,9 +757,7 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R NSLocalizedString("END_CALL_UNCATEGORIZED_FAILURE", comment: "Call setup status label")) self.callStatusLabel.text = text - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - self.dismiss(animated: true) - } + dismissIfPossible(shouldDelay:true) return } @@ -707,7 +786,57 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R Logger.warn("\(TAG) denied call, but call was unexpectedly nil") } - self.dismiss(animated: true) + dismissIfPossible(shouldDelay:false) + } + + func didPressShowCallSettings(sender: UIButton) { + Logger.info("\(TAG) called \(#function)") + + markSettingsNagAsComplete() + + dismissIfPossible(shouldDelay: false, ignoreNag: true, completion: { + // Find the frontmost presented UIViewController from which to present the + // settings views. + let window = UIApplication.shared.keyWindow + var fromViewController = window!.rootViewController + let storyboard = fromViewController?.storyboard + while fromViewController?.presentedViewController != nil { + fromViewController = fromViewController?.presentedViewController + } + assert(fromViewController != nil) + assert(storyboard != nil) + + // Construct the "settings" view & push the "privacy settings" view. + let navigationController = storyboard?.instantiateViewController(withIdentifier:"SettingsNavigationController") as! UINavigationController + assert(navigationController.viewControllers.count == 1) + let privacySettingsViewController = PrivacySettingsTableViewController() + navigationController.pushViewController(privacySettingsViewController, animated:false) + + fromViewController?.present(navigationController, animated: true, completion: nil) + }) + } + + func didPressDismissNag(sender: UIButton) { + Logger.info("\(TAG) called \(#function)") + + markSettingsNagAsComplete() + + dismissIfPossible(shouldDelay: false, ignoreNag: true) + } + + // We only show the "blocking" settings nag until the user has chosen + // to view the privacy settings _or_ dismissed the nag at least once. + // + // In either case, we set the "CallKit enabled" and "CallKit privacy enabled" + // settings to their default values to indicate that the user has reviewed + // them. + private func markSettingsNagAsComplete() { + Logger.info("\(TAG) called \(#function)") + + let preferences = Environment.getCurrent().preferences! + + preferences.setIsCallKitEnabled(preferences.isCallKitEnabled()) + preferences.setIsCallKitPrivacyEnabled(preferences.isCallKitPrivacyEnabled()) } // MARK: - CallObserver @@ -775,6 +904,56 @@ class CallViewController: UIViewController, CallObserver, CallServiceObserver, R updateRemoteVideoLayout() } + internal func dismissIfPossible(shouldDelay: Bool, ignoreNag: Bool = false, completion: (() -> Swift.Void)? = nil) { + if hasDismissed { + // Don't dismiss twice. + return + } else if !ignoreNag && + call.direction == .incoming && + supportsCallKit() && + (!Environment.getCurrent().preferences.isCallKitEnabled() || + Environment.getCurrent().preferences.isCallKitPrivacyEnabled()) { + + isShowingSettingsNag = true + + // Update the nag view's copy to reflect the settings state. + if Environment.getCurrent().preferences.isCallKitEnabled() { + settingsNagDescriptionLabel.text = NSLocalizedString("CALL_VIEW_SETTINGS_NAG_DESCRIPTION_PRIVACY", + comment: "Reminder to the user of the benefits of disabling CallKit privacy.") + } else { + settingsNagDescriptionLabel.text = NSLocalizedString("CALL_VIEW_SETTINGS_NAG_DESCRIPTION_ALL", + comment: "Reminder to the user of the benefits of enabling CallKit and disabling CallKit privacy.") + } + settingsNagDescriptionLabel.superview?.setNeedsLayout() + + if Environment.getCurrent().preferences.isCallKitEnabledSet() || + Environment.getCurrent().preferences.isCallKitPrivacySet() { + // User has already touched these preferences, only show + // the "fleeting" nag, not the "blocking" nag. + + // Show nag for N seconds. + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { [weak self] in + guard let strongSelf = self else { return } + strongSelf.dismissIfPossible(shouldDelay: false, ignoreNag: true) + } + } + } else if shouldDelay { + hasDismissed = true + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + self.dismiss(animated: true, completion:completion) + } + } else { + hasDismissed = true + self.dismiss(animated: false, completion:completion) + } + } + + // MARK: - Util + + private func supportsCallKit() -> Bool { + return ProcessInfo().isOperatingSystemAtLeast(OperatingSystemVersion(majorVersion: 10, minorVersion: 0, patchVersion: 0)) + } + // MARK: - CallServiceObserver internal func didUpdateCall(call: SignalCall?) { diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 60ba2baa2..009eb0b16 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -82,6 +82,18 @@ /* 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 %@"; +/* Reminder to the user of the benefits of enabling CallKit and disabling CallKit privacy. */ +"CALL_VIEW_SETTINGS_NAG_DESCRIPTION_ALL" = "You can answer calls directly from your lockscreen and see the names and phone numbers of incoming callers if you change your settings.\n\nSee the privacy settings for details."; + +/* Reminder to the user of the benefits of disabling CallKit privacy. */ +"CALL_VIEW_SETTINGS_NAG_DESCRIPTION_PRIVACY" = "You can see the names and phone numbers of incoming callers if you change your settings.\n\nSee the privacy settings for details."; + +/* Label for button that dismiss the call view's settings nag. */ +"CALL_VIEW_SETTINGS_NAG_NOT_NOW_BUTTON" = "Not Now"; + +/* Label for button that shows the privacy settings */ +"CALL_VIEW_SETTINGS_NAG_SHOW_CALL_SETTINGS" = "Show Privacy Settings"; + /* notification action */ "CALLBACK_BUTTON_TITLE" = "Call back";