Report group calls to CallKit like we do 1:1 calls

This commit is contained in:
Jordan Rose 2022-09-19 18:19:35 -07:00
parent 687779e80f
commit aeecb19d2a
11 changed files with 174 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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