Alert user of safety number changes in group call

- Present SafetyNumberConfirmationSheet for all group members before
  joining call from lobby.
- Present SafetyNumberConfirmationSheet for newly joined participants
  with a safety number mismatch
- If the call is minimized or backgrounded, present a notification.
This commit is contained in:
Michelle Linington 2020-12-08 00:51:40 -08:00
parent de37b1dd4a
commit eaef20b780
10 changed files with 262 additions and 31 deletions

View File

@ -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")

View File

@ -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
}
}

View File

@ -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".

View File

@ -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()
}
}
}
}

View File

@ -113,23 +113,27 @@ class NotificationActionHandler {
return firstly { () -> Promise<NotificationMessage> 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<Void> {
return firstly { () -> Promise<NotificationMessage> 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?

View File

@ -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)
}
}
}

View File

@ -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";

View File

@ -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 {

View File

@ -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 }
}
}

View File

@ -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}}")