Skip to content

Instantly share code, notes, and snippets.

@Pieeer1
Last active July 29, 2025 04:25
Show Gist options
  • Save Pieeer1/34152312ca6269cda81fb68214863049 to your computer and use it in GitHub Desktop.
Save Pieeer1/34152312ca6269cda81fb68214863049 to your computer and use it in GitHub Desktop.
Expo Modules Voip Push Token

Introduction

This gist shows the very basic bare minimum to get a voip push token in expo 53.

Getting Started

  1. Navigate to your project root
  2. Run the following command: npx create-expo-module --local expo-voip-push-token
  3. Remove all of the View and Web based files, we will not be needing those.
  4. Replace the Relevant Files with the ones in the gist. Podfile, Gradle Files, and Manifests are not modified.
export interface VoipToken {
voipToken: string;
}
export type ExpoVoipPushTokenModuleEvents = {
onRegistration: (params: VoipToken) => void;
notification: (params: { payload: Record<string, any> }) => void;
onCallAccepted: (params: { callUUID: string }) => void;
onCallEnded: (params: { callUUID: string }) => void;
};
package expo.modules.voippushtoken
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import java.net.URL
class ExpoVoipPushTokenModule : Module() {
override fun definition() = ModuleDefinition {
Name("ExpoVoipPushToken")
Events("onRegistration", "notification")
Function("registerVoipPushToken") {
// do nothing, as this is technically IOS only
}
}
}
import ExpoModulesCore
import Foundation
import PushKit
import CallKit
class VoipPushDelegate: NSObject, PKPushRegistryDelegate, CXProviderDelegate {
var onTokenReceived: ((String) -> Void)?
var onIncomingPush: ((PKPushPayload) -> Void)?
var onCallAccepted: ((UUID) -> Void)?
var onCallEnded: ((UUID) -> Void)?
var voipRegistrationToken: String?
var pushRegistry: PKPushRegistry?
private let callProvider: CXProvider
private let callController = CXCallController()
override init() {
let config = CXProviderConfiguration(localizedName: "App Name")
config.supportsVideo = true
config.maximumCallsPerCallGroup = 1
config.supportedHandleTypes = [.generic]
pushRegistry = PKPushRegistry(queue: DispatchQueue.main)
callProvider = CXProvider(configuration: config)
super.init()
pushRegistry?.delegate = self
pushRegistry?.desiredPushTypes = [.voIP]
callProvider.setDelegate(self, queue: nil)
}
func pushRegistry(
_ registry: PKPushRegistry,
didUpdate pushCredentials: PKPushCredentials,
for type: PKPushType
) {
guard type == .voIP else { return }
let tokenData = pushCredentials.token
let tokenParts = tokenData.map { String(format: "%02x", $0) }
voipRegistrationToken = tokenParts.joined()
onTokenReceived?(voipRegistrationToken ?? "")
}
func pushRegistry(
_ registry: PKPushRegistry,
didReceiveIncomingPushWith payload: PKPushPayload,
for type: PKPushType,
completion: @escaping () -> Void
) {
guard type == .voIP else {
completion()
return
}
// CallKit expects us to call this immediately
let update = CXCallUpdate()
update.hasVideo = true
update.remoteHandle = CXHandle(
type: .generic,
value: (payload.dictionaryPayload["callerName"] as? String ?? "Unknown Caller")
)
let uuid = payload.dictionaryPayload["uuid"] as? String
callProvider.reportNewIncomingCall(with: UUID(uuidString: uuid ?? "") ?? UUID(), update: update) { error in
if let error = error {
print("Error reporting call: \(error)")
} else {
self.onIncomingPush?(payload)
}
completion()
}
}
func startCall(callUUID: String, handle: String, callerName: String) {
let callUUIDObj = UUID(uuidString: callUUID) ?? UUID()
let handle = CXHandle(type: .generic, value: handle)
let callAction = CXStartCallAction(call: callUUIDObj, handle: handle)
callAction.isVideo = true
let transaction = CXTransaction(action: callAction)
self.callController.request(transaction, completion: { error in})
}
func endCall(callUUID: String) {
let callUUIDObj = UUID(uuidString: callUUID) ?? UUID()
let action = CXEndCallAction(call: callUUIDObj)
let transaction = CXTransaction(action: action)
self.callController.request(transaction, completion: { error in})
}
func provider(_ provider: CXProvider, perform action: CXAnswerCallAction) {
// Handle the action, e.g., start audio, setup media, etc.
let callUUID = action.callUUID
self.onCallAccepted?(callUUID)
action.fulfill()
}
func provider(_ provider: CXProvider, perform action: CXEndCallAction) {
let callUUID = action.callUUID
self.onCallEnded?(callUUID)
action.fulfill()
}
func providerDidReset(_ provider: CXProvider) {
// Handle provider reset if needed
}
}
public final class ExpoVoipPushTokenModule: Module {
private let delegate = VoipPushDelegate()
private func initializePushDelegate() {
delegate.onTokenReceived = { [weak self] token in
self?.sendEvent("onRegistration", ["voipToken": token])
}
delegate.onIncomingPush = { [weak self] payload in
let payloadDict = payload.dictionaryPayload
self?.sendEvent("notification", ["payload": payloadDict])
}
delegate.onCallAccepted = { [weak self] uuid in
self?.sendEvent("onCallAccepted", ["callUUID": uuid.uuidString])
}
delegate.onCallEnded = { [weak self] uuid in
self?.sendEvent("onCallEnded", ["callUUID": uuid.uuidString])
}
}
private func startCall(callUUID: String, handle: String, callerName: String) {
delegate.startCall(callUUID: callUUID, handle: handle, callerName: callerName)
}
private func endCall(callUUID: String) {
delegate.endCall(callUUID: callUUID)
}
public func definition() -> ModuleDefinition {
Name("ExpoVoipPushToken")
Events("onRegistration", "notification", "onCallAccepted", "onCallEnded")
OnCreate(){
self.initializePushDelegate()
}
Function("startCall") { (callUUID: String, handle: String, callerName: String) -> Void in
self.startCall(callUUID: callUUID, handle: handle, callerName: callerName)
}
Function("endCall") { (callUUID: String) -> Void in
self.endCall(callUUID: callUUID)
}
Function("registerVoipPushToken") {
self.sendEvent("onRegistration", ["voipToken": self.delegate.voipRegistrationToken ?? ""])
}
}
}
import { NativeModule, requireNativeModule } from 'expo';
import { ExpoVoipPushTokenModuleEvents } from './ExpoVoipPushToken.types';
declare class ExpoVoipPushTokenModule extends NativeModule<ExpoVoipPushTokenModuleEvents> {
registerVoipPushToken(): void;
startCall(callUUID: string, handle: string, callerName: string): void;
endCall(callUUID: string): void;
}
// This call loads the native module object from the JSI.
export default requireNativeModule<ExpoVoipPushTokenModule>('ExpoVoipPushToken');
// Reexport the native module. On web, it will be resolved to ExpoVoipPushTokenModule.web.ts
// and on native platforms to ExpoVoipPushTokenModule.ts
export { default } from './src/ExpoVoipPushTokenModule';
export * from './src/ExpoVoipPushToken.types';
@debugtheworldbot
Copy link

thank you!

@Rinshin-Jalal
Copy link

thank you!

did you get this working!

@Pieeer1
Copy link
Author

Pieeer1 commented Jul 24, 2025

thank you!

did you get this working!

It works when the app is in the foreground, I will update once I finish the background functionality, the AppDelegate modifications are complicated.

@debugtheworldbot
Copy link

I tried just adding reportNewIncomingCall ,it works also background/killed

  var provider: CXProvider?

  func pushRegistry(
    _ registry: PKPushRegistry,
    didReceiveIncomingPushWith payload: PKPushPayload,
    for type: PKPushType,
    completion: @escaping () -> Void
  ) {
    guard type == .voIP else {
      completion()
      return
    }
    
    setupProviderIfNeeded()

    // extract payload
    let payloadDict = payload.dictionaryPayload
    let callUUID = UUID()
    let handle = payloadDict["handle"] as? String ?? "Handle"
    let callerName = payloadDict["callerName"] as? String ?? "Unknown Caller"
    let hasVideo = payloadDict["hasVideo"] as? Bool ?? false
    
    // create Call Update
    let callUpdate = CXCallUpdate()
    callUpdate.remoteHandle = CXHandle(type: .generic, value: handle)
    callUpdate.localizedCallerName = callerName
    callUpdate.hasVideo = hasVideo
    
    // use current provider to reportNewIncomingCall
    self.provider?.reportNewIncomingCall(with: callUUID, update: callUpdate) { error in
      if let error = error {
        print("CallKit report error: \(error)")
      } else {
        print("CallKit report success: \(callUUID), caller: \(callerName)")
        self.currentCallUUID = callUUID
      }
      
      self.onIncomingPush?(payload)
      
      completion()
    }
  }

  // init provider with custom icon
  func setupProviderIfNeeded() {
      if provider == nil {
          let config = CXProviderConfiguration(localizedName: "Your APP")
          config.supportsVideo = true
          config.maximumCallGroups = 1
          config.maximumCallsPerCallGroup = 1
          config.supportedHandleTypes = [.generic, .phoneNumber]
          
          // set custom icon at callKit page(optional)
          if let iconImage = UIImage(named: "CallKitIcon") {
              config.iconTemplateImageData = iconImage.pngData()
          }           

          let p = CXProvider(configuration: config)
          p.setDelegate(self, queue: nil)
          self.provider = p
      }
  }

@Rinshin-Jalal
Copy link

For my case i tried checking how expo-notifications works and found this "https://docs.expo.dev/modules/appdelegate-subscribers/"

Added a appdelegate like this It's all working now!

@Pieeer1
Copy link
Author

Pieeer1 commented Jul 26, 2025

Gist has been updated with the full closed app and background functionality. Good callout on the reporting in the module @debugtheworldbot

@mduc-dev
Copy link

We don't need to rewrite the call logic with CallKit here because we already have CallKeep, which handles this part well.

You can reuse CallKeep’s functionality by adding the dependency to your .podspec file like this:
s.dependency 'RNCallKeep'
Then, simply import it into your Swift file and use it as needed.

For more details, please refer to the images below.

image image

@Pieeer1
Copy link
Author

Pieeer1 commented Jul 29, 2025

Meh I was already at the dependency rewrite anyways since the voip push one wasn’t working so that’s why I did it. I will look into that to avoid reinventing the wheel though

@Pieeer1
Copy link
Author

Pieeer1 commented Jul 29, 2025

I also have some custom UUID logic I needed to implement so that was another reason for my specific rewrite, may not cover everyone’s specific use case.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment