Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
0a19388
feat(voip): add MediaCallsAnswerRequest for REST accept/reject
diegolmello Apr 10, 2026
f48c448
feat(voip): migrate accept() and reject() from DDP to REST
diegolmello Apr 10, 2026
9167fa4
feat(voip): remove dead buildMediaCallAnswerParams after REST migration
diegolmello Apr 10, 2026
4b43763
fix(voip): guard against nil API in accept/reject on iOS
diegolmello Apr 10, 2026
ba637f8
fix(voip): add missing RNCallKeep.endCall on user hangup before answer
diegolmello Apr 10, 2026
d9233e7
fix(voip): add missing RNCallKeep.endCall on user hangup before answer
diegolmello Apr 10, 2026
ff477fd
Merge branch 'refactor.ddp-ios' of github.com:RocketChat/Rocket.Chat.…
diegolmello Apr 10, 2026
65b3789
fix(voip): remove duplicate MediaCallsAnswerRequest.swift and add to …
diegolmello Apr 10, 2026
4d13bbb
xcode
diegolmello Apr 10, 2026
da0b23d
merge: resolve conflicts with feat.voip-lib-new (PR 7124)
diegolmello Apr 10, 2026
8d13809
Merge branch 'feat.voip-lib-new' into refactor.ddp-ios
diegolmello Apr 14, 2026
92045ec
Fix build
diegolmello Apr 14, 2026
aa9a875
feat(voip): enhance VoIP handling with deep link normalization and RE…
diegolmello Apr 15, 2026
aec707d
feat(deepLinking): add normalizeDeepLinkingServerHost function and tests
diegolmello Apr 15, 2026
11571bf
chore(voip): address PR review slop in REST state signals refactor
diegolmello Apr 15, 2026
e2e8f8a
test(voip): mock store.getState in MediaCallEvents tests for host gate
diegolmello Apr 15, 2026
70f6239
fix(voip): drop unused async on host-gate test (require-await lint)
diegolmello Apr 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 25 additions & 116 deletions ios/Libraries/VoipService.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import CallKit
import Foundation
import PushKit
import RocketChat

/**
* VoipModuleSwift - Swift implementation for VoIP push notifications and initial events data.
Expand Down Expand Up @@ -60,11 +61,6 @@ public final class VoipService: NSObject {
/// cleared when that call's DDP accept finishes or another exit path runs for that `callId`.
private static var nativeAcceptHandledCallIds = Set<String>()

private enum VoipMediaCallAnswerKind {
case accept
case reject
}

// MARK: - Static Methods (Called from VoipModule.mm and AppDelegate)

/// Registers for VoIP push notifications via PushKit
Expand Down Expand Up @@ -413,40 +409,6 @@ public final class VoipService: NSObject {
ddpRegistry.stopAllClients()
}

// MARK: - Native DDP signaling (accept / reject)

/// `contractId` must match JS `getUniqueIdSync()` from react-native-device-info (`DeviceUID` on iOS; Android uses `Settings.Secure.ANDROID_ID` in VoipNotification).
private static func buildMediaCallAnswerParams(payload: VoipPayload, kind: VoipMediaCallAnswerKind) -> [Any]? {
let credentialStorage = Storage()
guard let credentials = credentialStorage.getCredentials(server: payload.host.removeTrailingSlash()) else {
#if DEBUG
print("[\(TAG)] Missing credentials, cannot build media-call answer params for \(payload.callId)")
#endif
stopDDPClientInternal(callId: payload.callId)
return nil
}

var signal: [String: Any] = [
"callId": payload.callId,
"contractId": DeviceUID.uid(),
"type": "answer",
"answer": kind == .accept ? "accept" : "reject"
]
if kind == .accept {
signal["supportedFeatures"] = ["audio"]
}

guard
let signalData = try? JSONSerialization.data(withJSONObject: signal),
let signalString = String(data: signalData, encoding: .utf8)
else {
stopDDPClientInternal(callId: payload.callId)
return nil
}

return ["\(credentials.userId)/media-calls", signalString]
}

/// Native DDP accept when the user answers via CallKit (parity with Android `VoipNotification.handleAcceptAction`).
private static func handleNativeAccept(payload: VoipPayload) {
if nativeAcceptHandledCallIds.contains(payload.callId) {
Expand Down Expand Up @@ -506,36 +468,20 @@ public final class VoipService: NSObject {
}
}

guard let client = ddpRegistry.clientFor(callId: payload.callId) else {
#if DEBUG
print("[\(TAG)] Native DDP client unavailable for accept \(payload.callId); relying on JS")
#endif
finishAccept(false)
return
}

guard let params = buildMediaCallAnswerParams(payload: payload, kind: .accept) else {
finishAccept(false)
return
}

if ddpRegistry.isLoggedIn(callId: payload.callId) {
client.callMethod("stream-notify-user", params: params) { success in
#if DEBUG
print("[\(TAG)] Native accept signal result for \(payload.callId): \(success)")
#endif
DispatchQueue.main.async { finishAccept(success) }
}
} else {
client.queueMethodCall("stream-notify-user", params: params) { success in
#if DEBUG
print("[\(TAG)] Queued native accept signal result for \(payload.callId): \(success)")
#endif
DispatchQueue.main.async { finishAccept(success) }
API(server: payload.host)?.fetch(request: MediaCallsAnswerRequest(
callId: payload.callId,
contractId: DeviceUID.uid(),
answer: "accept",
supportedFeatures: ["audio"]
)) { result in
DispatchQueue.main.async {
switch result {
case .resource(let response) where response.success:
finishAccept(true)
default:
finishAccept(false)
}
}
#if DEBUG
print("[\(TAG)] Queued native accept signal for \(payload.callId)")
#endif
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}

Expand All @@ -556,55 +502,22 @@ public final class VoipService: NSObject {
// End the just-reported CallKit call immediately (reason 2 = unanswered / declined).
RNCallKeep.endCall(withUUID: payload.callId, reason: 2)

// Send reject signal via native DDP if available, otherwise queue it.
if ddpRegistry.isLoggedIn(callId: payload.callId) {
sendRejectSignal(payload: payload)
} else {
queueRejectSignal(payload: payload)
}
// Send reject signal via REST
reject(payload: payload)

#if DEBUG
print("[\(TAG)] Rejected busy call \(payload.callId) — user already on a call")
#endif
}

private static func sendRejectSignal(payload: VoipPayload) {
guard let client = ddpRegistry.clientFor(callId: payload.callId) else {
#if DEBUG
print("[\(TAG)] Native DDP client unavailable, cannot send reject for \(payload.callId)")
#endif
return
}

guard let params = buildMediaCallAnswerParams(payload: payload, kind: .reject) else {
return
}

client.callMethod("stream-notify-user", params: params) { success in
#if DEBUG
print("[\(TAG)] Native reject signal result for \(payload.callId): \(success)")
#endif
stopDDPClientInternal(callId: payload.callId)
}
}

private static func queueRejectSignal(payload: VoipPayload) {
guard let client = ddpRegistry.clientFor(callId: payload.callId) else {
#if DEBUG
print("[\(TAG)] Native DDP client unavailable, cannot queue reject for \(payload.callId)")
#endif
return
}

guard let params = buildMediaCallAnswerParams(payload: payload, kind: .reject) else {
return
}

client.queueMethodCall("stream-notify-user", params: params) { success in
#if DEBUG
print("[\(TAG)] Queued native reject signal result for \(payload.callId): \(success)")
#endif
stopDDPClientInternal(callId: payload.callId)
private static func reject(payload: VoipPayload) {
API(server: payload.host)?.fetch(request: MediaCallsAnswerRequest(
callId: payload.callId,
contractId: DeviceUID.uid(),
answer: "reject",
supportedFeatures: nil
)) { _ in
self.stopDDPClientInternal(callId: payload.callId)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

Expand Down Expand Up @@ -671,10 +584,6 @@ public final class VoipService: NSObject {
clearNativeAcceptDedupe(for: observedCall.payload.callId)

let endedCallId = observedCall.payload.callId
if ddpRegistry.isLoggedIn(callId: endedCallId) {
sendRejectSignal(payload: observedCall.payload)
} else {
queueRejectSignal(payload: observedCall.payload)
}
reject(payload: observedCall.payload)
}
}
43 changes: 43 additions & 0 deletions ios/Shared/RocketChat/API/MediaCallsAnswerRequest.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
//
// MediaCallsAnswerRequest.swift
// RocketChat
//
// Created by Diego Mello on 4/10/26.
//

import Foundation

struct MediaCallsAnswerRequest: Request {
typealias ResponseType = MediaCallsAnswerResponse

let callId: String
let contractId: String
let answer: String
let supportedFeatures: [String]?

let method: HTTPMethod = .post
let path = "/api/v1/media-calls.answer"

init(callId: String, contractId: String, answer: String, supportedFeatures: [String]? = nil) {
self.callId = callId
self.contractId = contractId
self.answer = answer
self.supportedFeatures = supportedFeatures
}

func body() -> Data? {
var dict: [String: Any] = [
"callId": callId,
"contractId": contractId,
"answer": answer
]
if let features = supportedFeatures {
dict["supportedFeatures"] = features
}
return try? JSONSerialization.data(withJSONObject: dict)
}
}

struct MediaCallsAnswerResponse: Response {
let success: Bool
}
Loading