mirror of
https://github.com/Novaloop-AG/element-x-ios.git
synced 2026-10-31 08:15:24 +00:00
Report native calls that end without us to CallKit
The call package now says why a call ended. Only our own hang-up is an end call action; a call the other side declined or left, that nobody answered, or that failed is reported to CallKit with that reason, so the call log no longer shows it as one we ended, and no end action comes back to hang up a call that is already over. Also adopts the package's other host changes: the voice call icon beside "Calling…", and the call's large avatar rendered at the 140 pt it is drawn at. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -5317,6 +5317,37 @@ nonisolated class ElementCallServiceMock: ElementCallServiceProtocol, @unchecked
|
|||||||
tearDownCallSessionRoomIDReceivedInvocationsLock.withLock { tearDownCallSessionRoomIDUnderlyingReceivedInvocations.append(roomID) }
|
tearDownCallSessionRoomIDReceivedInvocationsLock.withLock { tearDownCallSessionRoomIDUnderlyingReceivedInvocations.append(roomID) }
|
||||||
tearDownCallSessionRoomIDClosure?(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
|
//MARK: - setAudioEnabled
|
||||||
|
|
||||||
private let setAudioEnabledRoomIDCallsCountLock = NSLock()
|
private let setAudioEnabledRoomIDCallsCountLock = NSLock()
|
||||||
|
|||||||
@@ -90,6 +90,8 @@ enum UserAvatarSizeOnScreen {
|
|||||||
case map
|
case map
|
||||||
case classicAppAccount
|
case classicAppAccount
|
||||||
case search
|
case search
|
||||||
|
/// The native call's large avatar: a one-to-one call, the spotlight and the ringing screen.
|
||||||
|
case call
|
||||||
|
|
||||||
var value: CGFloat {
|
var value: CGFloat {
|
||||||
switch self {
|
switch self {
|
||||||
@@ -122,6 +124,8 @@ enum UserAvatarSizeOnScreen {
|
|||||||
75
|
75
|
||||||
case .memberDetails, .editUserDetails, .classicAppAccount:
|
case .memberDetails, .editUserDetails, .classicAppAccount:
|
||||||
96
|
96
|
||||||
|
case .call:
|
||||||
|
140
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -244,6 +244,21 @@ class ElementCallService: NSObject, ElementCallServiceProtocol, PKPushRegistryDe
|
|||||||
MXLog.info("Not tearing down the call session, no call for room \(roomID)")
|
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) {
|
func setAudioEnabled(_ enabled: Bool, roomID: String) {
|
||||||
guard let ongoingCallID else {
|
guard let ongoingCallID else {
|
||||||
MXLog.error("Failed toggling call microphone, no calls running")
|
MXLog.error("Failed toggling call microphone, no calls running")
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CallKit
|
||||||
import Combine
|
import Combine
|
||||||
import ElementCall
|
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.
|
/// Ends the call for the room, including an answered call the native stack never took over.
|
||||||
func tearDownCallSession(roomID: String)
|
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)
|
func setAudioEnabled(_ enabled: Bool, roomID: String)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,6 +106,7 @@ struct NativeCallIcons: ElementCallIconRenderingProtocol {
|
|||||||
case .micOff: \.micOffSolid
|
case .micOff: \.micOffSolid
|
||||||
case .videoCall: \.videoCallSolid
|
case .videoCall: \.videoCallSolid
|
||||||
case .videoCallOff: \.videoCallOffSolid
|
case .videoCallOff: \.videoCallOffSolid
|
||||||
|
case .voiceCall: \.voiceCallSolid
|
||||||
case .switchCamera: \.switchCameraSolid
|
case .switchCamera: \.switchCameraSolid
|
||||||
case .shareScreen: \.shareScreenSolid
|
case .shareScreen: \.shareScreenSolid
|
||||||
case .volumeOn: \.volumeOnSolid
|
case .volumeOn: \.volumeOnSolid
|
||||||
@@ -148,7 +149,7 @@ struct NativeCallAvatars: ElementCallAvatarRenderingProtocol {
|
|||||||
AnyView(LoadableAvatarImage(url: avatarURL,
|
AnyView(LoadableAvatarImage(url: avatarURL,
|
||||||
name: displayName,
|
name: displayName,
|
||||||
contentID: userID,
|
contentID: userID,
|
||||||
avatarSize: size == .thumbnail ? .user(on: .roomDetails) : .user(on: .memberDetails),
|
avatarSize: size == .thumbnail ? .user(on: .roomDetails) : .user(on: .call),
|
||||||
mediaProvider: mediaProvider))
|
mediaProvider: mediaProvider))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Please see LICENSE files in the repository root for full details.
|
// Please see LICENSE files in the repository root for full details.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import CallKit
|
||||||
import Combine
|
import Combine
|
||||||
import ElementCall
|
import ElementCall
|
||||||
|
|
||||||
@@ -55,8 +56,19 @@ final class NativeCallSystem: ElementCallSystemProvidingProtocol {
|
|||||||
service?.reportCallSessionConnected(roomID: roomID)
|
service?.reportCallSessionConnected(roomID: roomID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func endCall(roomID: String) {
|
/// Only our own hang-up is an end call action; the rest the system hears as the call ending on
|
||||||
service?.tearDownCallSession(roomID: roomID)
|
/// 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) {
|
func setMicrophoneEnabled(_ enabled: Bool, roomID: String) {
|
||||||
|
|||||||
@@ -295,6 +295,26 @@ final class ElementCallServiceTests {
|
|||||||
#expect(service.ongoingCallRoomIDPublisher.value == nil)
|
#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
|
@Test
|
||||||
func tearingDownAnotherRoomLeavesTheOngoingCallUp() async {
|
func tearingDownAnotherRoomLeavesTheOngoingCallUp() async {
|
||||||
// A call screen that is dismissed after another room's call replaced it must not take the
|
// A call screen that is dismissed after another room's call replaced it must not take the
|
||||||
|
|||||||
Reference in New Issue
Block a user