From c4397971cb31d50c0e93aee7919bbccfb9fb9c38 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Sun, 5 Jul 2026 21:58:42 +0200 Subject: [PATCH 1/6] feat: Add re-negotiation to upgrade voice -> video MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Marcel Müller --- NextcloudTalk/Calls/CallKitManager.swift | 6 + NextcloudTalk/Calls/CallViewController.swift | 44 +++++- NextcloudTalk/Calls/NCCallController.swift | 131 +++++++++++++++++- NextcloudTalk/Network/NCAPIController.swift | 13 ++ .../NCExternalSignalingController.swift | 20 ++- NextcloudTalk/WebRTC/NCPeerConnection.swift | 60 ++++++-- 6 files changed, 246 insertions(+), 28 deletions(-) diff --git a/NextcloudTalk/Calls/CallKitManager.swift b/NextcloudTalk/Calls/CallKitManager.swift index e82dffdbd..3094ca85a 100644 --- a/NextcloudTalk/Calls/CallKitManager.swift +++ b/NextcloudTalk/Calls/CallKitManager.swift @@ -312,6 +312,12 @@ public class CallKitManager: NSObject, CXProviderDelegate { self.provider.reportCall(with: uuid, updated: update) } + public func reportCallUpgradedToVideoCall(forCall token: String) { + guard let call = self.call(forToken: token) else { return } + + self.updateCall(call, hasVideo: true) + } + private func stopHangUpTimer(forCallUUID uuid: UUID) { if let hangUpTimer = self.hangUpTimers[uuid] { hangUpTimer.invalidate() diff --git a/NextcloudTalk/Calls/CallViewController.swift b/NextcloudTalk/Calls/CallViewController.swift index 55db91d70..c53266d96 100644 --- a/NextcloudTalk/Calls/CallViewController.swift +++ b/NextcloudTalk/Calls/CallViewController.swift @@ -2101,7 +2101,12 @@ class CallViewController: UIViewController, func showUpgradeToVideoCallDialog() { let confirmTitle = NSLocalizedString("Do you want to enable your camera?", comment: "") - let confirmMessage = NSLocalizedString("If you enable your camera, this call will be interrupted for a few seconds.", comment: "") + var confirmMessage: String? + + // Without renegotiation support the call needs to be restarted to be upgraded to a video call + if callController?.supportsCallUpgradeUsingRenegotiation != true { + confirmMessage = NSLocalizedString("If you enable your camera, this call will be interrupted for a few seconds.", comment: "") + } let confirmDialog = UIAlertController(title: confirmTitle, message: confirmMessage, preferredStyle: .alert) confirmDialog.addAction(UIAlertAction(title: NSLocalizedString("Enable", comment: ""), style: .default) { _ in @@ -2113,8 +2118,41 @@ class CallViewController: UIViewController, } func upgradeToVideoCall() { - self.videoCallUpgrade = true - self.hangup(forAll: false) + if callController?.supportsCallUpgradeUsingRenegotiation == true { + self.upgradeToVideoCallUsingRenegotiation() + } else { + // Without renegotiation support the call needs to be restarted with video enabled + self.videoCallUpgrade = true + self.hangup(forAll: false) + } + } + + func upgradeToVideoCallUsingRenegotiation() { + guard let callController else { return } + + let authStatus = AVCaptureDevice.authorizationStatus(for: .video) + + if authStatus == .notDetermined { + AVCaptureDevice.requestAccess(for: .video) { _ in + DispatchQueue.main.async { + self.upgradeToVideoCallUsingRenegotiation() + } + } + + return + } + + self.isAudioOnly = false + + // In voice only calls the video disable button was disabled when no local video track was + // created (see didCreateLocalVideoTrack delegate), so it needs to be enabled again + if callController.isCameraAccessAvailable(), room.canPublishVideo { + self.userDisabledVideo = false + self.setVideoDisableButtonEnabled(true) + } + + callController.upgradeToVideoCall() + self.adjustBars() } @IBAction func toggleChatButtonPressed(_ sender: Any) { diff --git a/NextcloudTalk/Calls/NCCallController.swift b/NextcloudTalk/Calls/NCCallController.swift index 2c405ad8c..721981e74 100644 --- a/NextcloudTalk/Calls/NCCallController.swift +++ b/NextcloudTalk/Calls/NCCallController.swift @@ -48,7 +48,7 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling private let room: NCRoom private let account: TalkAccount private let userSessionId: String - private let isAudioOnly: Bool + public private(set) var isAudioOnly: Bool // TODO: Default true? public var disableAudioAtStart: Bool = false @@ -454,6 +454,79 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling self.cameraController?.switchCamera() } + // Upgrading a voice only call using renegotiation is only supported when using a MCU + // and the signaling server supports updating existing connections ("update-sdp"). + // Otherwise the call needs to be restarted to be upgraded to a video call. + public var supportsCallUpgradeUsingRenegotiation: Bool { + guard let externalSignalingController else { return false } + + return externalSignalingController.hasMCU && externalSignalingController.hasUpdateSdp + } + + public func upgradeToVideoCall() { + WebRTCCommon.shared.dispatch { + guard self.isAudioOnly, self.supportsCallUpgradeUsingRenegotiation else { return } + + NCLog.log("Upgrading voice only call to video call using renegotiation") + + self.isAudioOnly = false + self.disableVideoAtStart = false + + NCAudioController.shared.setAudioSessionToVideoChatMode() + + // Create the local video track, since it is not created in voice only calls + if self.localVideoTrack == nil, self.room.canPublishVideo, self.isCameraAccessAvailable() { + self.createLocalVideoTrack() + } + + if let localVideoTrack = self.localVideoTrack { + if let peerConnection = self.publisherPeerConnection?.getPeerConnection() { + if let videoTransceiver = peerConnection.transceivers.first(where: { $0.mediaType == .video }) { + // The video m-line was already negotiated with a placeholder transceiver when + // the publisher peer connection was created, so the video track can start to + // be sent right away, without any renegotiation + videoTransceiver.sender.track = localVideoTrack + } else { + // Fallback: adding the video track triggers a renegotiation of the publisher + // peer connection ("negotiationneeded" -> updated offer with the same sid) + peerConnection.add(localVideoTrack, streamIds: [NCCallController.kNCMediaStreamId]) + } + } else { + // There was no publisher peer connection (e.g. no microphone access), create it now + self.createPublisherPeerConnection() + } + } + + // Update the existing receiver peer connections, so remote videos can be received. + // Requesting an offer with the "sid" of the existing connection makes the MCU update + // that connection with a renegotiation offer instead of creating a new one. + for (_, peerConnectionWrapper) in self.connectionsDict { + guard !peerConnectionWrapper.isMCUPublisherPeer, !peerConnectionWrapper.isDummyPeer, + peerConnectionWrapper.roomType == kRoomTypeVideo + else { continue } + + peerConnectionWrapper.isAudioOnly = false + self.requestOfferWithRepetition(forSessionId: peerConnectionWrapper.peerId, withRoomType: kRoomTypeVideo, withSid: peerConnectionWrapper.sid) + } + + // Let the other participants know that we are now in the call with video + self.updateCallFlagsInServer() + self.startSendingCurrentState() + + if CallKitManager.isCallKitAvailable() { + CallKitManager.sharedInstance().reportCallUpgradedToVideoCall(forCall: self.room.token) + } + } + } + + private func updateCallFlagsInServer() { + NCAPIController.shared.updateCallFlags(inRoom: self.room.token, withCallFlags: self.joinCallFlags, forAccount: self.account) { error in + if let error { + NCLog.log("Could not update call flags. Error: \(error)") + } + } + } + public func enableVideo(_ enable: Bool) { WebRTCCommon.shared.dispatch { if enable { @@ -1025,6 +1098,15 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling if let localVideoTrack { peerConnection.add(localVideoTrack, streamIds: [NCCallController.kNCMediaStreamId]) + } else if self.room.canPublishVideo { + // In voice only calls, already negotiate a placeholder video m-line (a transceiver + // without a track), so the call can later be upgraded to a video call by just replacing + // the sender track. A renegotiation that adds a new m-line to an existing publisher + // peer connection completes on the SDP level, but the MCU never relays its media. + let transceiverInit = RTCRtpTransceiverInit() + transceiverInit.direction = .sendOnly + transceiverInit.streamIds = [NCCallController.kNCMediaStreamId] + peerConnection.addTransceiver(of: .video, init: transceiverInit) } peerConnectionWrapper.sendPublisherOffer() @@ -1060,24 +1142,42 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling peerConnectionWrapper.sendPublisherOffer() } - public func requestOfferWithRepetition(forSessionId sessionId: String, withRoomType roomType: String) { + public func requestOfferWithRepetition(forSessionId sessionId: String, withRoomType roomType: String, withSid sid: String? = nil) { WebRTCCommon.shared.assertQueue() + let peerKey = self.getPeerKey(withSessionId: sessionId, ofType: roomType, forOwnScreenshare: false) + + if let previousOfferTimer = self.pendingOffersDict[peerKey], previousOfferTimer.isValid { + // If a connection needs to be updated ("sid" is set) but another offer request is already + // pending, ignore the new update. A new connection needs to be requested even if there + // is another one already pending. + if sid != nil { + return + } + + DispatchQueue.main.async { + previousOfferTimer.invalidate() + } + } + let timeout = Int(Date().timeIntervalSince1970 + 60) - let userInfo: [String: Any] = [ + var userInfo: [String: Any] = [ "sessionId": sessionId, "roomType": roomType, "timeout": timeout ] + if let sid { + userInfo["sid"] = sid + } + let pendingOfferTimer = Timer(timeInterval: 8.0, target: self, selector: #selector(self.requestNewOffer), userInfo: userInfo, repeats: true) - let peerKey = self.getPeerKey(withSessionId: sessionId, ofType: roomType, forOwnScreenshare: false) self.pendingOffersDict[peerKey] = pendingOfferTimer // Request new offer if let externalSignalingController { - externalSignalingController.requestOffer(forSessionId: sessionId, andRoomType: roomType) + externalSignalingController.requestOffer(forSessionId: sessionId, andRoomType: roomType, withSid: sid) } DispatchQueue.main.async { @@ -1093,10 +1193,12 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling let externalSignalingController else { return } + let sid = userInfo["sid"] as? String + WebRTCCommon.shared.dispatch { if Int(Date().timeIntervalSince1970) < timeout { print("Re-requesting an offer to session \(sessionId)") - externalSignalingController.requestOffer(forSessionId: sessionId, andRoomType: roomType) + externalSignalingController.requestOffer(forSessionId: sessionId, andRoomType: roomType, withSid: sid) } else { DispatchQueue.main.async { timer.invalidate() @@ -1323,6 +1425,8 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling } func peerConnection(_ peerConnection: NCPeerConnection, didRemove stream: RTCMediaStream?) { + // The stream is nil when a receiver was removed (e.g. when a video transceiver + // was set to inactive in an audio only call) guard !peerConnection.isMCUPublisherPeer, let stream else { return } self.delegate?.callController(self, didRemoveStream: stream, ofPeer: peerConnection) @@ -1425,6 +1529,21 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling self.delegate?.callController(self, didReceiveNick: nick, fromPeer: peerConnection) } + func peerConnectionNeedsRenegotiation(_ peerConnection: NCPeerConnection) { + WebRTCCommon.shared.assertQueue() + + // Local tracks are only added to (or removed from) an established publisher peer connection, + // so this is the only renegotiation triggered from our side that needs to be handled. + // The MCU updates the existing connection when it receives an offer with the same "sid". + guard peerConnection.isMCUPublisherPeer, let externalSignalingController, externalSignalingController.hasMCU else { + NCLog.log("Renegotiation needed for peer \(peerConnection.peerIdentifier), but it is not supported -> ignoring") + return + } + + NCLog.log("Renegotiating publisher peer connection") + peerConnection.sendPublisherOffer() + } + // MARK: - Signaling functions private func processSignalingMessage(_ signalingMessage: NCSignalingMessage?) { diff --git a/NextcloudTalk/Network/NCAPIController.swift b/NextcloudTalk/Network/NCAPIController.swift index f217c7674..88c29cc2e 100644 --- a/NextcloudTalk/Network/NCAPIController.swift +++ b/NextcloudTalk/Network/NCAPIController.swift @@ -1924,6 +1924,19 @@ class NCAPIController: NSObject, NKCommonDelegate { } } + @discardableResult + public func updateCallFlags(inRoom token: String, withCallFlags flags: CallFlag, forAccount account: TalkAccount, completionBlock: @escaping (_ error: OcsError?) -> Void) -> URLSessionDataTask? { + guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId), + let encodedToken = token.addingPercentEncoding(withAllowedCharacters: .urlHostAllowed) + else { return nil } + + let urlString = self.getRequestURL(forEndpoint: "call/\(encodedToken)", withAPIType: .call, forAccount: account) + + return apiSessionManager.putOcs(urlString, account: account, parameters: ["flags": flags.rawValue]) { _, ocsError in + completionBlock(ocsError) + } + } + @discardableResult public func leaveCall(inRoom token: String, forAllParticipants allParticipants: Bool, forAccount account: TalkAccount, completionBlock: @escaping (_ error: OcsError?) -> Void) -> URLSessionDataTask? { guard let apiSessionManager = self.getAPISessionManager(forAccountId: account.accountId), diff --git a/NextcloudTalk/WebRTC/NCExternalSignalingController.swift b/NextcloudTalk/WebRTC/NCExternalSignalingController.swift index 1c1325dec..5ad72f843 100644 --- a/NextcloudTalk/WebRTC/NCExternalSignalingController.swift +++ b/NextcloudTalk/WebRTC/NCExternalSignalingController.swift @@ -46,6 +46,7 @@ public enum NCExternalSignalingSendMessageStatus { public private(set) var account: TalkAccount public private(set) var disconnected: Bool = true public private(set) var hasMCU: Bool = false + public private(set) var hasUpdateSdp: Bool = false public private(set) var hasChatRelay: Bool = false public private(set) var sessionId: String? public private(set) var participantsMap = [String: SignalingParticipant]() @@ -348,6 +349,7 @@ public enum NCExternalSignalingSendMessageStatus { } self.hasMCU = serverFeatures.contains(where: { $0 == "mcu" }) + self.hasUpdateSdp = serverFeatures.contains(where: { $0 == "update-sdp" }) self.hasChatRelay = serverFeatures.contains(where: { $0 == "chat-relay" }) DispatchQueue.main.async { @@ -499,7 +501,18 @@ public enum NCExternalSignalingSendMessageStatus { self.send(message: messageDict, withCompletionBlock: nil) } - func requestOffer(forSessionId sessionId: String, andRoomType roomType: String) { + func requestOffer(forSessionId sessionId: String, andRoomType roomType: String, withSid sid: String? = nil) { + // When a "sid" of an existing connection is provided (and the server supports "update-sdp"), + // the MCU updates that connection with a renegotiation offer instead of creating a new one + var dataDict: [AnyHashable: Any] = [ + "type": "requestoffer", + "roomType": roomType + ] + + if let sid { + dataDict["sid"] = sid + } + let messageDict: [AnyHashable: Any] = [ "type": "message", "message": [ @@ -507,10 +520,7 @@ public enum NCExternalSignalingSendMessageStatus { "type": "session", "sessionid": sessionId ], - "data": [ - "type": "requestoffer", - "roomType": roomType - ] + "data": dataDict ] ] diff --git a/NextcloudTalk/WebRTC/NCPeerConnection.swift b/NextcloudTalk/WebRTC/NCPeerConnection.swift index 732f6fedf..f9793d12a 100644 --- a/NextcloudTalk/WebRTC/NCPeerConnection.swift +++ b/NextcloudTalk/WebRTC/NCPeerConnection.swift @@ -28,6 +28,10 @@ import WebRTC /// Called when a peer connection creates a session description. func peerConnection(_ peerConnection: NCPeerConnection, needsToSend sessionDescription: RTCSessionDescription) + + /// Called when an already established peer connection needs to be renegotiated + /// (e.g. after a local track was added or removed). + func peerConnectionNeedsRenegotiation(_ peerConnection: NCPeerConnection) } public class NCPeerConnection: NSObject { @@ -59,6 +63,7 @@ public class NCPeerConnection: NSObject { } private var queuedRemoteCandidates: [RTCIceCandidate]? + private var completedInitialNegotiation = false private var peerConnection: RTCPeerConnection? private var localDataChannel: RTCDataChannel? private var remoteDataChannel: RTCDataChannel? @@ -176,10 +181,13 @@ public class NCPeerConnection: NSObject { WebRTCCommon.shared.assertQueue() // Create data channel before creating the offer to enable data channels - let config = RTCDataChannelConfiguration() - config.isNegotiated = false - localDataChannel = peerConnection?.dataChannel(forLabel: "status", configuration: config) - localDataChannel?.delegate = self + // Skip if it already exists, when sending a renegotiation offer on an established connection + if localDataChannel == nil { + let config = RTCDataChannelConfiguration() + config.isNegotiated = false + localDataChannel = peerConnection?.dataChannel(forLabel: "status", configuration: config) + localDataChannel?.delegate = self + } // Create offer peerConnection?.offer(for: constraints) { [weak self] sdp, error in @@ -353,17 +361,27 @@ public class NCPeerConnection: NSObject { // If we just set a remote offer we need to create an answer and set it as local description. if peerConnection?.signalingState == .haveRemoteOffer { // Create data channel before sending answer - let config = RTCDataChannelConfiguration() - config.isNegotiated = false - localDataChannel = peerConnection?.dataChannel(forLabel: "status", configuration: config) - localDataChannel?.delegate = self + // Skip if it already exists, when answering a renegotiation offer on an established connection + if localDataChannel == nil { + let config = RTCDataChannelConfiguration() + config.isNegotiated = false + localDataChannel = peerConnection?.dataChannel(forLabel: "status", configuration: config) + localDataChannel?.delegate = self + } - // Stop video transceiver in audio only peer connections + // Decline to receive video in audio only peer connections by setting the video transceivers + // to "inactive". The m-line needs to stay negotiated (so it must not be rejected by stopping + // the transceiver), otherwise it can not be activated again when the call is upgraded to a + // video call later. Reactivating an "inactive" transceiver is the only kind of renegotiation + // that is known to work with the MCU (it is what the web client does to block remote videos). // Constraints are no longer supported when creating answers (with Unified Plan semantics) - if isAudioOnly { - for transceiver in peerConnection?.transceivers ?? [] where transceiver.mediaType == .video { - transceiver.stopInternal() - NSLog("Stop video transceiver in audio only peer connections.") + for transceiver in peerConnection?.transceivers ?? [] where transceiver.mediaType == .video { + if isAudioOnly, transceiver.direction != .inactive { + NSLog("Set video transceiver to inactive in audio only peer connection.") + transceiver.setDirection(.inactive, error: nil) + } else if !isAudioOnly, transceiver.direction == .inactive { + NSLog("Reactivate inactive video transceiver to receive video again.") + transceiver.setDirection(.recvOnly, error: nil) } } @@ -471,6 +489,12 @@ extension NCPeerConnection: RTCPeerConnectionDelegate { public func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) { NSLog("Signaling state with '%@' changed to: %@", peerId, stringForSignalingState(stateChanged)) + + if stateChanged == .stable { + WebRTCCommon.shared.dispatch { + self.completedInitialNegotiation = true + } + } } public func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { @@ -517,7 +541,15 @@ extension NCPeerConnection: RTCPeerConnectionDelegate { } public func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { - NSLog("WARNING: Renegotiation needed but unimplemented.") + WebRTCCommon.shared.dispatch { + // Before the initial negotiation this event is expected (e.g. when the local tracks are + // added on creation) and the offer is created explicitly, so it should be ignored here. + // After that the connection needs to be renegotiated (e.g. a track was added or removed). + guard self.completedInitialNegotiation else { return } + + NSLog("Renegotiation needed for peer '%@'", self.peerId) + self.delegate?.peerConnectionNeedsRenegotiation(self) + } } public func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) { From 6b08ebb32f858809b91eb846293018a96a60a170 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Mon, 6 Jul 2026 18:26:42 +0200 Subject: [PATCH 2/6] feat: Allow downgrading to voice-only call MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Marcel Müller --- NextcloudTalk/Calls/CallKitManager.swift | 4 +- NextcloudTalk/Calls/CallViewController.swift | 29 ++++++++++++ NextcloudTalk/Calls/NCCallController.swift | 47 +++++++++++++++++++- 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/NextcloudTalk/Calls/CallKitManager.swift b/NextcloudTalk/Calls/CallKitManager.swift index 3094ca85a..8f6b33685 100644 --- a/NextcloudTalk/Calls/CallKitManager.swift +++ b/NextcloudTalk/Calls/CallKitManager.swift @@ -312,10 +312,10 @@ public class CallKitManager: NSObject, CXProviderDelegate { self.provider.reportCall(with: uuid, updated: update) } - public func reportCallUpgradedToVideoCall(forCall token: String) { + public func changeHasVideo(_ hasVideo: Bool, forCall token: String) { guard let call = self.call(forToken: token) else { return } - self.updateCall(call, hasVideo: true) + self.updateCall(call, hasVideo: hasVideo) } private func stopHangUpTimer(forCallUUID uuid: UUID) { diff --git a/NextcloudTalk/Calls/CallViewController.swift b/NextcloudTalk/Calls/CallViewController.swift index c53266d96..0fd33efae 100644 --- a/NextcloudTalk/Calls/CallViewController.swift +++ b/NextcloudTalk/Calls/CallViewController.swift @@ -1689,6 +1689,13 @@ class CallViewController: UIViewController, items.append(reactionMenu) } + // Switch back to a voice only call + if !self.isAudioOnly, callController.supportsCallUpgradeUsingRenegotiation { + items.append(UIAction(title: NSLocalizedString("Switch to voice-only call", comment: ""), image: .init(systemName: "video.slash"), handler: { [unowned self] _ in + self.switchToVoiceOnlyCall() + })) + } + // Background blur if !self.isAudioOnly, self.room.canPublishVideo { var blurActionImage = UIImage(systemName: "person.and.background.dotted") @@ -2155,6 +2162,28 @@ class CallViewController: UIViewController, self.adjustBars() } + func switchToVoiceOnlyCall() { + guard let callController else { return } + + self.isAudioOnly = true + self.userDisabledVideo = true + + callController.downgradeToVoiceOnlyCall() + + self.setLocalVideoViewWrapperHidden(true) + + // Remote videos are no longer received, so show the avatars of all participants again + DispatchQueue.main.async { + for peer in self.peersInCall where peer.roomType == kRoomTypeVideo { + self.updatePeer(peer) { cell in + cell.videoDisabled = true + } + } + } + + self.adjustBars() + } + @IBAction func toggleChatButtonPressed(_ sender: Any) { self.toggleChatView() } diff --git a/NextcloudTalk/Calls/NCCallController.swift b/NextcloudTalk/Calls/NCCallController.swift index 721981e74..528b9561d 100644 --- a/NextcloudTalk/Calls/NCCallController.swift +++ b/NextcloudTalk/Calls/NCCallController.swift @@ -514,7 +514,52 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling self.startSendingCurrentState() if CallKitManager.isCallKitAvailable() { - CallKitManager.sharedInstance().reportCallUpgradedToVideoCall(forCall: self.room.token) + CallKitManager.sharedInstance().changeHasVideo(true, forCall: self.room.token) + } + } + } + + public func downgradeToVoiceOnlyCall() { + WebRTCCommon.shared.dispatch { + guard !self.isAudioOnly, self.supportsCallUpgradeUsingRenegotiation else { return } + + NCLog.log("Downgrading video call to voice only call using renegotiation") + + self.isAudioOnly = true + self.disableVideoAtStart = true + + NCAudioController.shared.setAudioSessionToVoiceChatMode() + + // Stop sending the local video, but keep the negotiated m-line (a sender without + // a track), so the call can be upgraded to a video call again later + if let peerConnection = self.publisherPeerConnection?.getPeerConnection(), + let videoTransceiver = peerConnection.transceivers.first(where: { $0.mediaType == .video }) { + videoTransceiver.sender.track = nil + } + + // Stop capturing and release the local video track and the camera + self.cameraController?.stopAVCaptureSession() + self.cameraController = nil + self.localVideoTrack = nil + + // Stop receiving remote videos. Requesting an offer with the "sid" of the existing + // connection makes the MCU update that connection with a renegotiation offer, which + // is then answered with inactive video transceivers, since the call is audio only again. + for (_, peerConnectionWrapper) in self.connectionsDict { + guard !peerConnectionWrapper.isMCUPublisherPeer, !peerConnectionWrapper.isDummyPeer, + peerConnectionWrapper.roomType == kRoomTypeVideo + else { continue } + + peerConnectionWrapper.isAudioOnly = true + self.requestOfferWithRepetition(forSessionId: peerConnectionWrapper.peerId, withRoomType: kRoomTypeVideo, withSid: peerConnectionWrapper.sid) + } + + // Let the other participants know that we are now in the call without video + self.updateCallFlagsInServer() + self.startSendingCurrentState() + + if CallKitManager.isCallKitAvailable() { + CallKitManager.sharedInstance().changeHasVideo(false, forCall: self.room.token) } } } From 6a0e03b6572863149a3db15a6ecf8846b069c7e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Mon, 6 Jul 2026 18:30:41 +0200 Subject: [PATCH 3/6] fix: No need to have public access to isAudioOnly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- NextcloudTalk/Calls/NCCallController.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/NextcloudTalk/Calls/NCCallController.swift b/NextcloudTalk/Calls/NCCallController.swift index 528b9561d..247dbc886 100644 --- a/NextcloudTalk/Calls/NCCallController.swift +++ b/NextcloudTalk/Calls/NCCallController.swift @@ -48,7 +48,7 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling private let room: NCRoom private let account: TalkAccount private let userSessionId: String - public private(set) var isAudioOnly: Bool + private var isAudioOnly: Bool // TODO: Default true? public var disableAudioAtStart: Bool = false From 5d5ad080cead3ecc98f8da6f44c9993f14cc2fe4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Mon, 6 Jul 2026 18:31:07 +0200 Subject: [PATCH 4/6] chore: Update localizable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Marcel Müller --- NextcloudTalk/en.lproj/Localizable.strings | 3 +++ 1 file changed, 3 insertions(+) diff --git a/NextcloudTalk/en.lproj/Localizable.strings b/NextcloudTalk/en.lproj/Localizable.strings index 979f81e61..b98f914e0 100644 --- a/NextcloudTalk/en.lproj/Localizable.strings +++ b/NextcloudTalk/en.lproj/Localizable.strings @@ -2282,6 +2282,9 @@ /* No comment provided by engineer. */ "Switch account" = "Switch account"; +/* No comment provided by engineer. */ +"Switch to voice-only call" = "Switch to voice-only call"; + /* No comment provided by engineer. */ "Switching to another conversation …" = "Switching to another conversation …"; From 27b7271bbf022b0462d876f91315ccd8d7bf5b25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Mon, 6 Jul 2026 18:45:05 +0200 Subject: [PATCH 5/6] fix: Request a new offer in case of failed connection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: ClaudeCode:claude-fable-5 Signed-off-by: Marcel Müller --- NextcloudTalk/Calls/NCCallController.swift | 15 +++++++++++---- NextcloudTalk/WebRTC/NCPeerConnection.swift | 8 ++++++++ 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/NextcloudTalk/Calls/NCCallController.swift b/NextcloudTalk/Calls/NCCallController.swift index 247dbc886..343015dc4 100644 --- a/NextcloudTalk/Calls/NCCallController.swift +++ b/NextcloudTalk/Calls/NCCallController.swift @@ -1190,13 +1190,20 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling public func requestOfferWithRepetition(forSessionId sessionId: String, withRoomType roomType: String, withSid sid: String? = nil) { WebRTCCommon.shared.assertQueue() + // Requesting an offer with a "sid" updates the existing connection. If that connection has + // failed it would require an ICE restart rather than an update to recover, so request a new + // connection instead (by omitting the "sid"). The offer will then arrive with a new "sid", + // which replaces the failed peer connection (see processOfferAnswer). + let isConnectionFailed = self.getPeerConnectionWrapper(forSessionId: sessionId, ofType: roomType)?.isConnectionFailed() ?? false + let updateSid = isConnectionFailed ? nil : sid + let peerKey = self.getPeerKey(withSessionId: sessionId, ofType: roomType, forOwnScreenshare: false) if let previousOfferTimer = self.pendingOffersDict[peerKey], previousOfferTimer.isValid { // If a connection needs to be updated ("sid" is set) but another offer request is already // pending, ignore the new update. A new connection needs to be requested even if there // is another one already pending. - if sid != nil { + if updateSid != nil { return } @@ -1212,8 +1219,8 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling "timeout": timeout ] - if let sid { - userInfo["sid"] = sid + if let updateSid { + userInfo["sid"] = updateSid } let pendingOfferTimer = Timer(timeInterval: 8.0, target: self, selector: #selector(self.requestNewOffer), userInfo: userInfo, repeats: true) @@ -1222,7 +1229,7 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling // Request new offer if let externalSignalingController { - externalSignalingController.requestOffer(forSessionId: sessionId, andRoomType: roomType, withSid: sid) + externalSignalingController.requestOffer(forSessionId: sessionId, andRoomType: roomType, withSid: updateSid) } DispatchQueue.main.async { diff --git a/NextcloudTalk/WebRTC/NCPeerConnection.swift b/NextcloudTalk/WebRTC/NCPeerConnection.swift index f9793d12a..df19bff43 100644 --- a/NextcloudTalk/WebRTC/NCPeerConnection.swift +++ b/NextcloudTalk/WebRTC/NCPeerConnection.swift @@ -255,6 +255,14 @@ public class NCPeerConnection: NSObject { return peerConnection } + func isConnectionFailed() -> Bool { + WebRTCCommon.shared.assertQueue() + + guard let peerConnection else { return false } + + return peerConnection.iceConnectionState == .failed || peerConnection.connectionState == .failed + } + func getLocalDataChannel() -> RTCDataChannel? { WebRTCCommon.shared.assertQueue() return localDataChannel From d6abd89e9e763b4291d8b881199e8c81bb64e416 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20M=C3=BCller?= Date: Mon, 6 Jul 2026 22:00:11 +0200 Subject: [PATCH 6/6] fix: Remove publisher peer re-negotiation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It only seems to work in some cases, sometimes it's only a black video is shown. Therefore we fallback to reconnect for now. Signed-off-by: Marcel Müller --- NextcloudTalk/Calls/NCCallController.swift | 23 +++++--------------- NextcloudTalk/WebRTC/NCPeerConnection.swift | 24 ++++----------------- 2 files changed, 9 insertions(+), 38 deletions(-) diff --git a/NextcloudTalk/Calls/NCCallController.swift b/NextcloudTalk/Calls/NCCallController.swift index 343015dc4..35d7f61e3 100644 --- a/NextcloudTalk/Calls/NCCallController.swift +++ b/NextcloudTalk/Calls/NCCallController.swift @@ -487,9 +487,11 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling // be sent right away, without any renegotiation videoTransceiver.sender.track = localVideoTrack } else { - // Fallback: adding the video track triggers a renegotiation of the publisher - // peer connection ("negotiationneeded" -> updated offer with the same sid) - peerConnection.add(localVideoTrack, streamIds: [NCCallController.kNCMediaStreamId]) + // The publisher peer connection was negotiated without a video m-line. Adding + // one to an existing connection is not supported by the MCU (see comment in + // createPublisherPeerConnection), so the call needs to be reconnected instead + self.forceReconnect() + return } } else { // There was no publisher peer connection (e.g. no microphone access), create it now @@ -1581,21 +1583,6 @@ internal class NCCallController: NSObject, NCPeerConnectionDelegate, NCSignaling self.delegate?.callController(self, didReceiveNick: nick, fromPeer: peerConnection) } - func peerConnectionNeedsRenegotiation(_ peerConnection: NCPeerConnection) { - WebRTCCommon.shared.assertQueue() - - // Local tracks are only added to (or removed from) an established publisher peer connection, - // so this is the only renegotiation triggered from our side that needs to be handled. - // The MCU updates the existing connection when it receives an offer with the same "sid". - guard peerConnection.isMCUPublisherPeer, let externalSignalingController, externalSignalingController.hasMCU else { - NCLog.log("Renegotiation needed for peer \(peerConnection.peerIdentifier), but it is not supported -> ignoring") - return - } - - NCLog.log("Renegotiating publisher peer connection") - peerConnection.sendPublisherOffer() - } - // MARK: - Signaling functions private func processSignalingMessage(_ signalingMessage: NCSignalingMessage?) { diff --git a/NextcloudTalk/WebRTC/NCPeerConnection.swift b/NextcloudTalk/WebRTC/NCPeerConnection.swift index df19bff43..8c2a90925 100644 --- a/NextcloudTalk/WebRTC/NCPeerConnection.swift +++ b/NextcloudTalk/WebRTC/NCPeerConnection.swift @@ -28,10 +28,6 @@ import WebRTC /// Called when a peer connection creates a session description. func peerConnection(_ peerConnection: NCPeerConnection, needsToSend sessionDescription: RTCSessionDescription) - - /// Called when an already established peer connection needs to be renegotiated - /// (e.g. after a local track was added or removed). - func peerConnectionNeedsRenegotiation(_ peerConnection: NCPeerConnection) } public class NCPeerConnection: NSObject { @@ -63,7 +59,6 @@ public class NCPeerConnection: NSObject { } private var queuedRemoteCandidates: [RTCIceCandidate]? - private var completedInitialNegotiation = false private var peerConnection: RTCPeerConnection? private var localDataChannel: RTCDataChannel? private var remoteDataChannel: RTCDataChannel? @@ -497,12 +492,6 @@ extension NCPeerConnection: RTCPeerConnectionDelegate { public func peerConnection(_ peerConnection: RTCPeerConnection, didChange stateChanged: RTCSignalingState) { NSLog("Signaling state with '%@' changed to: %@", peerId, stringForSignalingState(stateChanged)) - - if stateChanged == .stable { - WebRTCCommon.shared.dispatch { - self.completedInitialNegotiation = true - } - } } public func peerConnection(_ peerConnection: RTCPeerConnection, didAdd stream: RTCMediaStream) { @@ -549,15 +538,10 @@ extension NCPeerConnection: RTCPeerConnectionDelegate { } public func peerConnectionShouldNegotiate(_ peerConnection: RTCPeerConnection) { - WebRTCCommon.shared.dispatch { - // Before the initial negotiation this event is expected (e.g. when the local tracks are - // added on creation) and the offer is created explicitly, so it should be ignored here. - // After that the connection needs to be renegotiated (e.g. a track was added or removed). - guard self.completedInitialNegotiation else { return } - - NSLog("Renegotiation needed for peer '%@'", self.peerId) - self.delegate?.peerConnectionNeedsRenegotiation(self) - } + // Client initiated renegotiation is not supported. Offers are created explicitly and + // tracks are only added to or removed from already negotiated m-lines (replaceTrack), + // which does not require a renegotiation. + NSLog("Negotiation needed for peer '%@' -> ignoring", peerId) } public func peerConnection(_ peerConnection: RTCPeerConnection, didChange newState: RTCIceConnectionState) {