diff --git a/Signal.xcodeproj/project.pbxproj b/Signal.xcodeproj/project.pbxproj index 982e44b8c..698879c23 100644 --- a/Signal.xcodeproj/project.pbxproj +++ b/Signal.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 341BB7491DB727EE001E2975 /* JSQMediaItem+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = 341BB7481DB727EE001E2975 /* JSQMediaItem+OWS.m */; }; 34535D821E256BE9008A4747 /* UIView+OWS.m in Sources */ = {isa = PBXBuildFile; fileRef = 34535D811E256BE9008A4747 /* UIView+OWS.m */; }; + 348F3A4F1E4A533900750D44 /* CallInterstitialViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 348F3A4E1E4A533900750D44 /* CallInterstitialViewController.swift */; }; 34FD93701E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m in Sources */ = {isa = PBXBuildFile; fileRef = 34FD936F1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m */; }; 450873C31D9D5149006B54F2 /* OWSExpirationTimerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 450873C21D9D5149006B54F2 /* OWSExpirationTimerView.m */; }; 450873C41D9D5149006B54F2 /* OWSExpirationTimerView.m in Sources */ = {isa = PBXBuildFile; fileRef = 450873C21D9D5149006B54F2 /* OWSExpirationTimerView.m */; }; @@ -602,6 +603,7 @@ 341BB7481DB727EE001E2975 /* JSQMediaItem+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "JSQMediaItem+OWS.m"; sourceTree = ""; }; 34535D801E256BE9008A4747 /* UIView+OWS.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "UIView+OWS.h"; sourceTree = ""; }; 34535D811E256BE9008A4747 /* UIView+OWS.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "UIView+OWS.m"; sourceTree = ""; }; + 348F3A4E1E4A533900750D44 /* CallInterstitialViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CallInterstitialViewController.swift; sourceTree = ""; }; 34FD936E1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = OWSAnyTouchGestureRecognizer.h; path = views/OWSAnyTouchGestureRecognizer.h; sourceTree = ""; }; 34FD936F1E3BD43A00109093 /* OWSAnyTouchGestureRecognizer.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = OWSAnyTouchGestureRecognizer.m; path = views/OWSAnyTouchGestureRecognizer.m; sourceTree = ""; }; 450873C11D9D5149006B54F2 /* OWSExpirationTimerView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = OWSExpirationTimerView.h; sourceTree = ""; }; @@ -2607,25 +2609,26 @@ FC3196321A08142D0094C78E /* Signals */ = { isa = PBXGroup; children = ( + 348F3A4E1E4A533900750D44 /* CallInterstitialViewController.swift */, 4509E79B1DD6545B0025A59F /* CallViewController.swift */, - FC3196281A067D8F0094C78E /* MessageComposeTableViewController.h */, - FC3196291A067D8F0094C78E /* MessageComposeTableViewController.m */, - FCAC963A19FEF9280046DFC5 /* SignalsViewController.h */, - FCAC963B19FEF9280046DFC5 /* SignalsViewController.m */, - FCAC964F19FF0A6E0046DFC5 /* MessagesViewController.h */, - FCAC965019FF0A6E0046DFC5 /* MessagesViewController.m */, FC31962B1A06A2190094C78E /* FingerprintViewController.h */, FC31962C1A06A2190094C78E /* FingerprintViewController.m */, FCB11D911A12A4AA002F93FB /* FullImageViewController.h */, FCB11D921A12A4AA002F93FB /* FullImageViewController.m */, - A5D0699A1A50E9CB004CB540 /* ShowGroupMembersViewController.h */, - A5D069991A50E9CB004CB540 /* ShowGroupMembersViewController.m */, + FC3196281A067D8F0094C78E /* MessageComposeTableViewController.h */, + FC3196291A067D8F0094C78E /* MessageComposeTableViewController.m */, + FCAC964F19FF0A6E0046DFC5 /* MessagesViewController.h */, + FCAC965019FF0A6E0046DFC5 /* MessagesViewController.m */, FCFD256D1A151BCB00F4C644 /* NewGroupViewController.h */, FCFD256E1A151BCB00F4C644 /* NewGroupViewController.m */, - FC4FA0241A1B9DC600DA100A /* SignalsNavigationController.h */, - FC4FA0251A1B9DC600DA100A /* SignalsNavigationController.m */, 452E3C8C1D935C77002A45B0 /* OWSConversationSettingsTableViewController.h */, 452E3C8D1D935C77002A45B0 /* OWSConversationSettingsTableViewController.m */, + A5D0699A1A50E9CB004CB540 /* ShowGroupMembersViewController.h */, + A5D069991A50E9CB004CB540 /* ShowGroupMembersViewController.m */, + FC4FA0241A1B9DC600DA100A /* SignalsNavigationController.h */, + FC4FA0251A1B9DC600DA100A /* SignalsNavigationController.m */, + FCAC963A19FEF9280046DFC5 /* SignalsViewController.h */, + FCAC963B19FEF9280046DFC5 /* SignalsViewController.m */, ); name = Signals; sourceTree = ""; @@ -3081,6 +3084,7 @@ 45387B041E36D650005D00B3 /* OWS102MoveLoggingPreferenceToUserDefaults.m in Sources */, E197B61818BBEC1A00F073E5 /* RemoteIOAudio.m in Sources */, B67ADDC41989FF8700E1A773 /* RPServerRequestsManager.m in Sources */, + 348F3A4F1E4A533900750D44 /* CallInterstitialViewController.swift in Sources */, EF764C351DB67CC5000D9A87 /* UIViewController+CameraPermissions.m in Sources */, 76EB059418170B33006006FC /* HttpManager.m in Sources */, 45CD81EF1DC030E7004C9430 /* AccountManager.swift in Sources */, diff --git a/Signal/src/call/CallService.swift b/Signal/src/call/CallService.swift index 5b20ca2a4..dccb9d164 100644 --- a/Signal/src/call/CallService.swift +++ b/Signal/src/call/CallService.swift @@ -250,6 +250,18 @@ protocol CallServiceObserver: class { return "CallServiceActiveCallNotification" } + class func presentCallInterstitialNotificationName() -> String { + return "PresentCallInterstitialNotification" + } + + class func dismissCallInterstitialNotificationName() -> String { + return "DismissCallInterstitialNotification" + } + + class func callWasCancelledByInterstitialNotificationName() -> String { + return "CallWasCancelledByInterstitialNotification" + } + // MARK: - Service Actions /** diff --git a/Signal/src/call/OutboundCallInitiator.swift b/Signal/src/call/OutboundCallInitiator.swift index 4c40637e2..0ccfff931 100644 --- a/Signal/src/call/OutboundCallInitiator.swift +++ b/Signal/src/call/OutboundCallInitiator.swift @@ -14,11 +14,37 @@ import Foundation let contactsManager: OWSContactsManager let contactsUpdater: ContactsUpdater + var cancelledCallTokens: [String] = [] + init(redphoneManager: PhoneManager, contactsManager: OWSContactsManager, contactsUpdater: ContactsUpdater) { self.redphoneManager = redphoneManager self.contactsManager = contactsManager self.contactsUpdater = contactsUpdater + + super.init() + + NotificationCenter.default.addObserver(self, + selector:#selector(callWasCancelledByInterstitial), + name:Notification.Name(rawValue: CallService.callWasCancelledByInterstitialNotificationName()), + object:nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func callWasCancelledByInterstitial(notification: NSNotification) { + AssertIsOnMainThread() + + let callToken = notification.object as! String + cancelCallToken(callToken) + } + + func cancelCallToken(_ callToken: String) { + AssertIsOnMainThread() + + cancelledCallTokens.append(callToken) } /** @@ -44,6 +70,10 @@ import Foundation return self.initiateRedphoneCall(recipientId: recipientId) } + // A temporary unique id used to identify this call during the + let callToken = NSUUID().uuidString + presentCallInterstitial(callToken) + // Since users can toggle this setting, which is only communicated during contact sync, it's easy to imagine the // preference getting stale. Especially as users are toggling the feature to test calls. So here, we opt for a // blocking network request *every* time we place a call to make sure we've got up to date preferences. @@ -52,6 +82,10 @@ import Foundation // SignalRecipient *recipient = [SignalRecipient recipientWithTextSecureIdentifier:self.thread.contactIdentifier]; self.contactsUpdater.lookupIdentifier(recipientId, success: { recipient in + guard !self.cancelledCallTokens.contains(callToken) else { + Logger.error("\(self.TAG) OutboundCallInitiator aborting due to cancelled call.") + return + } guard !Environment.getCurrent().phoneManager.hasOngoingRedphoneCall() else { Logger.error("\(self.TAG) OutboundCallInitiator aborting due to ongoing RedPhone call.") @@ -74,6 +108,9 @@ import Foundation failure: { error in Logger.warn("\(self.TAG) looking up recipientId: \(recipientId) failed with error \(error)") + self.cancelCallToken(callToken) + self.dismissCallInterstitial(callToken) + let alertTitle = NSLocalizedString("UNABLE_TO_PLACE_CALL", comment:"Alert Title") let alertController = UIAlertController(title: alertTitle, message: error.localizedDescription, preferredStyle: .alert) @@ -113,4 +150,17 @@ import Foundation return true } + private func presentCallInterstitial(_ callToken: String) { + AssertIsOnMainThread() + + let notificationName = CallService.presentCallInterstitialNotificationName() + NotificationCenter.default.post(name: NSNotification.Name(rawValue: notificationName), object: callToken) + } + + private func dismissCallInterstitial(_ callToken: String) { + AssertIsOnMainThread() + + let notificationName = CallService.dismissCallInterstitialNotificationName() + NotificationCenter.default.post(name: NSNotification.Name(rawValue: notificationName), object: callToken) + } } diff --git a/Signal/src/view controllers/CallInterstitialViewController.swift b/Signal/src/view controllers/CallInterstitialViewController.swift new file mode 100644 index 000000000..550cb40c8 --- /dev/null +++ b/Signal/src/view controllers/CallInterstitialViewController.swift @@ -0,0 +1,166 @@ +// +// Copyright (c) 2017 Open Whisper Systems. All rights reserved. +// + +import Foundation + +@objc(OWSCallInterstitialViewController) +class CallInterstitialViewController: UIViewController { + + let TAG = "[CallInterstitialViewController]" + + var wasCallCancelled = false + var callToken: String? + + // MARK: Views + + var hasConstraints = false + var blurView: UIVisualEffectView! + var contentView: UIView! + + // MARK: Initializers + + required init?(coder aDecoder: NSCoder) { + assert(false) + super.init(coder: aDecoder) + } + + required init() { + super.init(nibName: nil, bundle: nil) + observeNotifications() + } + + func observeNotifications() { + NotificationCenter.default.addObserver(self, + selector:#selector(willResignActive), + name:NSNotification.Name.UIApplicationWillResignActive, + object:nil) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + func willResignActive() { + cancelCall() + } + + // MARK: View Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + createViews() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + blurView.layer.opacity = 0 + contentView.layer.opacity = 0 + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + UIView.animate(withDuration: 0.3, + delay: 1.0, + options: UIViewAnimationOptions.curveLinear, + animations: { + self.blurView.layer.opacity = 1 + self.contentView.layer.opacity = 1 + }, + completion: nil) + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + blurView.layer.removeAllAnimations() + contentView.layer.removeAllAnimations() + } + + // MARK: - Create Views + + func createViews() { + assert(self.view != nil) + + // Dark blurred background. + let blurEffect = UIBlurEffect(style: .dark) + blurView = UIVisualEffectView(effect: blurEffect) + blurView.isUserInteractionEnabled = false + self.view.addSubview(blurView) + + contentView = UIView() + self.view.addSubview(contentView) + + let dialingLabel = UILabel() + dialingLabel.text = NSLocalizedString("CALL_INTERSTITIAL_CALLING_LABEL", comment: "Title for call interstitial view") + dialingLabel.textColor = UIColor.white + dialingLabel.font = UIFont.ows_lightFont(withSize:ScaleFromIPhone5To7Plus(32, 40)) + dialingLabel.textAlignment = .center + contentView.addSubview(dialingLabel) + + let cancelCallButton = UIButton() + cancelCallButton.setTitle(NSLocalizedString("CALL_INTERSTITIAL_CANCEL_BUTTON", comment: "Label for cancel button on call interstitial view"), + for:.normal) + cancelCallButton.setTitleColor(UIColor.white, for:.normal) + cancelCallButton.titleLabel?.font = UIFont.ows_lightFont(withSize:ScaleFromIPhone5To7Plus(26, 32)) + let buttonInset = ScaleFromIPhone5To7Plus(7, 9) + cancelCallButton.titleEdgeInsets = UIEdgeInsets(top: buttonInset, + left: buttonInset, + bottom: buttonInset, + right: buttonInset) + cancelCallButton.addTarget(self, action:#selector(cancelCallButtonPressed), for:.touchUpInside) + contentView.addSubview(cancelCallButton) + + dialingLabel.autoPinWidthToSuperview() + dialingLabel.autoVCenterInSuperview() + + cancelCallButton.autoSetDimension(.height, toSize:ScaleFromIPhone5To7Plus(50, 60)) + cancelCallButton.autoPinWidthToSuperview() + cancelCallButton.autoPinEdge(toSuperviewEdge:.bottom, withInset:ScaleFromIPhone5To7Plus(23, 41)) + } + + func cancelCallButtonPressed(sender button: UIButton) { + cancelCall() + } + + // MARK: - Layout + + override func updateViewConstraints() { + if !hasConstraints { + // We only want to create our constraints once. + // + // Note that constraints are also created elsewhere. + // This only creates the constraints for the top-level contents of the view. + hasConstraints = true + + // Force creation of the view. + let view = self.view + assert(view != nil) + + // Dark blurred background. + blurView.autoPinEdgesToSuperviewEdges() + + contentView.autoPinEdgesToSuperviewEdges() + } + + super.updateViewConstraints() + } + + // MARK: - Methods + + func cancelCall() { + guard !wasCallCancelled else { + return + } + wasCallCancelled = true + + assert(callToken != nil) + let notificationName = CallService.callWasCancelledByInterstitialNotificationName() + NotificationCenter.default.post(name: NSNotification.Name(rawValue: notificationName), object: callToken) + + self.dismiss(animated: false) + } +} diff --git a/Signal/src/view controllers/SignalsViewController.m b/Signal/src/view controllers/SignalsViewController.m index 214afe1b8..672df503a 100644 --- a/Signal/src/view controllers/SignalsViewController.m +++ b/Signal/src/view controllers/SignalsViewController.m @@ -125,11 +125,19 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS (self.traitCollection.forceTouchCapability == UIForceTouchCapabilityAvailable)) { [self registerForPreviewingWithDelegate:self sourceView:self.tableView]; } - + [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(handleActiveCallNotification:) name:[CallService callServiceActiveCallNotificationName] object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handlePresentCallInterstitialNotification:) + name:[CallService presentCallInterstitialNotificationName] + object:nil]; + [[NSNotificationCenter defaultCenter] addObserver:self + selector:@selector(handleDismissCallInterstitialNotification:) + name:[CallService dismissCallInterstitialNotificationName] + object:nil]; } - (UIViewController *)previewingContext:(id)previewingContext @@ -150,22 +158,71 @@ NSString *const SignalsViewControllerSegueShowIncomingCall = @"ShowIncomingCallS } } -- (void)handleActiveCallNotification:(NSNotification *)notification +- (void)handlePresentCallInterstitialNotification:(NSNotification *)notification { AssertIsOnMainThread(); + + NSString *callToken = notification.object; + OWSAssert(callToken != nil); + + OWSCallInterstitialViewController *viewController = [OWSCallInterstitialViewController new]; + viewController.callToken = callToken; + + void(^presentInterstitial)() = ^{ + viewController.modalPresentationStyle = UIModalPresentationOverFullScreen; + [self presentViewController:viewController + animated:NO + completion:nil]; + }; + + // Dismiss any other modals so we can present call modal. + if (self.presentedViewController) { + [self dismissViewControllerAnimated:YES completion:^{ + presentInterstitial(); + }]; + } else { + presentInterstitial(); + } +} +- (void)handleDismissCallInterstitialNotification:(NSNotification *)notification +{ + AssertIsOnMainThread(); + + NSString *callToken = notification.object; + OWSAssert(callToken != nil); + + if (!self.presentedViewController || + ![self.presentedViewController isKindOfClass:[OWSCallInterstitialViewController class]]) { + return; + } + + + OWSCallInterstitialViewController *viewController = (OWSCallInterstitialViewController *)self.presentedViewController; + if (![viewController.callToken isEqualToString:callToken]) { + return; + } + [self dismissViewControllerAnimated:YES completion:nil]; +} + +- (void)handleActiveCallNotification:(NSNotification *)notification +{ + AssertIsOnMainThread(); + if (![notification.object isKindOfClass:[SignalCall class]]) { DDLogError(@"%@ expected presentCall observer to be notified with a SignalCall, but found %@", - self.tag, - notification.object); + self.tag, + notification.object); return; } - + SignalCall *call = (SignalCall *)notification.object; // Dismiss any other modals so we can present call modal. if (self.presentedViewController) { - [self dismissViewControllerAnimated:YES completion:^{ + BOOL shouldAnimate = ![self.presentedViewController isKindOfClass:[OWSCallInterstitialViewController class]]; + [self dismissViewControllerAnimated:shouldAnimate + completion:^{ [self performSegueWithIdentifier:SignalsViewControllerSegueShowIncomingCall sender:call]; }]; } else { diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 0ed38407e..10d62d124 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -70,6 +70,12 @@ /* No comment provided by engineer. */ "AUDIO_PERMISSION_MESSAGE" = "Signal requires access to your microphone to work properly. You can grant this permission in the Settings app >> Privacy >> Microphone >> Signal"; +/* Title for call interstitial view */ +"CALL_INTERSTITIAL_CALLING_LABEL" = "Calling..."; + +/* Label for cancel button on call interstitial view */ +"CALL_INTERSTITIAL_CANCEL_BUTTON" = "Cancel"; + /* Accessibilty label for placing call button */ "CALL_LABEL" = "Call";