diff --git a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift index f113ba420..5fc527511 100644 --- a/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift +++ b/ElementX/Sources/Mocks/Generated/GeneratedMocks.swift @@ -5317,6 +5317,37 @@ nonisolated class ElementCallServiceMock: ElementCallServiceProtocol, @unchecked tearDownCallSessionRoomIDReceivedInvocationsLock.withLock { tearDownCallSessionRoomIDUnderlyingReceivedInvocations.append(roomID) } tearDownCallSessionRoomIDClosure?(roomID) } + //MARK: - reportCallSessionEnded + + private let reportCallSessionEndedRoomIDReasonCallsCountLock = NSLock() + private nonisolated(unsafe) var reportCallSessionEndedRoomIDReasonUnderlyingCallsCount = 0 + var reportCallSessionEndedRoomIDReasonCallsCount: Int { + get { reportCallSessionEndedRoomIDReasonCallsCountLock.withLock { reportCallSessionEndedRoomIDReasonUnderlyingCallsCount } } + set { reportCallSessionEndedRoomIDReasonCallsCountLock.withLock { reportCallSessionEndedRoomIDReasonUnderlyingCallsCount = newValue } } + } + var reportCallSessionEndedRoomIDReasonCalled: Bool { + return reportCallSessionEndedRoomIDReasonCallsCount > 0 + } + private let reportCallSessionEndedRoomIDReasonReceivedArgumentsLock = NSLock() + private nonisolated(unsafe) var reportCallSessionEndedRoomIDReasonUnderlyingReceivedArguments: (roomID: String, reason: CXCallEndedReason)? + var reportCallSessionEndedRoomIDReasonReceivedArguments: (roomID: String, reason: CXCallEndedReason)? { + get { reportCallSessionEndedRoomIDReasonReceivedArgumentsLock.withLock { reportCallSessionEndedRoomIDReasonUnderlyingReceivedArguments } } + set { reportCallSessionEndedRoomIDReasonReceivedArgumentsLock.withLock { reportCallSessionEndedRoomIDReasonUnderlyingReceivedArguments = newValue } } + } + private let reportCallSessionEndedRoomIDReasonReceivedInvocationsLock = NSLock() + private nonisolated(unsafe) var reportCallSessionEndedRoomIDReasonUnderlyingReceivedInvocations: [(roomID: String, reason: CXCallEndedReason)] = [] + var reportCallSessionEndedRoomIDReasonReceivedInvocations: [(roomID: String, reason: CXCallEndedReason)] { + get { reportCallSessionEndedRoomIDReasonReceivedInvocationsLock.withLock { reportCallSessionEndedRoomIDReasonUnderlyingReceivedInvocations } } + set { reportCallSessionEndedRoomIDReasonReceivedInvocationsLock.withLock { reportCallSessionEndedRoomIDReasonUnderlyingReceivedInvocations = newValue } } + } + nonisolated(unsafe) var reportCallSessionEndedRoomIDReasonClosure: ((String, CXCallEndedReason) -> Void)? + + func reportCallSessionEnded(roomID: String, reason: CXCallEndedReason) { + reportCallSessionEndedRoomIDReasonCallsCountLock.withLock { reportCallSessionEndedRoomIDReasonUnderlyingCallsCount += 1 } + reportCallSessionEndedRoomIDReasonReceivedArguments = (roomID: roomID, reason: reason) + reportCallSessionEndedRoomIDReasonReceivedInvocationsLock.withLock { reportCallSessionEndedRoomIDReasonUnderlyingReceivedInvocations.append((roomID: roomID, reason: reason)) } + reportCallSessionEndedRoomIDReasonClosure?(roomID, reason) + } //MARK: - setAudioEnabled private let setAudioEnabledRoomIDCallsCountLock = NSLock() diff --git a/ElementX/Sources/Other/Avatars.swift b/ElementX/Sources/Other/Avatars.swift index e55c2cfa6..4e7ae7032 100644 --- a/ElementX/Sources/Other/Avatars.swift +++ b/ElementX/Sources/Other/Avatars.swift @@ -90,6 +90,8 @@ enum UserAvatarSizeOnScreen { case map case classicAppAccount case search + /// The native call's large avatar: a one-to-one call, the spotlight and the ringing screen. + case call var value: CGFloat { switch self { @@ -122,6 +124,8 @@ enum UserAvatarSizeOnScreen { 75 case .memberDetails, .editUserDetails, .classicAppAccount: 96 + case .call: + 140 } } } diff --git a/ElementX/Sources/Services/ElementCall/ElementCallService.swift b/ElementX/Sources/Services/ElementCall/ElementCallService.swift index 37c6b93f8..3343081f8 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallService.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallService.swift @@ -244,6 +244,21 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe MXLog.info("Not tearing down the call session, no call for room \(roomID)") } + func reportCallSessionEnded(roomID: String, reason: CXCallEndedReason) { + guard let ongoingCallID, ongoingCallID.roomID == roomID else { + MXLog.info("Not reporting the end of a call for \(roomID), it isn't the ongoing call") + return + } + + // A web view call is not tracked by CallKit, as in `reportCallSessionConnected`. + if ongoingCallID.isNative { + MXLog.info("Reporting the call as ended without us: \(reason.rawValue)") + callProvider.reportCall(with: ongoingCallID.callKitID, endedAt: nil, reason: reason) + } + + self.ongoingCallID = nil + } + func setAudioEnabled(_ enabled: Bool, roomID: String) { guard let ongoingCallID else { MXLog.error("Failed toggling call microphone, no calls running") diff --git a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift index 7f98a5c05..a0e4abfcb 100644 --- a/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift +++ b/ElementX/Sources/Services/ElementCall/ElementCallServiceProtocol.swift @@ -6,6 +6,7 @@ // Please see LICENSE files in the repository root for full details. // +import CallKit import Combine import ElementCall @@ -66,5 +67,10 @@ protocol ElementCallServiceProtocol: AnyObject { /// Ends the call for the room, including an answered call the native stack never took over. func tearDownCallSession(roomID: String) + /// The call for the room ended without us: declined, unanswered, the other side gone, or failed. + /// Reported to the system as such rather than as an end call action, which would log it as one + /// we hung up and ask the call stack to end a call it already has. + func reportCallSessionEnded(roomID: String, reason: CXCallEndedReason) + func setAudioEnabled(_ enabled: Bool, roomID: String) } diff --git a/ElementX/Sources/Services/NativeCall/NativeCallStyle.swift b/ElementX/Sources/Services/NativeCall/NativeCallStyle.swift index c8eb92cff..4cae514a0 100644 --- a/ElementX/Sources/Services/NativeCall/NativeCallStyle.swift +++ b/ElementX/Sources/Services/NativeCall/NativeCallStyle.swift @@ -106,6 +106,7 @@ struct NativeCallIcons: ElementCallIconRenderingProtocol { case .micOff: \.micOffSolid case .videoCall: \.videoCallSolid case .videoCallOff: \.videoCallOffSolid + case .voiceCall: \.voiceCallSolid case .switchCamera: \.switchCameraSolid case .shareScreen: \.shareScreenSolid case .volumeOn: \.volumeOnSolid @@ -148,7 +149,7 @@ struct NativeCallAvatars: ElementCallAvatarRenderingProtocol { AnyView(LoadableAvatarImage(url: avatarURL, name: displayName, contentID: userID, - avatarSize: size == .thumbnail ? .user(on: .roomDetails) : .user(on: .memberDetails), + avatarSize: size == .thumbnail ? .user(on: .roomDetails) : .user(on: .call), mediaProvider: mediaProvider)) } } diff --git a/ElementX/Sources/Services/NativeCall/NativeCallSystem.swift b/ElementX/Sources/Services/NativeCall/NativeCallSystem.swift index f26f11d38..0d92adad2 100644 --- a/ElementX/Sources/Services/NativeCall/NativeCallSystem.swift +++ b/ElementX/Sources/Services/NativeCall/NativeCallSystem.swift @@ -5,6 +5,7 @@ // Please see LICENSE files in the repository root for full details. // +import CallKit import Combine import ElementCall @@ -55,8 +56,19 @@ final class NativeCallSystem: ElementCallSystemProvidingProtocol { service?.reportCallSessionConnected(roomID: roomID) } - func endCall(roomID: String) { - service?.tearDownCallSession(roomID: roomID) + /// Only our own hang-up is an end call action; the rest the system hears as the call ending on + /// its own, so the call log does not show a declined or unanswered call as one we ended. + func endCall(roomID: String, reason: ElementCallEndReason) { + switch reason { + case .local: + service?.tearDownCallSession(roomID: roomID) + case .remoteEnded: + service?.reportCallSessionEnded(roomID: roomID, reason: .remoteEnded) + case .unanswered: + service?.reportCallSessionEnded(roomID: roomID, reason: .unanswered) + case .failed: + service?.reportCallSessionEnded(roomID: roomID, reason: .failed) + } } func setMicrophoneEnabled(_ enabled: Bool, roomID: String) { diff --git a/UnitTests/Sources/ElementCallServiceTests.swift b/UnitTests/Sources/ElementCallServiceTests.swift index 1c04e6b78..9856cf009 100644 --- a/UnitTests/Sources/ElementCallServiceTests.swift +++ b/UnitTests/Sources/ElementCallServiceTests.swift @@ -295,6 +295,26 @@ final class ElementCallServiceTests { #expect(service.ongoingCallRoomIDPublisher.value == nil) } + /// A declined or unanswered call is the system's to log as such: reported with its reason, and + /// never as an end call action, which would log it as one we hung up. + @Test + func aCallThatEndedWithoutUsIsReportedRatherThanEnded() async throws { + enableNativeCalls() + await service.setupCallSession(roomID: "!room:example.com", roomDisplayName: "welcome", isVideo: true) + let callID = try #require(callProvider.reportCallWithUpdatedReceivedArguments?.uuid) + let requestsBeforeTheEnd = callController.requestCompletionCallsCount + + service.reportCallSessionEnded(roomID: "!other:example.com", reason: .unanswered) + #expect(!callProvider.reportCallWithEndedAtReasonCalled, "Another room's end is not this call's") + + service.reportCallSessionEnded(roomID: "!room:example.com", reason: .unanswered) + + #expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.uuid == callID) + #expect(callProvider.reportCallWithEndedAtReasonReceivedArguments?.reason == .unanswered) + #expect(callController.requestCompletionCallsCount == requestsBeforeTheEnd) + #expect(service.ongoingCallRoomIDPublisher.value == nil) + } + @Test func tearingDownAnotherRoomLeavesTheOngoingCallUp() async { // A call screen that is dismissed after another room's call replaced it must not take the