diff --git a/Signal/src/AppDelegate.m b/Signal/src/AppDelegate.m index 8ab1e588f5..8041caaae3 100644 --- a/Signal/src/AppDelegate.m +++ b/Signal/src/AppDelegate.m @@ -964,12 +964,22 @@ static void uncaughtExceptionHandler(NSException *exception) return [CallKitIdStore threadForCallKitId:handle]; } + NSData *_Nullable groupId = [CallKitCallManager decodeGroupIdFromIntentHandle:handle]; + if (groupId) { + __block TSGroupThread *thread = nil; + [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { + thread = [TSGroupThread fetchWithGroupId:groupId transaction:transaction]; + }]; + return thread; + } + for (PhoneNumber *phoneNumber in [PhoneNumber tryParsePhoneNumbersFromUserSpecifiedText:handle clientPhoneNumber:[TSAccountManager localNumber]]) { SignalServiceAddress *address = [[SignalServiceAddress alloc] initWithPhoneNumber:phoneNumber.toE164]; return [TSContactThread getOrCreateThreadWithContactAddress:address]; } + return nil; } diff --git a/Signal/src/Calls/CallService.swift b/Signal/src/Calls/CallService.swift index 220adc0d9a..a011d813ca 100644 --- a/Signal/src/Calls/CallService.swift +++ b/Signal/src/Calls/CallService.swift @@ -451,6 +451,14 @@ public final class CallService: LightweightCallManager { } } + func handleLocalHangupCall(_ call: SignalCall) { + if call.isIndividualCall { + individualCallService.handleLocalHangupCall(call) + } else { + terminate(call: call) + } + } + /** * Clean up any existing call state and get ready to receive a new call. */ @@ -564,13 +572,13 @@ public final class CallService: LightweightCallManager { func joinGroupCallIfNecessary(_ call: SignalCall) { owsAssertDebug(call.isGroupCall) - guard currentCall == nil || currentCall == call else { + let currentCall = self.currentCall + if currentCall == nil { + self.currentCall = call + } else if currentCall != call { return owsFailDebug("A call is already in progress") } - // The joined/joining call must always be the current call. - currentCall = call - // If we're not yet connected, connect now. This may happen if, for // example, the call ended unexpectedly. if call.groupCall.localDeviceState.connectionState == .notConnected { @@ -583,7 +591,14 @@ public final class CallService: LightweightCallManager { // If we're not yet joined, join now. In general, it's unexpected that // this method would be called when you're already joined, but it is // safe to do so. - if call.groupCall.localDeviceState.joinState == .notJoined { call.groupCall.join() } + if call.groupCall.localDeviceState.joinState == .notJoined { + call.groupCall.join() + // Group calls can get disconnected, but we don't count that as ending the call. + // So this call may have already been reported. + if call.systemState == .notReported { + callUIAdapter.startOutgoingCall(call: call) + } + } } @discardableResult @@ -842,7 +857,14 @@ extension CallService: CallObserver { } } - public func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) {} + public func groupCallRemoteDeviceStatesChanged(_ call: SignalCall) { + if call.groupCallRingState == .ringing && !call.groupCall.remoteDeviceStates.isEmpty { + // The first time someone joins after a ring, we need to mark the call accepted. + // (But if we didn't ring, the call will have already been marked accepted.) + callUIAdapter.recipientAcceptedCall(call) + } + } + public func groupCallPeekChanged(_ call: SignalCall) { guard let thread = call.thread as? TSGroupThread else { owsFailDebug("Invalid thread for call: \(call)") diff --git a/Signal/src/Calls/SignalCall.swift b/Signal/src/Calls/SignalCall.swift index 5d51aaeeeb..f5d7b86154 100644 --- a/Signal/src/Calls/SignalCall.swift +++ b/Signal/src/Calls/SignalCall.swift @@ -56,8 +56,8 @@ public class SignalCall: NSObject, CallManagerCallReference { public let audioActivity: AudioActivity - private var systemState: SystemState = .notReported - private enum SystemState { + private(set) var systemState: SystemState = .notReported + enum SystemState { case notReported case reported case removed @@ -107,6 +107,13 @@ public class SignalCall: NSObject, CallManagerCallReference { } } + public var isOutgoingAudioMuted: Bool { + switch mode { + case .individual(let call): return call.isMuted + case .group(let call): return call.isOutgoingAudioMuted + } + } + private(set) lazy var videoCaptureController = VideoCaptureController() // Should be used only on the main thread diff --git a/Signal/src/Calls/UserInterface/CallHeader.swift b/Signal/src/Calls/UserInterface/CallHeader.swift index 6be6e9d453..17106a273d 100644 --- a/Signal/src/Calls/UserInterface/CallHeader.swift +++ b/Signal/src/Calls/UserInterface/CallHeader.swift @@ -4,6 +4,7 @@ import Foundation import SignalRingRTC +import UIKit @objc protocol CallHeaderDelegate: AnyObject { @@ -122,6 +123,13 @@ class CallHeader: UIView { callTitleLabel.textColor = UIColor.white addShadow(to: callTitleLabel) +#if TESTABLE_BUILD + // For debugging purposes, make it easy to force the call to disconnect. + callTitleLabel.addGestureRecognizer(UILongPressGestureRecognizer(target: self, + action: #selector(injectDisconnect))) + callTitleLabel.isUserInteractionEnabled = true +#endif + vStack.addArrangedSubview(callTitleLabel) // Make the title view as wide as possible, but don't overlap either button. @@ -351,6 +359,12 @@ class CallHeader: UIView { groupMembersButton.updateMemberCount(remoteMemberCount + (isJoined ? 1 : 0)) } + // For testing abnormal scenarios. + @objc + private func injectDisconnect() { + call.groupCall.disconnect() + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } diff --git a/Signal/src/Calls/UserInterface/CallKit/CallKitCallManager.swift b/Signal/src/Calls/UserInterface/CallKit/CallKitCallManager.swift index 894166445d..53a767634d 100644 --- a/Signal/src/Calls/UserInterface/CallKit/CallKitCallManager.swift +++ b/Signal/src/Calls/UserInterface/CallKit/CallKitCallManager.swift @@ -21,6 +21,21 @@ final class CallKitCallManager: NSObject { @objc static let kAnonymousCallHandlePrefix = "Signal:" + static let kGroupCallHandlePrefix = "SignalGroup:" + + @objc + static func decodeGroupId(fromIntentHandle handle: String) -> Data? { + let prefix = handle.prefix(kGroupCallHandlePrefix.count) + guard prefix == kGroupCallHandlePrefix else { + return nil + } + do { + return try Data.data(fromBase64Url: String(handle[prefix.endIndex...])) + } catch { + // ignore the error + return nil + } + } required init(showNamesOnCallScreen: Bool) { AssertIsOnMainThread() @@ -39,12 +54,15 @@ final class CallKitCallManager: NSObject { if showNamesOnCallScreen { let type: CXHandle.HandleType let value: String - if let phoneNumber = call.individualCall.remoteAddress.phoneNumber { + if call.isGroupCall { + type = .generic + value = Self.kGroupCallHandlePrefix + call.thread.groupModelIfGroupThread!.groupId.asBase64Url + } else if let phoneNumber = call.individualCall.remoteAddress.phoneNumber { type = .phoneNumber value = phoneNumber } else { type = .generic - value = call.individualCall.remoteAddress.stringForDisplay + value = call.individualCall.remoteAddress.uuidString! } handle = CXHandle(type: type, value: value) } else { @@ -55,7 +73,14 @@ final class CallKitCallManager: NSObject { let startCallAction = CXStartCallAction(call: call.localId, handle: handle) - startCallAction.isVideo = call.individualCall.offerMediaType == .video + if call.isIndividualCall { + startCallAction.isVideo = call.individualCall.offerMediaType == .video + } else { + // All group calls are video calls even if the local video is off, + // but what we set here is how the call shows up in the system call log, + // which controls what happens if the user starts another call from the system call log. + startCallAction.isVideo = !call.groupCall.isOutgoingVideoMuted + } let transaction = CXTransaction() transaction.addAction(startCallAction) diff --git a/Signal/src/Calls/UserInterface/CallKit/CallKitCallUIAdaptee.swift b/Signal/src/Calls/UserInterface/CallKit/CallKitCallUIAdaptee.swift index 5964f300bf..c504e45e59 100644 --- a/Signal/src/Calls/UserInterface/CallKit/CallKitCallUIAdaptee.swift +++ b/Signal/src/Calls/UserInterface/CallKit/CallKitCallUIAdaptee.swift @@ -218,7 +218,7 @@ final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, CXProviderDelegate { // to unmute the call. We can work around this // by ignoring the next "unmute" request from // CallKit after the call is answered. - self.ignoreFirstUnmuteAfterRemoteAnswer = call.individualCall.isMuted + self.ignoreFirstUnmuteAfterRemoteAnswer = call.isOutgoingAudioMuted // Enable audio for remotely accepted calls after the session is configured. self.audioSession.isRTCAudioEnabled = true @@ -235,6 +235,11 @@ final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, CXProviderDelegate { AssertIsOnMainThread() Logger.info("") + guard call.systemState == .reported else { + callService.handleLocalHangupCall(call) + return + } + Self.providerReadyFlag.runNowOrWhenDidBecomeReadySync { self.callManager.localHangup(call: call) } @@ -346,7 +351,9 @@ final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, CXProviderDelegate { // We can't wait for long before fulfilling the CXAction, else CallKit will show a "Failed Call". We don't // actually need to wait for the outcome of the handleOutgoingCall promise, because it handles any errors by // manually failing the call. - self.callService.individualCallService.handleOutgoingCall(call) + if call.isIndividualCall { + self.callService.individualCallService.handleOutgoingCall(call) + } action.fulfill() provider.reportOutgoingCall(with: call.localId, startedConnectingAt: nil) @@ -354,11 +361,28 @@ final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, CXProviderDelegate { let update = CXCallUpdate() if showNamesOnCallScreen { update.localizedCallerName = contactsManager.displayNameWithSneakyTransaction(thread: call.thread) - } else { + } else if call.isIndividualCall { update.localizedCallerName = NSLocalizedString("CALLKIT_ANONYMOUS_CONTACT_NAME", comment: "The generic name used for calls if CallKit privacy is enabled") + } else { + update.localizedCallerName = NSLocalizedString("CALLKIT_ANONYMOUS_GROUP_NAME", + comment: "The generic name used for group calls if CallKit privacy is enabled") } provider.reportCall(with: call.localId, updated: update) + + if call.isGroupCall { + switch call.groupCallRingState { + case .shouldRing, .ringing: + // Let CallService call recipientAcceptedCall when someone joins. + break + case .ringingEnded: + Logger.warn("ringing ended before we even reported the call to CallKit (maybe our peek info was out of date)") + fallthrough + case .doNotRing: + // Immediately consider ourselves connected. + recipientAcceptedCall(call) + } + } } func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) { @@ -394,7 +418,7 @@ final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, CXProviderDelegate { return } - self.callService.individualCallService.handleLocalHangupCall(call) + callService.handleLocalHangupCall(call) // Signal to the system that the action has been successfully performed. action.fulfill() @@ -487,7 +511,7 @@ final class CallKitCallUIAdaptee: NSObject, CallUIAdaptee, CXProviderDelegate { return } - if call.individualCall.direction == .incoming { + if call.isIndividualCall && call.individualCall.direction == .incoming { // Only enable audio upon activation for locally accepted calls. self.audioSession.isRTCAudioEnabled = true } diff --git a/Signal/src/Calls/UserInterface/CallUIAdapter.swift b/Signal/src/Calls/UserInterface/CallUIAdapter.swift index 1e7eab6dd8..f3fd9d2e27 100644 --- a/Signal/src/Calls/UserInterface/CallUIAdapter.swift +++ b/Signal/src/Calls/UserInterface/CallUIAdapter.swift @@ -112,6 +112,9 @@ public class CallUIAdapter: NSObject, CallServiceObserver { var defaultAdaptee: CallUIAdaptee { callKitAdaptee ?? nonCallKitAdaptee } func adaptee(for call: SignalCall) -> CallUIAdaptee { + guard call.isIndividualCall else { + return defaultAdaptee + } switch call.individualCall.callAdapterType { case .nonCallKit: return nonCallKitAdaptee case .default: return defaultAdaptee diff --git a/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift b/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift index f39f30dc25..f5e64827f7 100644 --- a/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift +++ b/Signal/src/Calls/UserInterface/Group/GroupCallViewController.swift @@ -491,7 +491,7 @@ class GroupCallViewController: UIViewController { } func dismissCall() { - callService.terminate(call: call) + callService.callUIAdapter.localHangupCall(call) guard let splitViewSnapshot = SignalApp.shared().snapshotSplitViewController(afterScreenUpdates: false) else { OWSWindowManager.shared.endCall(self) diff --git a/Signal/src/Calls/UserInterface/NonCallKitCallUIAdaptee.swift b/Signal/src/Calls/UserInterface/NonCallKitCallUIAdaptee.swift index 7c83dbd1c0..eda319d281 100644 --- a/Signal/src/Calls/UserInterface/NonCallKitCallUIAdaptee.swift +++ b/Signal/src/Calls/UserInterface/NonCallKitCallUIAdaptee.swift @@ -29,7 +29,21 @@ class NonCallKitCallUIAdaptee: NSObject, CallUIAdaptee { let success = self.audioSession.startAudioActivity(call.audioActivity) assert(success) - self.callService.individualCallService.handleOutgoingCall(call) + if call.isIndividualCall { + self.callService.individualCallService.handleOutgoingCall(call) + } else { + switch call.groupCallRingState { + case .shouldRing, .ringing: + // Let CallService call recipientAcceptedCall when someone joins. + break + case .ringingEnded: + owsFailDebug("ringing ended while we were starting the call") + fallthrough + case .doNotRing: + // Immediately consider ourselves connected. + recipientAcceptedCall(call) + } + } } func reportIncomingCall(_ call: SignalCall, completion: @escaping (Error?) -> Void) { @@ -129,7 +143,7 @@ class NonCallKitCallUIAdaptee: NSObject, CallUIAdaptee { return } - self.callService.individualCallService.handleLocalHangupCall(call) + callService.handleLocalHangupCall(call) } internal func remoteDidHangupCall(_ call: SignalCall) { diff --git a/Signal/translations/en.lproj/Localizable.strings b/Signal/translations/en.lproj/Localizable.strings index 8559e66336..51672aea30 100644 --- a/Signal/translations/en.lproj/Localizable.strings +++ b/Signal/translations/en.lproj/Localizable.strings @@ -781,6 +781,9 @@ /* The generic name used for calls if CallKit privacy is enabled */ "CALLKIT_ANONYMOUS_CONTACT_NAME" = "Signal User"; +/* The generic name used for group calls if CallKit privacy is enabled */ +"CALLKIT_ANONYMOUS_GROUP_NAME" = "Signal Group"; + /* Accessibility hint describing what you can do with the camera button */ "CAMERA_BUTTON_HINT" = "Take a picture and then send it"; diff --git a/SignalServiceKit/src/Storage/AxolotlStore/CallKitIdStore.m b/SignalServiceKit/src/Storage/AxolotlStore/CallKitIdStore.m index 7600111b31..702a853237 100644 --- a/SignalServiceKit/src/Storage/AxolotlStore/CallKitIdStore.m +++ b/SignalServiceKit/src/Storage/AxolotlStore/CallKitIdStore.m @@ -19,23 +19,39 @@ NS_ASSUME_NONNULL_BEGIN return [[SDSKeyValueStore alloc] initWithCollection:@"TSStorageManagerCallKitIdToUUIDCollection"]; } ++ (SDSKeyValueStore *)groupIdStore +{ + return [[SDSKeyValueStore alloc] initWithCollection:@"TSStorageManagerCallKitIdToGroupId"]; +} + #pragma mark - + (void)setThread:(TSThread *)thread forCallKitId:(NSString *)callKitId { OWSAssertDebug(callKitId.length > 0); - OWSAssertDebug([thread isKindOfClass:[TSContactThread class]]); DatabaseStorageWrite(self.databaseStorage, ^(SDSAnyWriteTransaction *transaction) { - SignalServiceAddress *address = [(TSContactThread *)thread contactAddress]; - NSString *uuidString = address.uuidString; - if (uuidString) { - [self.uuidStore setString:uuidString key:callKitId transaction:transaction]; + TSGroupModel *groupModel = [thread groupModelIfGroupThread]; + if (groupModel) { + [self.groupIdStore setData:groupModel.groupId key:callKitId transaction:transaction]; + // This is probably overkill since we currently generate these IDs randomly, + // but better futureproof than sorry. + [self.uuidStore removeValueForKey:callKitId transaction:transaction]; [self.phoneNumberStore removeValueForKey:callKitId transaction:transaction]; } else { - OWSFailDebug(@"making a call to an address with no UUID: %@", address.phoneNumber); - [self.phoneNumberStore setString:address.phoneNumber key:callKitId transaction:transaction]; - [self.uuidStore removeValueForKey:callKitId transaction:transaction]; + OWSAssertDebug([thread isKindOfClass:[TSContactThread class]]); + SignalServiceAddress *address = [(TSContactThread *)thread contactAddress]; + NSString *uuidString = address.uuidString; + if (uuidString) { + [self.uuidStore setString:uuidString key:callKitId transaction:transaction]; + [self.phoneNumberStore removeValueForKey:callKitId transaction:transaction]; + } else { + OWSFailDebug(@"making a call to an address with no UUID: %@", address.phoneNumber); + [self.phoneNumberStore setString:address.phoneNumber key:callKitId transaction:transaction]; + [self.uuidStore removeValueForKey:callKitId transaction:transaction]; + } + + [self.groupIdStore removeValueForKey:callKitId transaction:transaction]; } }); } @@ -46,7 +62,7 @@ NS_ASSUME_NONNULL_BEGIN __block TSThread *_Nullable result; [self.databaseStorage readWithBlock:^(SDSAnyReadTransaction *transaction) { - // Check for an ACI first, then phone numbers. + // Most likely: modern 1:1 calls NSString *_Nullable uuidString = [self.uuidStore getString:callKitId transaction:transaction]; if (uuidString) { SignalServiceAddress *address = [[SignalServiceAddress alloc] initWithUuidString:uuidString]; @@ -54,6 +70,14 @@ NS_ASSUME_NONNULL_BEGIN return; } + // Next try group calls + NSData *_Nullable groupId = [self.groupIdStore getData:callKitId transaction:transaction]; + if (groupId) { + result = [TSGroupThread fetchWithGroupId:groupId transaction:transaction]; + return; + } + + // Finally check the phone number store, for very old 1:1 calls. NSString *_Nullable phoneNumber = [self.phoneNumberStore getString:callKitId transaction:transaction]; if (phoneNumber) { SignalServiceAddress *address = [[SignalServiceAddress alloc] initWithPhoneNumber:phoneNumber];