diff --git a/Signal/src/Calls/CallAudioService.swift b/Signal/src/Calls/CallAudioService.swift index 9ac01b411a..0b69c4d30c 100644 --- a/Signal/src/Calls/CallAudioService.swift +++ b/Signal/src/Calls/CallAudioService.swift @@ -107,6 +107,8 @@ protocol CallAudioServiceDelegate: class { } private let routePicker = AVRoutePickerView() + + @discardableResult public func presentRoutePicker() -> Bool { guard let routeButton = routePicker.subviews.first(where: { $0 is UIButton }) as? UIButton else { owsFailDebug("Failed to find subview to present route picker, falling back to old system") diff --git a/Signal/src/Calls/CallService.swift b/Signal/src/Calls/CallService.swift index c56cac156e..e669184dcc 100644 --- a/Signal/src/Calls/CallService.swift +++ b/Signal/src/Calls/CallService.swift @@ -786,6 +786,15 @@ extension CallService: CallManagerDelegate { AssertIsOnMainThread() Logger.info("shouldSendCallMessage") + // It's unlikely that this would ever have more than one call. But technically + // we don't know which call this message is on behalf of. So we assume it's every + // call with a participant with recipientUuid + let relevantCalls = calls.filter { (call: SignalCall) -> Bool in + call.participantAddresses + .compactMap { $0.uuid } + .contains(recipientUuid) + } + databaseStorage.write(.promise) { transaction in TSContactThread.getOrCreateThread( withContactAddress: SignalServiceAddress(uuid: recipientUuid), @@ -804,10 +813,10 @@ extension CallService: CallManagerDelegate { }.done { _ in // TODO: Tell RingRTC we succeeded in sending the message. API TBD }.catch { error in - // Did the recipients safety number change? - // Is the user blocked? if error.isNetworkFailureOrTimeout { Logger.warn("Failed to send opaque message \(error)") + } else if error.isUntrustedIdentityError { + relevantCalls.forEach { $0.publishSendFailureUntrustedParticipantIdentity() } } else { Logger.error("Failed to send opaque message \(error)") } @@ -1067,3 +1076,10 @@ extension CallService: CallManagerDelegate { ) } } + +private extension Error { + var isUntrustedIdentityError: Bool { + let nsError = self as NSError + return nsError.domain == OWSSignalServiceKitErrorDomain && nsError.code == OWSErrorCode.untrustedIdentity.rawValue + } +} diff --git a/Signal/src/Calls/SignalCall.swift b/Signal/src/Calls/SignalCall.swift index a8b094090c..e6755a4ffb 100644 --- a/Signal/src/Calls/SignalCall.swift +++ b/Signal/src/Calls/SignalCall.swift @@ -19,6 +19,10 @@ public protocol CallObserver: class { func groupCallRequestMembershipProof(_ call: SignalCall) func groupCallRequestGroupMembers(_ call: SignalCall) func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) + + /// Invoked if a call message failed to send because of a safety number change + /// UI observing call state may choose to alert the user (e.g. presenting a SafetyNumberConfirmationSheet) + func callMessageSendFailedUntrustedIdentity(_ call: SignalCall) } public extension CallObserver { @@ -34,6 +38,8 @@ public extension CallObserver { func groupCallRequestMembershipProof(_ call: SignalCall) {} func groupCallRequestGroupMembers(_ call: SignalCall) {} func groupCallEnded(_ call: SignalCall, reason: GroupCallEndReason) {} + + func callMessageSendFailedUntrustedIdentity(_ call: SignalCall) {} } @objc @@ -104,6 +110,15 @@ public class SignalCall: NSObject, CallManagerCallReference { case messageSendFailure(underlyingError: Error) } + var participantAddresses: [SignalServiceAddress] { + switch mode { + case .group(let call): + return call.remoteDeviceStates.values.map { $0.address } + case .individual(let call): + return [call.remoteAddress] + } + } + init(groupCall: GroupCall, groupThread: TSGroupThread) { mode = .group(groupCall) audioActivity = AudioActivity( @@ -215,6 +230,10 @@ public class SignalCall: NSObject, CallManagerCallReference { observers = [] } + public func publishSendFailureUntrustedParticipantIdentity() { + observers.elements.forEach { $0.callMessageSendFailedUntrustedIdentity(self) } + } + // MARK: - // This method should only be called when the call state is "connected". diff --git a/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift b/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift index e54c1c36be..42b2f36400 100644 --- a/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift +++ b/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift @@ -39,6 +39,7 @@ class GroupCallViewController: UIViewController { var shouldRemoteVideoControlsBeHidden = false { didSet { updateCallUI() } } + var hasUnresolvedSafetyNumberMismatch = false private static let keyValueStore = SDSKeyValueStore(collection: "GroupCallViewController") @@ -199,6 +200,12 @@ class GroupCallViewController: UIViewController { } } + override func viewDidAppear(_ animated: Bool) { + if hasUnresolvedSafetyNumberMismatch { + resolveSafetyNumberMismatch() + } + } + private var hasOverflowMembers: Bool { videoGrid.maxItems < groupCall.remoteDeviceStates.count } private func updateScrollViewFrames(size: CGSize? = nil, controlsAreHidden: Bool) { @@ -492,8 +499,97 @@ extension GroupCallViewController: CallViewControllerWindowReference { self.updateCallUI() splitViewSnapshot.removeFromSuperview() pipSnapshot.removeFromSuperview() + + if self.hasUnresolvedSafetyNumberMismatch { + self.resolveSafetyNumberMismatch() + } } } + + func resolveSafetyNumberMismatch() { + if !isCallMinimized, CurrentAppContext().isAppForegroundAndActive() { + presentSafetyNumberChangeSheetIfNecessary { [weak self] success in + guard let self = self else { return } + if success { + self.groupCall.resendMediaKeys() + self.hasUnresolvedSafetyNumberMismatch = false + } else { + self.dismissCall() + } + } + } else { + AppEnvironment.shared.notificationPresenter.notifyForGroupCallSafetyNumberChange(inThread: call.thread) + } + } + + func presentSafetyNumberChangeSheetIfNecessary(completion: @escaping (Bool) -> Void) { + let localDeviceHasNotJoined = groupCall.localDeviceState.joinState == .notJoined + let currentParticipantAddresses = groupCall.remoteDeviceStates.map { $0.value.address } + + // If we haven't joined the call yet, we want to alert for all members of the group + // If we are in the call, we only care about safety numbers for the active call participants + let addressesToAlert = call.thread.recipientAddresses.filter { memberAddress in + let isUntrusted = OWSIdentityManager.shared().untrustedIdentityForSending(to: memberAddress) != nil + let isMemberInCall = currentParticipantAddresses.contains(memberAddress) + + // We want to alert for safety number changes of all members if we haven't joined yet + // If we're already in the call, we only care about active call participants + return isUntrusted && (isMemberInCall || localDeviceHasNotJoined) + } + + // There are no unverified addresses that we're currently concerned about. No need to show a sheet + guard addressesToAlert.count > 0 else { completion(true); return } + + let startCallString = NSLocalizedString("GROUP_CALL_START_BUTTON", comment: "Button to start a group call") + let joinCallString = NSLocalizedString("GROUP_CALL_JOIN_BUTTON", comment: "Button to join an ongoing group call") + let continueCallString = NSLocalizedString("GROUP_CALL_CONTINUE_BUTTON", comment: "Button to continue an ongoing group call") + let leaveCallString = NSLocalizedString("GROUP_CALL_LEAVE_BUTTON", comment: "Button to leave a group call") + let cancelString = CommonStrings.cancelButton + + let approveText: String + let denyText: String + if localDeviceHasNotJoined { + let deviceCount = call.groupCall.peekInfo?.deviceCount ?? 0 + approveText = deviceCount > 0 ? joinCallString : startCallString + denyText = cancelString + } else { + approveText = continueCallString + denyText = leaveCallString + } + + let sheet = SafetyNumberConfirmationSheet( + addressesToConfirm: addressesToAlert, + confirmationText: approveText, + cancelText: denyText, + theme: .translucentDark) { didApprove in + + if didApprove { + SDSDatabaseStorage.shared.asyncWrite { writeTx in + let identityManager = OWSIdentityManager.shared() + for address in addressesToAlert { + guard let identityKey = identityManager.identityKey(for: address, transaction: writeTx) else { return } + let currentState = identityManager.verificationState(for: address, transaction: writeTx) + let newState = (currentState == .noLongerVerified) ? .default : currentState + + identityManager.setVerificationState(newState, + identityKey: identityKey, + address: address, + isUserInitiatedChange: true, + transaction: writeTx) + } + } completion: { + completion(true) + } + + } else { + completion(false) + } + } + sheet.allowsDismissal = false + sheet.confirmAction.button.titleLabel?.font = UIFont.ows_dynamicTypeBody.ows_semibold + sheet.cancelAction.button.titleLabel?.font = UIFont.ows_dynamicTypeBody + present(sheet, animated: true, completion: nil) + } } extension GroupCallViewController: CallObserver { @@ -560,6 +656,16 @@ extension GroupCallViewController: CallObserver { )) presentActionSheet(actionSheet) } + + func callMessageSendFailedUntrustedIdentity(_ call: SignalCall) { + AssertIsOnMainThread() + guard call == self.call else { return owsFailDebug("Unexpected call \(call)") } + + if !hasUnresolvedSafetyNumberMismatch { + hasUnresolvedSafetyNumberMismatch = true + resolveSafetyNumberMismatch() + } + } } extension GroupCallViewController: CallControlsDelegate { @@ -603,7 +709,14 @@ extension GroupCallViewController: CallControlsDelegate { } func didPressJoin(sender: UIButton) { - callService.joinGroupCallIfNecessary(call) + presentSafetyNumberChangeSheetIfNecessary { [weak self] success in + guard let self = self else { return } + if success { + self.callService.joinGroupCallIfNecessary(self.call) + } else { + self.dismissCall() + } + } } } diff --git a/Signal/src/NotificationActionHandler.swift b/Signal/src/NotificationActionHandler.swift index 13163411e3..c554098e62 100644 --- a/Signal/src/NotificationActionHandler.swift +++ b/Signal/src/NotificationActionHandler.swift @@ -113,23 +113,27 @@ class NotificationActionHandler { return firstly { () -> Promise in self.notificationMessage(forUserInfo: userInfo) }.done(on: .main) { notificationMessage in - let thread = notificationMessage.thread - let currentCall = AppEnvironment.shared.callService.currentCall - let isGroupCallMessage = notificationMessage.interaction is OWSGroupCallMessage + self.showThread(notificationMessage: notificationMessage) + } + } - if isGroupCallMessage, currentCall?.thread.uniqueId == thread.uniqueId { - OWSWindowManager.shared.returnToCallView() - } else if let thread = thread as? TSGroupThread, isGroupCallMessage, currentCall == nil { - GroupCallViewController.presentLobby(thread: thread) - } else { - // If this happens when the the app is not, visible we skip the animation so the thread - // can be visible to the user immediately upon opening the app, rather than having to watch - // it animate in from the homescreen. - self.signalApp.presentConversationAndScrollToFirstUnreadMessage( - forThreadId: thread.uniqueId, - animated: UIApplication.shared.applicationState == .active - ) - } + private func showThread(notificationMessage: NotificationMessage) { + let thread = notificationMessage.thread + let currentCall = AppEnvironment.shared.callService.currentCall + let isGroupCallMessage = notificationMessage.interaction is OWSGroupCallMessage + + if isGroupCallMessage, currentCall?.thread.uniqueId == thread.uniqueId { + OWSWindowManager.shared.returnToCallView() + } else if let thread = thread as? TSGroupThread, isGroupCallMessage, currentCall == nil { + GroupCallViewController.presentLobby(thread: thread) + } else { + // If this happens when the the app is not, visible we skip the animation so the thread + // can be visible to the user immediately upon opening the app, rather than having to watch + // it animate in from the homescreen. + self.signalApp.presentConversationAndScrollToFirstUnreadMessage( + forThreadId: thread.uniqueId, + animated: UIApplication.shared.applicationState == .active + ) } } @@ -157,6 +161,25 @@ class NotificationActionHandler { } } + func showCallLobby(userInfo: [AnyHashable: Any]) throws -> Promise { + return firstly { () -> Promise in + self.notificationMessage(forUserInfo: userInfo) + }.done(on: .main) { notificationMessage in + let thread = notificationMessage.thread + let currentCall = AppEnvironment.shared.callService.currentCall + + if currentCall?.thread.uniqueId == thread.uniqueId { + OWSWindowManager.shared.returnToCallView() + } else if let thread = thread as? TSGroupThread, currentCall == nil { + GroupCallViewController.presentLobby(thread: thread) + } else { + // If currentCall is non-nil, we can't join a call anyway, fallback to showing the thread. + // Individual calls don't have a lobby, just show the thread. + return self.showThread(notificationMessage: notificationMessage) + } + } + } + private struct NotificationMessage { let thread: TSThread let interaction: TSInteraction? diff --git a/Signal/src/UserNotificationActionHandler.swift b/Signal/src/UserNotificationActionHandler.swift index 571e2bb713..4a0bae34ac 100644 --- a/Signal/src/UserNotificationActionHandler.swift +++ b/Signal/src/UserNotificationActionHandler.swift @@ -31,21 +31,24 @@ public class UserNotificationActionHandler: NSObject { let userInfo = response.notification.request.content.userInfo + let action: AppNotificationAction + switch response.actionIdentifier { case UNNotificationDefaultActionIdentifier: Logger.debug("default action") - return try actionHandler.showThread(userInfo: userInfo) + let defaultActionString = userInfo[AppNotificationUserInfoKey.defaultAction] as? String + let defaultAction = defaultActionString.flatMap { AppNotificationAction(rawValue: $0) } + action = defaultAction ?? .showThread case UNNotificationDismissActionIdentifier: // TODO - mark as read? Logger.debug("dismissed notification") return Promise.value(()) default: - // proceed - break - } - - guard let action = UserNotificationConfig.action(identifier: response.actionIdentifier) else { - throw OWSAssertionError("unable to find action for actionIdentifier: \(response.actionIdentifier)") + if let responseAction = UserNotificationConfig.action(identifier: response.actionIdentifier) { + action = responseAction + } else { + throw OWSAssertionError("unable to find action for actionIdentifier: \(response.actionIdentifier)") + } } switch action { @@ -67,6 +70,8 @@ public class UserNotificationActionHandler: NSObject { return try actionHandler.showThread(userInfo: userInfo) case .reactWithThumbsUp: return try actionHandler.reactWithThumbsUp(userInfo: userInfo) + case .showCallLobby: + return try actionHandler.showCallLobby(userInfo: userInfo) } } } diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 1213f5c277..6f72a2170d 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -1531,6 +1531,9 @@ /* String displayed in group call grid cell when a user is blocked. Embeds {user's name} */ "GROUP_CALL_BLOCKED_USER_FORMAT" = "%@ is blocked"; +/* Button to continue an ongoing group call */ +"GROUP_CALL_CONTINUE_BUTTON" = "Continue Call"; + /* Text in conversation view for a group call that has since ended */ "GROUP_CALL_ENDED_MESSAGE" = "The group call has ended"; @@ -1549,6 +1552,9 @@ /* Button to join an ongoing group call */ "GROUP_CALL_JOIN_BUTTON" = "Join Call"; +/* Button to leave a group call */ +"GROUP_CALL_LEAVE_BUTTON" = "Leave Call"; + /* String indicating how many people are current in the call */ "GROUP_CALL_MANY_IN_THIS_CALL_FORMAT" = "In this call ยท %ld people"; @@ -1597,6 +1603,9 @@ /* Text indicating that the user has lost their connection to the call and we are reconnecting. */ "GROUP_CALL_RECONNECTING" = "Reconnecting"; +/* notification body when a group call participant joins with an untrusted safety number */ +"GROUP_CALL_SAFETY_NUMBER_CHANGE_BODY" = "Someone has joined this call with a safety number that has changed."; + /* Text in conversation view for a group call that someone started. We don't know who */ "GROUP_CALL_SOMEONE_STARTED_MESSAGE" = "Someone started a group call"; diff --git a/SignalMessaging/Notifications/AppNotifications.swift b/SignalMessaging/Notifications/AppNotifications.swift index 11d9a4c110..151024da0b 100644 --- a/SignalMessaging/Notifications/AppNotifications.swift +++ b/SignalMessaging/Notifications/AppNotifications.swift @@ -36,7 +36,7 @@ public enum AppNotificationCategory: CaseIterable { case grdbMigration } -public enum AppNotificationAction: CaseIterable { +public enum AppNotificationAction: String, CaseIterable { case answerCall case callBack case declineCall @@ -44,6 +44,7 @@ public enum AppNotificationAction: CaseIterable { case reply case showThread case reactWithThumbsUp + case showCallLobby } public struct AppNotificationUserInfoKey { @@ -53,6 +54,7 @@ public struct AppNotificationUserInfoKey { public static let callBackUuid = "Signal.AppNotificationsUserInfoKey.callBackUuid" public static let callBackPhoneNumber = "Signal.AppNotificationsUserInfoKey.callBackPhoneNumber" public static let localCallId = "Signal.AppNotificationsUserInfoKey.localCallId" + public static let defaultAction = "Signal.AppNotificationsUserInfoKey.defaultAction" } extension AppNotificationCategory { @@ -139,6 +141,8 @@ extension AppNotificationAction { return "Signal.AppNotifications.Action.showThread" case .reactWithThumbsUp: return "Signal.AppNotifications.Action.reactWithThumbsUp" + case .showCallLobby: + return "Signal.AppNotifications.Action.showCallLobby" } } } @@ -617,6 +621,33 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { } } + public func notifyForGroupCallSafetyNumberChange(inThread thread: TSThread) { + let notificationTitle: String? + switch previewType { + case .noNameNoPreview: + notificationTitle = nil + case .nameNoPreview, .namePreview: + notificationTitle = contactsManager.displayNameWithSneakyTransaction(thread: thread) + } + + let notificationBody = NotificationStrings.groupCallSafetyNumberChangeBody + let threadId = thread.uniqueId + let userInfo: [String: Any] = [ + AppNotificationUserInfoKey.threadId: threadId, + AppNotificationUserInfoKey.defaultAction: AppNotificationAction.showCallLobby.rawValue + ] + + DispatchQueue.main.async { + let sound = self.requestSound(thread: thread) + self.adaptee.notify(category: .infoOrErrorMessage, + title: notificationTitle, + body: notificationBody, + threadIdentifier: nil, // show ungrouped + userInfo: userInfo, + sound: sound) + } + } + public func notifyUser(for errorMessage: TSErrorMessage, thread: TSThread, transaction: SDSAnyWriteTransaction) { switch errorMessage.errorType { case .noSession, @@ -660,10 +691,14 @@ public class NotificationPresenter: NSObject, NotificationsProtocol { notificationBody = previewableInteraction.previewText(transaction: transaction) } + let isGroupCallMessage = previewableInteraction is OWSGroupCallMessage + let preferredDefaultAction: AppNotificationAction = isGroupCallMessage ? .showCallLobby : .showThread + let threadId = thread.uniqueId let userInfo = [ AppNotificationUserInfoKey.threadId: threadId, - AppNotificationUserInfoKey.messageId: previewableInteraction.uniqueId + AppNotificationUserInfoKey.messageId: previewableInteraction.uniqueId, + AppNotificationUserInfoKey.defaultAction: preferredDefaultAction.rawValue ] transaction.addAsyncCompletion { diff --git a/SignalMessaging/Notifications/UserNotificationsAdaptee.swift b/SignalMessaging/Notifications/UserNotificationsAdaptee.swift index 86d206c444..e98f9bba62 100644 --- a/SignalMessaging/Notifications/UserNotificationsAdaptee.swift +++ b/SignalMessaging/Notifications/UserNotificationsAdaptee.swift @@ -14,7 +14,7 @@ public class UserNotificationConfig { } class func notificationActions(for category: AppNotificationCategory) -> [UNNotificationAction] { - return category.actions.map { notificationAction($0) } + return category.actions.compactMap { notificationAction($0) } } class func notificationCategory(_ category: AppNotificationCategory) -> UNNotificationCategory { @@ -24,7 +24,7 @@ public class UserNotificationConfig { options: []) } - class func notificationAction(_ action: AppNotificationAction) -> UNNotificationAction { + class func notificationAction(_ action: AppNotificationAction) -> UNNotificationAction? { switch action { case .answerCall: return UNNotificationAction(identifier: action.identifier, @@ -56,11 +56,16 @@ public class UserNotificationConfig { return UNNotificationAction(identifier: action.identifier, title: MessageStrings.reactWithThumbsUpNotificationAction, options: []) + + case .showCallLobby: + // Currently, .showCallLobby is only used as a default action. + owsFailDebug("Show call lobby not supported as a UNNotificationAction") + return nil } } public class func action(identifier: String) -> AppNotificationAction? { - return AppNotificationAction.allCases.first { notificationAction($0).identifier == identifier } + return AppNotificationAction.allCases.first { notificationAction($0)?.identifier == identifier } } } diff --git a/SignalMessaging/Views/CommonStrings.swift b/SignalMessaging/Views/CommonStrings.swift index 81611138a4..63264f3e8c 100644 --- a/SignalMessaging/Views/CommonStrings.swift +++ b/SignalMessaging/Views/CommonStrings.swift @@ -171,6 +171,10 @@ public class NotificationStrings: NSObject { @objc static public let failedToSendBody = NSLocalizedString("SEND_FAILED_NOTIFICATION_BODY", comment: "notification body") + @objc + static public let groupCallSafetyNumberChangeBody = NSLocalizedString("GROUP_CALL_SAFETY_NUMBER_CHANGE_BODY", + comment: "notification body when a group call participant joins with an untrusted safety number") + @objc static public let incomingReactionFormat = NSLocalizedString("REACTION_INCOMING_NOTIFICATION_BODY_FORMAT", comment: "notification body. Embeds {{reaction emoji}}")