diff --git a/FishjamReactNativeWebrtc.podspec b/FishjamReactNativeWebrtc.podspec index 2f107d166..51cf4dea7 100644 --- a/FishjamReactNativeWebrtc.podspec +++ b/FishjamReactNativeWebrtc.podspec @@ -32,6 +32,9 @@ Pod::Spec.new do |s| # miniaudio conversion-only build. MA_NO_* must be global across all TUs # (miniaudio is not ABI-compatible across differing configs). s.pod_target_xcconfig = { + # Generate a Clang module map so Swift pods (e.g. FishjamExpoVoip) can + # `import FishjamReactNativeWebrtc`. + 'DEFINES_MODULE' => 'YES', 'CLANG_CXX_LANGUAGE_STANDARD' => 'c++20', 'HEADER_SEARCH_PATHS' => '"$(PODS_TARGET_SRCROOT)/common/cpp/vendor" "$(PODS_TARGET_SRCROOT)/common/cpp/fishjam-audio" "$(PODS_TARGET_SRCROOT)/common/cpp/fishjam-video"', 'GCC_PREPROCESSOR_DEFINITIONS' => diff --git a/android/build.gradle b/android/build.gradle index 17e746fda..a892cd3c8 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,4 +1,7 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + apply plugin: 'com.android.library' +apply plugin: 'org.jetbrains.kotlin.android' def safeExtGet(prop, fallback) { rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback @@ -60,9 +63,18 @@ android { } } +kotlin { + compilerOptions { + jvmTarget = JvmTarget.JVM_17 + } +} + dependencies { implementation "com.facebook.react:react-android:+" + implementation ("androidx.core:core-telecom:1.0.1") + implementation "androidx.core:core:1.16.0" api 'com.github.fishjam-cloud:webrtc:v124.0.2.3' - implementation "androidx.core:core:1.7.0" implementation 'com.facebook.fbjni:fbjni:0.6.0' + implementation 'androidx.core:core-ktx:1.16.0' + implementation 'com.google.firebase:firebase-messaging:25.1.0' } diff --git a/android/src/main/java/com/oney/WebRTCModule/AudioOutputManager.java b/android/src/main/java/com/oney/WebRTCModule/AudioOutputManager.java index de97fa2e9..05518d183 100644 --- a/android/src/main/java/com/oney/WebRTCModule/AudioOutputManager.java +++ b/android/src/main/java/com/oney/WebRTCModule/AudioOutputManager.java @@ -18,6 +18,7 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.WritableArray; import com.facebook.react.bridge.WritableMap; +import com.oney.WebRTCModule.voip.CallManager; import java.util.List; @@ -35,6 +36,12 @@ public class AudioOutputManager { private final Handler mainHandler = new Handler(Looper.getMainLooper()); // Guarded by `this`. Single in-flight selection — a new request supersedes any prior one. private PendingSelect pending; + // Guarded by `this`. In-flight telecom endpoint selection, resolved when + // onTelecomAudioStateChanged reports the target as current. + private PendingTelecomSelect telecomPending; + + private volatile WritableMap cachedTelecomCurrent; + private volatile boolean telecomOwnsRouting = false; private static final class PendingSelect { final Promise promise; @@ -50,6 +57,18 @@ private static final class PendingSelect { } } + private static final class PendingTelecomSelect { + final Promise promise; + final String targetId; + final Runnable timeoutTask; + + PendingTelecomSelect(Promise promise, String targetId, Runnable timeoutTask) { + this.promise = promise; + this.targetId = targetId; + this.timeoutTask = timeoutTask; + } + } + public AudioOutputManager(WebRTCModule module, ReactApplicationContext context) { this.webRTCModule = module; this.reactContext = context; @@ -134,6 +153,11 @@ private static WritableMap serializeAudioDevice(AudioDeviceInfo device) { } public void getAvailableAudioOutputs(Promise promise) { + if (telecomOwnsRouting && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + promise.resolve(CallManager.INSTANCE.availableEndpointsSnapshot()); + return; + } + WritableArray result = Arguments.createArray(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -160,7 +184,11 @@ public void getAvailableAudioOutputs(Promise promise) { } public void getCurrentAudioOutput(Promise promise) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + // https://developer.android.com/develop/connectivity/telecom/voip-app/telecom#manage-call-audio-endpoints + if (telecomOwnsRouting) { + WritableMap current = cachedTelecomCurrent; + promise.resolve(current != null ? current.copy() : null); + } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { AudioDeviceInfo device = audioManager.getCommunicationDevice(); if (device != null) { promise.resolve(serializeAudioDevice(device)); @@ -205,6 +233,11 @@ private AudioDeviceInfo findCurrentOutputLegacy() { } public void selectAudioOutput(String deviceIdStr, Promise promise) { + if (telecomOwnsRouting) { + selectTelecomAudioOutput(deviceIdStr, promise); + return; + } + int deviceId; try { deviceId = Integer.parseInt(deviceIdStr); @@ -253,6 +286,61 @@ public void selectAudioOutput(String deviceIdStr, Promise promise) { } } + private void selectTelecomAudioOutput(String deviceId, Promise promise) { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + promise.reject("E_AUDIO_OUTPUT_SELECT", "Telecom audio routing requires API 26+"); + return; + } + + synchronized (this) { + WritableMap current = cachedTelecomCurrent; + if (current != null && deviceId.equals(current.getString("id"))) { + promise.resolve(null); + return; + } + + if (telecomPending != null) { + mainHandler.removeCallbacks(telecomPending.timeoutTask); + Promise old = telecomPending.promise; + telecomPending = null; + old.reject("E_AUDIO_OUTPUT_SUPERSEDED", "Superseded by newer selectAudioOutput call"); + } + Runnable timeoutTask = this::timeoutTelecomPending; + telecomPending = new PendingTelecomSelect(promise, deviceId, timeoutTask); + mainHandler.postDelayed(timeoutTask, ROUTE_CHANGE_TIMEOUT_MS); + } + + if (!CallManager.INSTANCE.selectEndpoint(deviceId)) { + synchronized (this) { + if (telecomPending == null || telecomPending.promise != promise) return; + mainHandler.removeCallbacks(telecomPending.timeoutTask); + telecomPending = null; + } + promise.reject("E_AUDIO_OUTPUT_SELECT", "Audio endpoint not available for ID: " + deviceId); + } + } + + private void timeoutTelecomPending() { + synchronized (this) { + if (telecomPending == null) return; + mainHandler.removeCallbacks(telecomPending.timeoutTask); + Promise p = telecomPending.promise; + telecomPending = null; + p.reject("E_AUDIO_OUTPUT_TIMEOUT", + String.format("Route change not confirmed within %dms", ROUTE_CHANGE_TIMEOUT_MS)); + } + } + + private void cancelTelecomPending(String reason) { + synchronized (this) { + if (telecomPending == null) return; + mainHandler.removeCallbacks(telecomPending.timeoutTask); + Promise p = telecomPending.promise; + telecomPending = null; + p.reject("E_AUDIO_OUTPUT_CANCELLED", reason); + } + } + private AudioDeviceInfo findTargetDevice(int deviceId) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { for (AudioDeviceInfo d : audioManager.getAvailableCommunicationDevices()) { @@ -444,6 +532,8 @@ public void stopObserving() { } private void emitOutputChangedEvent() { + if (telecomOwnsRouting) return; + WritableMap params = Arguments.createMap(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -485,4 +575,32 @@ private void emitOutputChangedEvent() { webRTCModule.sendEvent("audioOutputChanged", params); } + + public void setTelecomOwnsRouting(boolean owns) { + telecomOwnsRouting = owns; + if (!owns) { + cancelTelecomPending("Telecom call ended"); + } + } + + public void onTelecomAudioStateChanged(WritableMap current, WritableArray available) { + cachedTelecomCurrent = current != null ? current.copy() : null; + + synchronized (this) { + if (telecomPending != null && current != null && telecomPending.targetId.equals(current.getString("id"))) { + mainHandler.removeCallbacks(telecomPending.timeoutTask); + Promise p = telecomPending.promise; + telecomPending = null; + p.resolve(null); + } + } + + WritableMap params = Arguments.createMap(); + if (current != null) + params.putMap("currentAudioOutput", current); + else + params.putNull("currentAudioOutput"); + params.putArray("availableAudioOutputs", available); + webRTCModule.sendEvent("audioOutputChanged", params); + } } diff --git a/android/src/main/java/com/oney/WebRTCModule/TelecomController.java b/android/src/main/java/com/oney/WebRTCModule/TelecomController.java new file mode 100644 index 000000000..34b1076d6 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/TelecomController.java @@ -0,0 +1,146 @@ +package com.oney.WebRTCModule; + +import android.app.Activity; +import android.os.Build; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.WritableMap; +import com.oney.WebRTCModule.voip.CallEventsListener; +import com.oney.WebRTCModule.voip.CallManager; +import com.oney.WebRTCModule.voip.LockScreenController; + +final class TelecomController implements CallEventsListener { + private final WebRTCModule webRTCModule; + private final ReactApplicationContext reactContext; + private final AudioOutputManager audioOutputManager; + + TelecomController( + WebRTCModule webRTCModule, ReactApplicationContext reactContext, AudioOutputManager audioOutputManager) { + this.webRTCModule = webRTCModule; + this.reactContext = reactContext; + this.audioOutputManager = audioOutputManager; + } + + // Core-Telecom (CallManager) requires API 26+; below that, Telecom methods are no-ops. + void attach() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallManager.INSTANCE.setListener(this); + CallManager.INSTANCE.setAudioOutputManager(audioOutputManager); + } + } + + void detach() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // CallManager is a process-wide singleton; detach so it doesn't hold a + // reference to this (possibly destroyed) controller after a reload. + CallManager.INSTANCE.setListener(null); + CallManager.INSTANCE.setAudioOutputManager(null); + } + } + + void startCall(String displayName, String handle, boolean isVideo) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallManager.INSTANCE.startOutgoingCall(reactContext, displayName, handle, isVideo); + } + } + + void reportOutgoingCallConnected() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallManager.INSTANCE.reportOutgoingCallConnected(); + } + } + + boolean fulfillAnswered(String requestId) { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && CallManager.INSTANCE.fulfillAnswered(requestId); + } + + void failAnswered(String requestId) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallManager.INSTANCE.failAnswered(requestId); + } + } + + void endCall(String reason) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallManager.INSTANCE.endCall(CallManager.INSTANCE.reasonToCause(reason)); + } + } + + void setCallHeld(boolean onHold) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallManager.INSTANCE.setCallHeld(onHold); + } + } + + boolean hasActiveCall() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && CallManager.INSTANCE.hasActiveCall(); + } + + boolean isAnswered() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && CallManager.INSTANCE.isAnswered(); + } + + boolean isOnHold() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && CallManager.INSTANCE.isOnHold(); + } + + String pendingAnswerRequestId() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O ? CallManager.INSTANCE.pendingAnswerRequestId() : null; + } + + @Override + public void onStarted() { + WritableMap body = Arguments.createMap(); + body.putString("event", "started"); + webRTCModule.sendEvent("telecomActionPerformed", body); + } + + @Override + public void onAnswered(String requestId) { + // Warm start: the host activity already exists, so the lifecycle hook + // in LockScreenController never fires — flag it directly. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Activity activity = reactContext.getCurrentActivity(); + if (activity != null) { + LockScreenController.INSTANCE.showOverLockScreen(activity); + } + } + WritableMap body = Arguments.createMap(); + body.putString("event", "answer"); + body.putString("requestId", requestId); + webRTCModule.sendEvent("telecomActionPerformed", body); + } + + @Override + public void onEnded(String reason) { + WritableMap body = Arguments.createMap(); + body.putString("event", "ended"); + body.putString("reason", reason); + webRTCModule.sendEvent("telecomActionPerformed", body); + } + + @Override + public void onFailed(String reason) { + WritableMap body = Arguments.createMap(); + body.putString("event", "failed"); + body.putString("reason", reason); + webRTCModule.sendEvent("telecomActionPerformed", body); + } + + @Override + public void onMuteChanged(boolean muted) { + WritableMap body = Arguments.createMap(); + body.putString("event", "muteChanged"); + body.putBoolean("muted", muted); + webRTCModule.sendEvent("telecomActionPerformed", body); + } + + @Override + public void onHoldChanged(boolean onHold) { + WritableMap body = Arguments.createMap(); + body.putString("event", "holdChanged"); + body.putBoolean("held", onHold); + webRTCModule.sendEvent("telecomActionPerformed", body); + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/VoIPController.java b/android/src/main/java/com/oney/WebRTCModule/VoIPController.java new file mode 100644 index 000000000..13e5480c6 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/VoIPController.java @@ -0,0 +1,77 @@ +package com.oney.WebRTCModule; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.WritableMap; +import com.oney.WebRTCModule.voip.VoIPPushRegistry; + +final class VoIPController implements VoIPPushRegistry.Listener { + private final WebRTCModule webRTCModule; + + VoIPController(WebRTCModule webRTCModule) { + this.webRTCModule = webRTCModule; + } + + void attach() { + VoIPPushRegistry.INSTANCE.setListener(this); + } + + void detach() { + VoIPPushRegistry.INSTANCE.setListener(null); + } + + void resolveToken(Promise promise) { + VoIPPushRegistry.INSTANCE.resolveToken(promise); + } + + WritableMap getPendingIncomingCall() { + VoIPPushRegistry.Incoming incoming = VoIPPushRegistry.INSTANCE.pending(); + if (incoming == null) { + return null; + } + WritableMap map = Arguments.createMap(); + map.putString("roomName", incoming.getRoomName()); + map.putString("displayName", incoming.getDisplayName()); + map.putString("handle", incoming.getHandle()); + map.putBoolean("isVideo", incoming.isVideo()); + map.putString("avatarUrl", incoming.getAvatarUrl()); + return map; + } + + void clearPendingIncomingCall() { + VoIPPushRegistry.INSTANCE.clearPending(); + } + + @Override + public void onVoIPToken(String token) { + WritableMap body = Arguments.createMap(); + body.putString("registered", token); + webRTCModule.sendEvent("voipPushEvent", body); + } + + @Override + public void onVoIPIncoming(VoIPPushRegistry.Incoming incoming) { + WritableMap payload = Arguments.createMap(); + payload.putString("roomName", incoming.getRoomName()); + payload.putString("displayName", incoming.getDisplayName()); + payload.putString("handle", incoming.getHandle()); + payload.putBoolean("isVideo", incoming.isVideo()); + payload.putString("avatarUrl", incoming.getAvatarUrl()); + WritableMap body = Arguments.createMap(); + body.putMap("incoming", payload); + webRTCModule.sendEvent("voipPushEvent", body); + } + + @Override + public void onWaitingCallDeclined(VoIPPushRegistry.Incoming incoming) { + WritableMap payload = Arguments.createMap(); + payload.putString("roomName", incoming.getRoomName()); + payload.putString("displayName", incoming.getDisplayName()); + payload.putString("handle", incoming.getHandle()); + payload.putBoolean("isVideo", incoming.isVideo()); + payload.putString("avatarUrl", incoming.getAvatarUrl()); + WritableMap body = Arguments.createMap(); + body.putMap("waitingDeclined", payload); + webRTCModule.sendEvent("voipPushEvent", body); + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java index 1b68fa751..c4f8afff5 100644 --- a/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java +++ b/android/src/main/java/com/oney/WebRTCModule/WebRTCModule.java @@ -76,9 +76,12 @@ public class WebRTCModule extends ReactContextBaseJavaModule { private FJAudioPushInstaller audioPushInstaller; private boolean audioPushInstallerInitialized; + // Core-Telecom (native call UX) and VoIP push bridging + private final TelecomController telecomController; + private final VoIPController voipController; + public WebRTCModule(ReactApplicationContext reactContext) { super(reactContext); - mPeerConnectionObservers = new SparseArray<>(); localStreams = new HashMap<>(); audioExtractionController = new AudioExtractionController(reactContext, this::getTrack); @@ -143,6 +146,11 @@ public WebRTCModule(ReactApplicationContext reactContext) { foregroundServiceController = ForegroundServiceController.getInstance(); foregroundServiceController.setContext(reactContext); audioOutputManager = new AudioOutputManager(this, reactContext); + + telecomController = new TelecomController(this, reactContext, audioOutputManager); + voipController = new VoIPController(this); + telecomController.attach(); + voipController.attach(); } @Override @@ -158,6 +166,9 @@ public void invalidate() { getUserMediaImpl.dispose(); // prevent using stale context foregroundServiceController.setContext(null); + + telecomController.detach(); + voipController.detach(); } @NonNull @@ -1757,4 +1768,76 @@ public void stopForegroundService(Promise promise) { public void removeListeners(Integer count) { // Keep: Required for RN built in Event Emitter Calls. } + + @ReactMethod + public void startTelecomCall(String displayName, String handle, boolean isVideo, Promise promise) { + String callHandle = (handle == null || handle.isEmpty()) ? displayName : handle; + telecomController.startCall(displayName, callHandle, isVideo); + promise.resolve(null); + } + + @ReactMethod + public void reportOutgoingCallConnected(Promise promise) { + telecomController.reportOutgoingCallConnected(); + promise.resolve(null); + } + + @ReactMethod + public void fulfillTelecomCallAnswered(String requestId, Promise promise) { + promise.resolve(telecomController.fulfillAnswered(requestId)); + } + + @ReactMethod + public void failTelecomCallAnswered(String requestId, Promise promise) { + telecomController.failAnswered(requestId); + promise.resolve(null); + } + + @ReactMethod + public void endTelecomCall(String reason, Promise promise) { + telecomController.endCall(reason); + promise.resolve(null); + } + + @ReactMethod + public void setTelecomCallHeld(boolean onHold, Promise promise) { + telecomController.setCallHeld(onHold); + promise.resolve(null); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public boolean hasActiveTelecomCall() { + return telecomController.hasActiveCall(); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public boolean isTelecomCallAnswered() { + return telecomController.isAnswered(); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public boolean isTelecomCallHeld() { + return telecomController.isOnHold(); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public String getPendingAnswerRequestId() { + return telecomController.pendingAnswerRequestId(); + } + + @ReactMethod + public void getVoIPToken(Promise promise) { + voipController.resolveToken(promise); + } + + @ReactMethod(isBlockingSynchronousMethod = true) + public WritableMap getPendingIncomingCall() { + return voipController.getPendingIncomingCall(); + } + + @ReactMethod + public void clearPendingIncomingCall(Promise promise) { + voipController.clearPendingIncomingCall(); + promise.resolve(null); + } } diff --git a/android/src/main/java/com/oney/WebRTCModule/foregroundService/ForegroundServiceController.java b/android/src/main/java/com/oney/WebRTCModule/foregroundService/ForegroundServiceController.java index 643461112..b0a7d870f 100644 --- a/android/src/main/java/com/oney/WebRTCModule/foregroundService/ForegroundServiceController.java +++ b/android/src/main/java/com/oney/WebRTCModule/foregroundService/ForegroundServiceController.java @@ -12,6 +12,7 @@ import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReadableMap; +import com.oney.WebRTCModule.voip.VoIPForegroundRequest; import java.util.ArrayList; import java.util.List; @@ -24,12 +25,15 @@ public class ForegroundServiceController { private static ForegroundServiceController instance; private ReactApplicationContext reactContext; + private Context appContext; private boolean cameraRequested = false; private boolean microphoneRequested = false; private boolean screenSharingAllowed = false; private boolean screenShareActive = false; + private VoIPForegroundRequest voipRequest = VoIPForegroundRequest.INACTIVE; + private String channelId = "com.fishjam.foregroundservice.channel"; private String channelName = "Fishjam Notifications"; private String notificationTitle = "[PLACEHOLDER] Tap to return to the call."; @@ -50,6 +54,9 @@ public static synchronized ForegroundServiceController getInstance() { public void setContext(ReactApplicationContext reactContext) { this.reactContext = reactContext; + if (reactContext != null) { + this.appContext = reactContext.getApplicationContext(); + } } // Called by WebRTCForegroundService after startForeground() completes. @@ -109,19 +116,37 @@ public synchronized void onScreenShareStopped(Context context) { applyState(); } + public synchronized void setVoIPRequest(VoIPForegroundRequest request) { + voipRequest = request; + applyState(); + } + + public synchronized void setVoIPHeld(boolean held) { + if (!voipRequest.isActive()) return; + voipRequest = voipRequest.withHeld(held); + applyState(); + } + + public synchronized VoIPForegroundRequest getVoIPRequest() { + return voipRequest; + } + private void applyState() { - if (reactContext == null) return; + Context context = appContext != null ? appContext : reactContext; + if (context == null) return; boolean screenShareNeedsService = screenSharingAllowed && screenShareActive; - int[] types = buildForegroundServiceTypes(cameraRequested, microphoneRequested, screenShareNeedsService); + boolean cameraNeeded = cameraRequested || voipRequest.needsCamera(); + boolean microphoneNeeded = microphoneRequested || voipRequest.needsMicrophone(); + int[] types = buildForegroundServiceTypes(cameraNeeded, microphoneNeeded, screenShareNeedsService); if (types.length == 0 && !screenShareNeedsService) { - Intent serviceIntent = new Intent(reactContext, WebRTCForegroundService.class); - reactContext.stopService(serviceIntent); + Intent serviceIntent = new Intent(context, WebRTCForegroundService.class); + context.stopService(serviceIntent); return; } - Intent serviceIntent = new Intent(reactContext, WebRTCForegroundService.class); + Intent serviceIntent = new Intent(context, WebRTCForegroundService.class); serviceIntent.putExtra("channelId", channelId); serviceIntent.putExtra("channelName", channelName); serviceIntent.putExtra("notificationTitle", notificationTitle); @@ -132,9 +157,9 @@ private void applyState() { try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - reactContext.startForegroundService(serviceIntent); + context.startForegroundService(serviceIntent); } else { - reactContext.startService(serviceIntent); + context.startService(serviceIntent); } } catch (RuntimeException e) { Log.e(TAG, "Failed to start foreground service", e); @@ -167,7 +192,9 @@ private int[] buildForegroundServiceTypes( } private boolean hasPermission(String permission) { - return ContextCompat.checkSelfPermission(reactContext, permission) + Context context = appContext != null ? appContext : reactContext; + return context != null + && ContextCompat.checkSelfPermission(context, permission) == android.content.pm.PackageManager.PERMISSION_GRANTED; } } diff --git a/android/src/main/java/com/oney/WebRTCModule/foregroundService/WebRTCForegroundService.java b/android/src/main/java/com/oney/WebRTCModule/foregroundService/WebRTCForegroundService.java index 195b71848..db9941333 100644 --- a/android/src/main/java/com/oney/WebRTCModule/foregroundService/WebRTCForegroundService.java +++ b/android/src/main/java/com/oney/WebRTCModule/foregroundService/WebRTCForegroundService.java @@ -12,8 +12,11 @@ import androidx.core.app.NotificationCompat; +import com.oney.WebRTCModule.voip.CallNotificationManager; +import com.oney.WebRTCModule.voip.VoIPForegroundRequest; + public class WebRTCForegroundService extends Service { - private static final int FOREGROUND_SERVICE_ID = 1668; + private static final int FOREGROUND_SERVICE_ID = CallNotificationManager.NOTIFICATION_ID; private final IBinder binder = new LocalBinder(); @@ -39,6 +42,35 @@ public void restartService(Intent intent) { return; } + int foregroundServiceType = 0; + int[] foregroundServiceTypesArray = intent.getIntArrayExtra("foregroundServiceTypes"); + if (foregroundServiceTypesArray != null) { + for (int value : foregroundServiceTypesArray) { + foregroundServiceType |= value; + } + } + + // Call mode: an active Core-Telecom call owns the notification slot. + // Post the ongoing CallStyle notification through startForeground() + // (FGS-attached is what makes it valid on Android 14+ without a + // full-screen intent) instead of the generic room notification. + VoIPForegroundRequest voipRequest = ForegroundServiceController.getInstance().getVoIPRequest(); + if (voipRequest.isActive() && Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CallNotificationManager callNotificationManager = new CallNotificationManager(); + Notification notification; + if (voipRequest.isConnecting()) { + notification = callNotificationManager.buildConnecting(this, voipRequest.getDisplayName()); + } else if (voipRequest.isHeld()) { + notification = callNotificationManager.buildHeld( + this, voipRequest.getDisplayName(), voipRequest.getConnectedAtMs()); + } else { + notification = callNotificationManager.buildOngoing( + this, voipRequest.getDisplayName(), voipRequest.getConnectedAtMs()); + } + startForegroundWithNotification(notification, foregroundServiceType); + return; + } + String channelId = intent.getStringExtra("channelId"); String channelName = intent.getStringExtra("channelName"); String notificationTitle = intent.getStringExtra("notificationTitle"); @@ -48,19 +80,11 @@ public void restartService(Intent intent) { if (importance == null) { importance = "high"; } - int[] foregroundServiceTypesArray = intent.getIntArrayExtra("foregroundServiceTypes"); if (channelId == null || channelName == null || notificationTitle == null || notificationContent == null) { return; } - int foregroundServiceType = 0; - if (foregroundServiceTypesArray != null) { - for (int value : foregroundServiceTypesArray) { - foregroundServiceType |= value; - } - } - Intent launchIntent = getPackageManager().getLaunchIntentForPackage(getPackageName()); if (launchIntent != null) { launchIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP); diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/AvatarLoader.kt b/android/src/main/java/com/oney/WebRTCModule/voip/AvatarLoader.kt new file mode 100644 index 000000000..82e2205fe --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/AvatarLoader.kt @@ -0,0 +1,84 @@ +package com.oney.WebRTCModule.voip + +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.BitmapShader +import android.graphics.Canvas +import android.graphics.Paint +import android.graphics.Shader +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import kotlin.math.min + +/** + * Downloads a caller avatar and hands back a circular-cropped [Bitmap]. Used by + * the incoming-call notification and the full-screen activity. + * + * Runs entirely off the main thread with short connect/read timeouts so a slow + * or unreachable image can never block the call UI; failures resolve to `null` + * and callers fall back to the initials avatar. + */ +object AvatarLoader { + private const val TAG = "FishjamVoIP.Avatar" + private const val TIMEOUT_MS = 5000 + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + + /** + * Downloads [url] and delivers a circular bitmap on the main thread, or `null` + * on any failure. No-op with a `null` result if [url] is blank. + */ + fun load(url: String?, onResult: (Bitmap?) -> Unit) { + if (url.isNullOrBlank()) { + onResult(null) + return + } + scope.launch { + val bitmap = runCatching { fetch(url) } + .onFailure { Log.e(TAG, "Failed to load avatar: ${it.localizedMessage}") } + .getOrNull() + ?.let { circularCrop(it) } + withContext(Dispatchers.Main) { onResult(bitmap) } + } + } + + private fun fetch(url: String): Bitmap? { + val connection = (URL(url).openConnection() as HttpURLConnection).apply { + connectTimeout = TIMEOUT_MS + readTimeout = TIMEOUT_MS + instanceFollowRedirects = true + } + return try { + connection.inputStream.use { BitmapFactory.decodeStream(it) } + } finally { + connection.disconnect() + } + } + + /** Center-crops [src] to a square and masks it into a circle. */ + private fun circularCrop(src: Bitmap): Bitmap { + val size = min(src.width, src.height) + val left = (src.width - size) / 2 + val top = (src.height - size) / 2 + val square = if (src.width == size && src.height == size) { + src + } else { + Bitmap.createBitmap(src, left, top, size, size) + } + + val output = Bitmap.createBitmap(size, size, Bitmap.Config.ARGB_8888) + val canvas = Canvas(output) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + shader = BitmapShader(square, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP) + } + val radius = size / 2f + canvas.drawCircle(radius, radius, radius, paint) + return output + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/CallManager.kt b/android/src/main/java/com/oney/WebRTCModule/voip/CallManager.kt new file mode 100644 index 000000000..b11d766a3 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/CallManager.kt @@ -0,0 +1,623 @@ +package com.oney.WebRTCModule.voip + +import android.content.Context +import android.content.Intent +import android.annotation.SuppressLint +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.net.Uri +import android.telecom.DisconnectCause +import androidx.annotation.RequiresApi +import androidx.core.net.toUri +import androidx.core.telecom.CallAttributesCompat +import androidx.core.telecom.CallControlResult +import androidx.core.telecom.CallControlScope +import androidx.core.telecom.CallEndpointCompat +import androidx.core.telecom.CallException +import androidx.core.telecom.CallsManager +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.WritableArray +import com.facebook.react.bridge.WritableMap +import com.oney.WebRTCModule.AudioOutputManager +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +interface CallEventsListener { + fun onStarted() + fun onAnswered(requestId: String) + fun onEnded(reason: String) + fun onFailed(reason: String) + fun onMuteChanged(muted: Boolean) + fun onHoldChanged(onHold: Boolean) +} + +/** + * Where an incoming call landed relative to whatever call already exists: + * - Current: there was no call yet, so it is registered with Telecom as usual. + * - Waiting: another call is already answered/connected, so this one only shows a heads-up + * notification - the JS layer is not told about it unless it is answered, at which + * point the current call ends and this one takes its place. + * - Rejected: both slots are taken, or the current call is still ringing/connecting. + */ +enum class IncomingCallSlot { CURRENT, WAITING, REJECTED } + +@RequiresApi(value = 26) +object CallManager { + private const val DEFAULT_INCOMING_CALL_TIMEOUT_MS = 45_000L + private const val DEFAULT_OUTGOING_CALL_TIMEOUT_MS = 60_000L + private const val DEFAULT_FULFILL_ANSWER_TIMEOUT_MS = 10_000L + + private val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) + private var callsManager: CallsManager? = null + private var registered = false + private val callNotificationManager = CallNotificationManager() + private var lastCurrentEndpoint: CallEndpointCompat? = null + private var lastEndpoints: List = emptyList() + private var audioOutputManager: AudioOutputManager? = null + + private var ringTimeoutJob: Job? = null + + private var actions: Channel? = null + + sealed interface CallAction { + data object Answer : CallAction + data object Activate : CallAction + data object Hold : CallAction + data class SetEndpoint(val endpoint: CallEndpointCompat) : CallAction + data class Disconnect(val cause: DisconnectCause) : CallAction + } + + @Volatile private var hasActiveCall = false + @Volatile private var answered = false + @Volatile private var onHold = false + @Volatile private var isOutgoing = false + @Volatile private var pendingAnswerRequestId: String? = null + private var appContext: Context? = null + private var listener: CallEventsListener? = null + private var displayName: String = "" + private var videoCall: Boolean = false + private var avatarUrl: String? = null + @Volatile private var avatarBitmap: Bitmap? = null + + // AvatarLoader callbacks arrive async. Each load captures the generation at kick + // time and is dropped if the slot's generation moved on before it landed. + @Volatile private var activeAvatarGeneration = 0 + @Volatile private var waitingAvatarGeneration = 0 + private var timeoutsLoaded = false + private var incomingCallTimeoutMs = DEFAULT_INCOMING_CALL_TIMEOUT_MS + private var outgoingCallTimeoutMs = DEFAULT_OUTGOING_CALL_TIMEOUT_MS + private var fulfillAnswerTimeoutMs = DEFAULT_FULFILL_ANSWER_TIMEOUT_MS + + // Tracks the current call's addCall coroutine so we can wait for telecom to tear down current call before registering the waiting call in its place. + private var callJob: Job? = null + + @Volatile private var hasWaitingCall = false + private var waitingDisplayName: String = "" + private var waitingHandle: String = "" + private var waitingIsVideo: Boolean = false + private var waitingAvatarUrl: String? = null + @Volatile private var waitingAvatarBitmap: Bitmap? = null + private var waitingRingTimeoutJob: Job? = null + + fun hasActiveCall(): Boolean = hasActiveCall + fun hasWaitingCall(): Boolean = hasWaitingCall + fun waitingDisplayName(): String = waitingDisplayName + fun waitingIsVideo(): Boolean = waitingIsVideo + fun isAnswered(): Boolean = answered + fun isOnHold(): Boolean = onHold + fun pendingAnswerRequestId(): String? = pendingAnswerRequestId + fun currentDisplayName(): String = displayName + fun currentIsVideo(): Boolean = videoCall + + /** The downloaded caller avatar for the active call, or null (falls back to initials). */ + fun currentAvatarBitmap(): Bitmap? = avatarBitmap + + /** The downloaded avatar for the waiting (second) call, or null until it loads. */ + fun currentWaitingAvatarBitmap(): Bitmap? = waitingAvatarBitmap + + fun startOutgoingCall(ctx: Context, displayName: String, handle: String, isVideo: Boolean) { + register(ctx, displayName, handle, isVideo, CallAttributesCompat.DIRECTION_OUTGOING) + } + + @Synchronized + fun reportIncomingCall( + ctx: Context, + displayName: String, + handle: String, + isVideo: Boolean, + avatarUrl: String? = null, + ): IncomingCallSlot { + if (!hasActiveCall) { + register(ctx, displayName, handle, isVideo, CallAttributesCompat.DIRECTION_INCOMING, avatarUrl) + return IncomingCallSlot.CURRENT + } + if (hasWaitingCall || !answered) { + return IncomingCallSlot.REJECTED + } + registerWaiting(ctx, displayName, handle, isVideo, avatarUrl) + return IncomingCallSlot.WAITING + } + + @Synchronized + private fun registerWaiting( + ctx: Context, + displayName: String, + handle: String, + isVideo: Boolean, + avatarUrl: String? = null, + ) { + hasWaitingCall = true + waitingDisplayName = displayName + waitingHandle = handle + waitingIsVideo = isVideo + waitingAvatarUrl = avatarUrl + waitingAvatarBitmap = null + val avatarGeneration = ++waitingAvatarGeneration + + val appContext = ctx.applicationContext + callNotificationManager.showWaiting(appContext, displayName, isVideo) + AvatarLoader.load(avatarUrl) { bitmap -> + if (bitmap != null && hasWaitingCall && avatarGeneration == waitingAvatarGeneration) { + waitingAvatarBitmap = bitmap + callNotificationManager.showWaiting(appContext, displayName, isVideo) + appContext.sendBroadcast( + Intent(IncomingCallActivity.ACTION_AVATAR_READY) + .setPackage(appContext.packageName), + ) + } + } + waitingRingTimeoutJob = scope.launch { + delay(incomingCallTimeoutMs) + declineWaitingCall(ctx) + } + } + + @Synchronized + fun declineWaitingCall(ctx: Context) { + if (!hasWaitingCall) return + hasWaitingCall = false + waitingRingTimeoutJob?.cancel() + waitingRingTimeoutJob = null + waitingAvatarUrl = null + waitingAvatarBitmap = null + callNotificationManager.cancelWaiting(ctx.applicationContext) + notifyWaitingEnded(ctx) + VoIPPushRegistry.discardWaitingIncoming() + } + + @Synchronized + fun acceptWaitingCall(ctx: Context) { + if (!hasWaitingCall) return + hasWaitingCall = false + waitingRingTimeoutJob?.cancel() + waitingRingTimeoutJob = null + callNotificationManager.cancelWaiting(ctx.applicationContext) + notifyWaitingEnded(ctx) + + val displayName = waitingDisplayName + val handle = waitingHandle + val isVideo = waitingIsVideo + val avatarUrl = waitingAvatarUrl + waitingAvatarUrl = null + waitingAvatarBitmap = null + val previousJob = callJob + + if (hasActiveCall) { + endCall(DisconnectCause(DisconnectCause.LOCAL)) + } + + scope.launch { + previousJob?.join() + VoIPPushRegistry.revealWaitingIncoming() + register(ctx, displayName, handle, isVideo, CallAttributesCompat.DIRECTION_INCOMING, avatarUrl) + answer() + launchHostApp(ctx.applicationContext) + } + } + + /** Dismisses a waiting-call screen that is still showing the ring UI. */ + private fun notifyWaitingEnded(ctx: Context) { + val appContext = ctx.applicationContext + appContext.sendBroadcast( + Intent(IncomingCallActivity.ACTION_WAITING_ENDED).setPackage(appContext.packageName) + ) + } + + fun answer() { actions?.trySend(CallAction.Answer) } + private fun setCallActive() { actions?.trySend(CallAction.Activate) } + fun setCallHeld(onHold: Boolean) { + actions?.trySend(if (onHold) CallAction.Hold else CallAction.Activate) + } + fun fulfillAnswered(requestId: String): Boolean { + if (!FulfillRequestManager.fulfill(requestId)) return false + if (pendingAnswerRequestId == requestId) { + pendingAnswerRequestId = null + } + markConnected() + return true + } + + fun reportOutgoingCallConnected() { + if (!hasActiveCall || !isOutgoing) return + markConnected() + } + + private fun markConnected() { + DialtonePlayer.stop() + setCallActive() + showOngoingNotification() + } + + fun failAnswered(requestId: String) { + if (!FulfillRequestManager.cancel(requestId)) return + if (pendingAnswerRequestId == requestId) { + pendingAnswerRequestId = null + } + endCall(DisconnectCause(DisconnectCause.ERROR)) + } + + fun endCall(cause: DisconnectCause = DisconnectCause(DisconnectCause.LOCAL)) { + actions?.trySend(CallAction.Disconnect(cause)) + } + + fun setListener(l: CallEventsListener?) { listener = l } + + fun reasonToCause(reason: String): DisconnectCause = when (reason) { + "local" -> DisconnectCause(DisconnectCause.LOCAL) + "rejected" -> DisconnectCause(DisconnectCause.REJECTED) + "missed" -> DisconnectCause(DisconnectCause.MISSED) + "remote" -> DisconnectCause(DisconnectCause.REMOTE) + "answeredElsewhere" -> DisconnectCause(DisconnectCause.ANSWERED_ELSEWHERE) + "failed" -> DisconnectCause(DisconnectCause.ERROR) + else -> DisconnectCause(DisconnectCause.LOCAL) + } + + private fun telecomSafeCause(cause: DisconnectCause): DisconnectCause = when (cause.code) { + DisconnectCause.LOCAL, + DisconnectCause.REMOTE, + DisconnectCause.MISSED, + DisconnectCause.REJECTED -> cause + DisconnectCause.ANSWERED_ELSEWHERE -> DisconnectCause(DisconnectCause.REJECTED) + else -> DisconnectCause(DisconnectCause.LOCAL) + } + + private fun causeToReason(cause: DisconnectCause): String = when (cause.code) { + DisconnectCause.LOCAL -> "local" + DisconnectCause.REJECTED -> "rejected" + DisconnectCause.MISSED -> "missed" + DisconnectCause.REMOTE -> "remote" + DisconnectCause.ANSWERED_ELSEWHERE -> "answeredElsewhere" + DisconnectCause.ERROR -> "failed" + else -> "remote" + } + + fun setAudioOutputManager(manager: AudioOutputManager?) { + audioOutputManager = manager + // On cold start the call is registered from the FCM push before React + // attaches the manager, so it missed setTelecomOwnsRouting(true) and + // the endpoint updates emitted inside the addCall block. Without this + // replay, selectAudioOutput falls through to AudioManager-based + // routing, which the platform ignores while Telecom owns the call. + if (manager != null && hasActiveCall) { + manager.setTelecomOwnsRouting(true) + manager.onTelecomAudioStateChanged( + lastCurrentEndpoint?.toWritableMap(), + lastEndpoints.map { it.toWritableMap() }.toWritableArray(), + ) + } + } + + fun selectEndpoint(id: String): Boolean { + val endpoint = lastEndpoints.firstOrNull { it.identifier.toString() == id } ?: return false + return actions?.trySend(CallAction.SetEndpoint(endpoint))?.isSuccess == true + } + + /** Fresh serialization of the last-known telecom endpoints for bridge consumers. */ + fun availableEndpointsSnapshot(): WritableArray = lastEndpoints.map { it.toWritableMap() }.toWritableArray() + + @SuppressLint("MissingPermission") + private fun ensureRegistered(context: Context) { + loadTimeouts(context) + callNotificationManager.initChannels(context.applicationContext) + + if (callsManager == null) { + callsManager = CallsManager(context.applicationContext) + } + + if (!registered) { + callsManager!!.registerAppWithTelecom(CallsManager.CAPABILITY_SUPPORTS_VIDEO_CALLING or CallsManager.CAPABILITY_SUPPORTS_CALL_STREAMING) + registered = true + } + } + + private fun loadTimeouts(context: Context) { + if (timeoutsLoaded) return + timeoutsLoaded = true + incomingCallTimeoutMs = readTimeoutMs(context, "VoIPIncomingCallTimeout", DEFAULT_INCOMING_CALL_TIMEOUT_MS) + outgoingCallTimeoutMs = readTimeoutMs(context, "VoIPOutgoingCallTimeout", DEFAULT_OUTGOING_CALL_TIMEOUT_MS) + fulfillAnswerTimeoutMs = readTimeoutMs(context, "VoIPFulfillAnswerTimeout", DEFAULT_FULFILL_ANSWER_TIMEOUT_MS) + } + + /** Reads a manifest meta-data value in seconds; returns milliseconds. */ + private fun readTimeoutMs(context: Context, key: String, defaultMs: Long): Long = try { + val appInfo = context.packageManager.getApplicationInfo( + context.packageName, + PackageManager.GET_META_DATA, + ) + val seconds = appInfo.metaData?.getInt(key, (defaultMs / 1000).toInt()) + ?: (defaultMs / 1000).toInt() + if (seconds > 0) seconds * 1000L else defaultMs + } catch (_: Exception) { + defaultMs + } + + @Synchronized + @SuppressLint("MissingPermission") + private fun register( + ctx: Context, + displayName: String, + handle: String, + isVideo: Boolean, + direction: Int, + avatarUrl: String? = null, + ) { + ensureRegistered(ctx) + + if (hasActiveCall) return + + val channel = Channel(Channel.BUFFERED) + actions = channel + hasActiveCall = true + answered = false + onHold = false + pendingAnswerRequestId = null + + this.displayName = displayName + this.videoCall = isVideo + this.avatarUrl = avatarUrl + this.avatarBitmap = null + val avatarGeneration = ++activeAvatarGeneration + val isIncoming = direction == CallAttributesCompat.DIRECTION_INCOMING + isOutgoing = direction == CallAttributesCompat.DIRECTION_OUTGOING + val appContext = ctx.applicationContext + this.appContext = appContext + val callType = if (isVideo) CallAttributesCompat.CALL_TYPE_VIDEO_CALL else CallAttributesCompat.CALL_TYPE_AUDIO_CALL + val callAttributes = CallAttributesCompat( + displayName = displayName, + address = "sip:${Uri.encode(handle)}".toUri(), + direction = direction, + callType = callType, + callCapabilities = + CallAttributesCompat.SUPPORTS_SET_INACTIVE or // can be put on hold + CallAttributesCompat.SUPPORTS_STREAM or // can stream to other surfaces (watch, car) + CallAttributesCompat.SUPPORTS_TRANSFER // can be transferred between devices + + ) + + callJob = scope.launch { + try { + callsManager!!.addCall( + callAttributes, + // Fires ONLY for external answer requests (system UI, Auto, + // Bluetooth, watch). App-initiated answers reach handleAnswered + // via processActions instead. + onAnswer = { _ -> + handleAnswered() + launchHostApp(appContext) + }, + onDisconnect = { cause -> + FulfillRequestManager.cancelAll() + pendingAnswerRequestId = null + listener?.onEnded(causeToReason(cause)) + }, + onSetActive = { + answered = true + cancelRingTimeout() + onHold = false + VoIPForegroundServiceController.onCallHeld(false) + listener?.onHoldChanged(false) + }, + onSetInactive = { + onHold = true + VoIPForegroundServiceController.onCallHeld(true) + listener?.onHoldChanged(true) + } + ) { + listener?.onStarted() + if (isIncoming) { + callNotificationManager.showIncoming(ctx.applicationContext, displayName, isVideo) + AvatarLoader.load(avatarUrl) { bitmap -> + if (bitmap != null && hasActiveCall && !answered && avatarGeneration == activeAvatarGeneration) { + avatarBitmap = bitmap + callNotificationManager.updateIncomingAvatar( + appContext, displayName, isVideo, + ) + appContext.sendBroadcast( + Intent(IncomingCallActivity.ACTION_AVATAR_READY) + .setPackage(appContext.packageName), + ) + } + } + startRingTimeout(incomingCallTimeoutMs) + } else { + showConnectingNotification() + startRingTimeout(outgoingCallTimeoutMs) + // Ringback while the outgoing call is connecting; stopped on + // connect (markConnected) or teardown (finally below). + DialtonePlayer.play() + } + audioOutputManager?.setTelecomOwnsRouting(true) + launch { processActions(channel.consumeAsFlow(), callType) } + launch { currentCallEndpoint.collect { endpoint -> + lastCurrentEndpoint = endpoint + audioOutputManager?.onTelecomAudioStateChanged( + endpoint.toWritableMap(), + lastEndpoints.map { it.toWritableMap() }.toWritableArray() + ) + } } + launch { availableEndpoints.collect { endpoints -> + lastEndpoints = endpoints + audioOutputManager?.onTelecomAudioStateChanged( + lastCurrentEndpoint?.toWritableMap(), + endpoints.map { it.toWritableMap() }.toWritableArray() + ) + } } + launch { isMuted.collect { listener?.onMuteChanged(it) } } + } + } catch (e: CancellationException) { + // Never swallow coroutine cancellation — let it propagate so the + // parent scope tears down cleanly. + throw e + } catch (e: CallException) { + listener?.onFailed(e.message ?: "addCall failed (code ${e.code})") + } catch (e: UnsupportedOperationException) { + listener?.onFailed(e.message ?: "Telecom not supported on this device") + } finally { + synchronized(this@CallManager) { + DialtonePlayer.stop() + cancelRingTimeout() + FulfillRequestManager.cancelAll() + pendingAnswerRequestId = null + hasActiveCall = false + answered = false + onHold = false + isOutgoing = false + this@CallManager.avatarUrl = null + avatarBitmap = null + actions = null + channel.close() + audioOutputManager?.setTelecomOwnsRouting(false) + LockScreenController.onCallEnded() + VoIPForegroundServiceController.onCallEnded() + callNotificationManager.cancel(ctx.applicationContext) + VoIPPushRegistry.clearPending() + // Dismiss IncomingCallActivity if the call ended before the + // user acted (remote hangup, timeout, answered elsewhere). + ctx.applicationContext.sendBroadcast( + Intent(IncomingCallActivity.ACTION_CALL_ENDED).setPackage(appContext.packageName) + ) + } + } + } + } + + private suspend fun CallControlScope.processActions( + src: Flow, + callType: Int, + ) { + src.collect { action -> + val result: CallControlResult = when (action) { + CallAction.Answer -> answer(callType) + CallAction.Activate -> setActive() + CallAction.Hold -> setInactive() + is CallAction.SetEndpoint -> requestEndpointChange(action.endpoint) + is CallAction.Disconnect -> { disconnect(telecomSafeCause(action.cause)) } + } + + if (action is CallAction.Disconnect) { + listener?.onEnded(causeToReason(action.cause)) + this@processActions.cancel() + } else if (result is CallControlResult.Error) { + listener?.onFailed("telecom action failed: ${result.errorCode}") + } else if (action == CallAction.Answer) { + // App-initiated answer succeeded — onAnswer won't fire for this, + // so run the post-answer side effects here. + handleAnswered() + } else if (action == CallAction.Activate) { + answered = true + cancelRingTimeout() + // Activate also reports an outgoing call as connected, so this can + // emit false before a call was ever held. + onHold = false + VoIPForegroundServiceController.onCallHeld(false) + listener?.onHoldChanged(false) + } else if (action == CallAction.Hold) { + onHold = true + VoIPForegroundServiceController.onCallHeld(true) + listener?.onHoldChanged(true) + } + } + } + + private fun launchHostApp(context: Context) { + val intent = context.packageManager.getLaunchIntentForPackage(context.packageName) ?: return + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + intent.putExtra(LockScreenController.VOIP_ANSWER, true) + context.startActivity(intent) + } + + /** Post-answer side effects shared by external (onAnswer) and app-initiated answers. */ + @Synchronized + private fun handleAnswered() { + cancelRingTimeout() + if (!hasActiveCall || answered || pendingAnswerRequestId != null) return + answered = true + callNotificationManager.stopVibration() + appContext?.let { LockScreenController.onCallAnswered(it) } + showConnectingNotification() + + val requestId = FulfillRequestManager.createRequest(fulfillAnswerTimeoutMs) { timedOutRequestId -> + if (pendingAnswerRequestId != timedOutRequestId) return@createRequest + pendingAnswerRequestId = null + listener?.onFailed("answer fulfill timed out") + endCall(DisconnectCause(DisconnectCause.ERROR)) + } + pendingAnswerRequestId = requestId + listener?.onAnswered(requestId) + } + + private fun startRingTimeout(timeoutMs: Long) { + cancelRingTimeout() + ringTimeoutJob = scope.launch { + delay(timeoutMs) + if (!hasActiveCall || answered) return@launch + endCall(DisconnectCause(DisconnectCause.MISSED)) + } + } + + private fun cancelRingTimeout() { + ringTimeoutJob?.cancel() + ringTimeoutJob = null + } + + private fun showConnectingNotification() { + VoIPForegroundServiceController.onCallConnecting(displayName, videoCall) + } + + private fun showOngoingNotification() { + VoIPForegroundServiceController.onCallConnected(displayName, videoCall) + } + + private fun CallEndpointCompat.toWritableMap(): WritableMap = Arguments.createMap().apply { + putString("type", normalizedType()) + putString("nativeType", normalizedType()) + putString("name", name.toString()) + putString("id", identifier.toString()) // ParcelUuid -> String; AudioDevice.id is already a String + } + + private fun List.toWritableArray(): WritableArray { + val array = Arguments.createArray() + for (map in this) { + array.pushMap(map) + } + return array + } + + private fun CallEndpointCompat.normalizedType() = when (type) { + CallEndpointCompat.TYPE_EARPIECE -> "earpiece" + CallEndpointCompat.TYPE_SPEAKER -> "speaker" + CallEndpointCompat.TYPE_BLUETOOTH -> "bluetooth" + CallEndpointCompat.TYPE_WIRED_HEADSET -> "wiredHeadset" + CallEndpointCompat.TYPE_STREAMING -> "streaming" // watch/Auto — no AudioDeviceInfo analog + else -> "unknown" + } +} \ No newline at end of file diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/CallNotificationManager.kt b/android/src/main/java/com/oney/WebRTCModule/voip/CallNotificationManager.kt new file mode 100644 index 000000000..c81b69c87 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/CallNotificationManager.kt @@ -0,0 +1,430 @@ +package com.oney.WebRTCModule.voip + +import android.annotation.SuppressLint +import android.app.Notification +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.media.AudioAttributes +import android.media.AudioManager +import android.media.RingtoneManager +import android.os.Build +import android.os.VibrationAttributes +import android.os.VibrationEffect +import android.os.Vibrator +import android.os.VibratorManager +import androidx.annotation.RequiresApi +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.Person +import androidx.core.graphics.drawable.IconCompat + +/** + * Builds and posts the single CallStyle notification that keeps the app in + * foreground-execution priority for the lifetime of a Core-Telecom call. + * + * Core-Telecom grants foreground priority only while a valid CallStyle + * notification is posted within ~5s of [CallsManager.addCall] and stays valid + * until the call ends, so [CallManager] posts here as soon as the call is + * accepted and cancels in its `finally` block. + * + * One notification (id [NOTIFICATION_ID]) transitions incoming -> connecting -> ongoing: + * - incoming is posted via notify() with a full-screen intent (rings over + * the lock screen; the FSI also makes the CallStyle valid on Android 14+). + * - ongoing is built by [buildOngoing] and posted by + * WebRTCForegroundService.startForeground() under the same id, so being + * FGS-attached is what keeps it valid — no full-screen intent needed, and + * the service's mediaProjection type covers screen share, the one + * capability Telecom's foreground delegation does not include. + */ +@RequiresApi(26) +class CallNotificationManager { + companion object { + private const val CHANNEL_INCOMING = "fishjam_telecom_incoming" + private const val CHANNEL_ONGOING = "fishjam_telecom_ongoing" + private const val CHANNEL_WAITING = "fishjam_telecom_waiting_incoming" + const val NOTIFICATION_ID = 8400 + + const val WAITING_NOTIFICATION_ID = 8401 + + /** + * Optional app `` key for the CallStyle small (status-bar) icon. + * Accepts `android:resource="@drawable/…"` or `android:value="drawable_name"`. + * Resolved from the manifest so it works even when the app is killed. + */ + private const val KEY_NOTIFICATION_ICON = "VoIPNotificationIcon" + + // Distinct request codes so the PendingIntents don't collapse into one. + private const val RC_ANSWER = 1 + private const val RC_DECLINE = 2 + private const val RC_HANGUP = 3 + private const val RC_FULL_SCREEN = 4 + private const val RC_CONTENT = 5 + private const val RC_ANSWER_WAITING = 6 + private const val RC_DECLINE_WAITING = 7 + private const val RC_FULL_SCREEN_WAITING = 8 + + private val RING_VIBRATION_PATTERN = longArrayOf(0, 350, 200, 350, 1200) + } + + private val ringToneUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE) + private var channelsReady = false + private var vibrator: Vibrator? = null + + fun initChannels(context: Context) { + if (channelsReady) return + val nm = NotificationManagerCompat.from(context) + + val incomingChannel = NotificationChannelCompat.Builder( + CHANNEL_INCOMING, + NotificationManagerCompat.IMPORTANCE_HIGH, + ).setName("Incoming calls") + .setDescription("Handles the notifications when receiving a call") + .setVibrationEnabled(false).setSound( + ringToneUri, + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setLegacyStreamType(AudioManager.STREAM_RING) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE).build(), + ).build() + + val ongoingChannel = NotificationChannelCompat.Builder( + CHANNEL_ONGOING, + NotificationManagerCompat.IMPORTANCE_DEFAULT, + ) + .setName("Ongoing calls") + .setDescription("Displays the ongoing call notifications") + .setSound(null, null) + .setVibrationEnabled(false) + .build() + + val waitingChannel = NotificationChannelCompat.Builder( + CHANNEL_WAITING, + NotificationManagerCompat.IMPORTANCE_HIGH, + ).setName("Call waiting") + .setDescription("Second incoming call while on a call") + .setVibrationEnabled(false) + .setSound(null, null).build() + + nm.createNotificationChannel(incomingChannel) + nm.createNotificationChannel(ongoingChannel) + nm.createNotificationChannel(waitingChannel) + channelsReady = true + } + + /** Incoming call: rings, shows over the lock screen, offers Answer/Decline. */ + fun showIncoming(context: Context, displayName: String, isVideo: Boolean) { + val ctx = context.applicationContext + initChannels(ctx) + notify(ctx, buildIncoming(ctx, displayName, isVideo)) + startVibration(ctx) + } + + /** + * Re-posts the incoming notification once the caller avatar has downloaded (the + * bitmap is read from [CallManager]). Does not re-ring — the call is already ringing. + */ + fun updateIncomingAvatar(context: Context, displayName: String, isVideo: Boolean) { + val ctx = context.applicationContext + initChannels(ctx) + notify(ctx, buildIncoming(ctx, displayName, isVideo)) + } + + private fun buildIncoming(ctx: Context, displayName: String, isVideo: Boolean): Notification = + callNotificationBuilder(ctx, CHANNEL_INCOMING, displayName, if (isVideo) "Incoming video call" else "Incoming call") + .setStyle( + NotificationCompat.CallStyle.forIncomingCall( + person(ctx, displayName, CallManager.currentAvatarBitmap()), + declinePendingIntent(ctx), + answerPendingIntent(ctx), + ) + ) + .setFullScreenIntent(fullScreenPendingIntent(ctx), true) + .setContentIntent(fullScreenPendingIntent(ctx)) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_MAX) + .build() + + fun showWaiting(context: Context, displayName: String, isVideo: Boolean) { + val ctx = context.applicationContext + initChannels(ctx) + + val notification = + callNotificationBuilder( + ctx, + CHANNEL_WAITING, + displayName, + if (isVideo) "Incoming video call" else "Incoming call", + ) + .setStyle( + NotificationCompat.CallStyle.forIncomingCall( + person(ctx, displayName, CallManager.currentWaitingAvatarBitmap()), + declineWaitingPendingIntent(ctx), + answerWaitingPendingIntent(ctx), + ) + ) + .setFullScreenIntent(waitingFullScreenPendingIntent(ctx), true) + .setContentIntent(appContentPendingIntent(ctx)) + .setOngoing(true) + .setAutoCancel(false) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .build() + + notify(ctx, notification, WAITING_NOTIFICATION_ID) + } + + fun cancelWaiting(context: Context) { + NotificationManagerCompat.from(context.applicationContext).cancel(WAITING_NOTIFICATION_ID) + } + + fun buildOngoing( + context: Context, + displayName: String, + connectedAtMs: Long, + ): Notification { + val ctx = context.applicationContext + initChannels(ctx) + return ongoingBuilder(ctx, displayName, connectedAtMs).build() + } + + fun buildConnecting(context: Context, displayName: String): Notification { + val ctx = context.applicationContext + initChannels(ctx) + return callNotificationBuilder(ctx, CHANNEL_ONGOING, displayName, "Connecting…") + .setStyle( + NotificationCompat.CallStyle.forOngoingCall( + person(ctx, displayName, CallManager.currentAvatarBitmap()), + hangupPendingIntent(ctx), + ) + ) + .setContentIntent(appContentPendingIntent(ctx)) + .setOngoing(true) + .setAutoCancel(false) + .setUsesChronometer(false) + .build() + } + + fun buildHeld(context: Context, displayName: String, connectedAtMs: Long): Notification { + val ctx = context.applicationContext + initChannels(ctx) + return callNotificationBuilder(ctx, CHANNEL_ONGOING, displayName, "On hold") + .setStyle( + NotificationCompat.CallStyle.forOngoingCall( + person(ctx, displayName, CallManager.currentAvatarBitmap()), + hangupPendingIntent(ctx), + ) + ) + .setContentIntent(appContentPendingIntent(ctx)) + .setOngoing(true) + .setAutoCancel(false) + .setUsesChronometer(true) + .setWhen(connectedAtMs) + .build() + } + + private fun ongoingBuilder( + ctx: Context, + displayName: String, + connectedAtMs: Long, + ): NotificationCompat.Builder = + callNotificationBuilder(ctx, CHANNEL_ONGOING, displayName, "Ongoing call") + .setStyle( + NotificationCompat.CallStyle.forOngoingCall( + person(ctx, displayName, CallManager.currentAvatarBitmap()), + hangupPendingIntent(ctx), + ) + ) + .setContentIntent(appContentPendingIntent(ctx)) + .setOngoing(true) + .setAutoCancel(false) + .setUsesChronometer(true) + .setWhen(connectedAtMs) + + fun cancel(context: Context) { + stopVibration() + NotificationManagerCompat.from(context.applicationContext).cancel(NOTIFICATION_ID) + } + + /** + * Starts the looping ring vibration. Honors the ringer mode (silent -> no + * buzz) and tags the vibration as a ringtone so the OS applies its own + * ring/DND policy. Safe to call repeatedly; the latest call replaces any + * in-flight vibration. + */ + @SuppressLint("MissingPermission") + private fun startVibration(context: Context) { + val ctx = context.applicationContext + val audioManager = ctx.getSystemService(Context.AUDIO_SERVICE) as? AudioManager + if (audioManager?.ringerMode == AudioManager.RINGER_MODE_SILENT) return + + val vib = resolveVibrator(ctx).also { vibrator = it } + if (vib == null || !vib.hasVibrator()) return + + val effect = VibrationEffect.createWaveform(RING_VIBRATION_PATTERN, 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + vib.vibrate( + effect, + VibrationAttributes.Builder().setUsage(VibrationAttributes.USAGE_RINGTONE).build(), + ) + } else { + @Suppress("DEPRECATION") + vib.vibrate( + effect, + AudioAttributes.Builder() + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) + .build(), + ) + } + } + + /** Stops the ring vibration. Called on answer, decline, hangup, timeout or teardown. */ + @SuppressLint("MissingPermission") + fun stopVibration() { + vibrator?.cancel() + vibrator = null + } + + private fun resolveVibrator(ctx: Context): Vibrator? = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (ctx.getSystemService(Context.VIBRATOR_MANAGER_SERVICE) as? VibratorManager)?.defaultVibrator + } else { + @Suppress("DEPRECATION") + ctx.getSystemService(Context.VIBRATOR_SERVICE) as? Vibrator + } + + // ---- builders -------------------------------------------------------- + + private fun callNotificationBuilder(ctx: Context, channelId: String, title: String, text: String): NotificationCompat.Builder = + NotificationCompat.Builder(ctx, channelId) + .setSmallIcon(appIcon(ctx)) + .setContentTitle(title) + .setContentText(text) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + private fun person(ctx: Context, displayName: String, avatar: Bitmap?): Person { + val icon = if (avatar != null) { + IconCompat.createWithBitmap(avatar) + } else { + IconCompat.createWithResource(ctx, android.R.drawable.ic_menu_call) + } + return Person.Builder() + .setName(displayName) + .setIcon(icon) + .setImportant(true) + .build() + } + + private fun answerPendingIntent(ctx: Context): PendingIntent { + val intent = + Intent(ctx, IncomingCallActivity::class.java).apply { + action = IncomingCallActivity.ACTION_ANSWER + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + return PendingIntent.getActivity(ctx, RC_ANSWER, intent, immutable()) + } + + private fun declinePendingIntent(ctx: Context): PendingIntent = + PendingIntent.getBroadcast( + ctx, + RC_DECLINE, + Intent(ctx, EndCallNotificationReceiver::class.java).setAction(EndCallNotificationReceiver.ACTION_DECLINE), + immutable(), + ) + + // Unlike the primary call's answer, accepting the waiting call needs no Activity - it + // just ends the current call and registers this one in its place (CallManager handles + // launching the host app itself), so both actions go through the same broadcast receiver. + private fun answerWaitingPendingIntent(ctx: Context): PendingIntent = + PendingIntent.getBroadcast( + ctx, + RC_ANSWER_WAITING, + Intent(ctx, EndCallNotificationReceiver::class.java).setAction(EndCallNotificationReceiver.ACTION_ANSWER_WAITING), + immutable(), + ) + + private fun declineWaitingPendingIntent(ctx: Context): PendingIntent = + PendingIntent.getBroadcast( + ctx, + RC_DECLINE_WAITING, + Intent(ctx, EndCallNotificationReceiver::class.java).setAction(EndCallNotificationReceiver.ACTION_DECLINE_WAITING), + immutable(), + ) + + private fun hangupPendingIntent(ctx: Context): PendingIntent = + PendingIntent.getBroadcast( + ctx, + RC_HANGUP, + Intent(ctx, EndCallNotificationReceiver::class.java).setAction(EndCallNotificationReceiver.ACTION_HANGUP), + immutable(), + ) + + private fun fullScreenPendingIntent(ctx: Context): PendingIntent { + val intent = + Intent(ctx, IncomingCallActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + return PendingIntent.getActivity(ctx, RC_FULL_SCREEN, intent, immutable()) + } + + private fun waitingFullScreenPendingIntent(ctx: Context): PendingIntent { + val intent = + Intent(ctx, IncomingCallActivity::class.java).apply { + action = IncomingCallActivity.ACTION_SHOW_WAITING + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + } + return PendingIntent.getActivity(ctx, RC_FULL_SCREEN_WAITING, intent, immutable()) + } + + private fun appContentPendingIntent(ctx: Context): PendingIntent { + val launch = + ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.apply { + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + } ?: Intent() + return PendingIntent.getActivity(ctx, RC_CONTENT, launch, immutable()) + } + + private fun immutable(): Int = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + + @SuppressLint("MissingPermission") + private fun notify(ctx: Context, notification: Notification, id: Int = NOTIFICATION_ID) { + try { + NotificationManagerCompat.from(ctx).notify(id, notification) + } catch (_: SecurityException) { + // POST_NOTIFICATIONS not granted (Android 13+). The host app is + // responsible for requesting it; swallow so the call still proceeds. + } + } + + private fun appIcon(ctx: Context): Int { + notificationIconOverride(ctx)?.let { return it } + return try { + ctx.packageManager.getApplicationInfo(ctx.packageName, 0).icon + .takeIf { it != 0 } ?: android.R.drawable.sym_def_app_icon + } catch (_: PackageManager.NameNotFoundException) { + android.R.drawable.sym_def_app_icon + } + } + + /** Resolves [KEY_NOTIFICATION_ICON] as either a resource ref or a drawable/mipmap name. */ + private fun notificationIconOverride(ctx: Context): Int? = + try { + val meta = ctx.packageManager + .getApplicationInfo(ctx.packageName, PackageManager.GET_META_DATA) + .metaData + when (val value = meta?.get(KEY_NOTIFICATION_ICON)) { + is Int -> value.takeIf { it != 0 } + is String -> ctx.resources.getIdentifier(value, "drawable", ctx.packageName) + .takeIf { it != 0 } + ?: ctx.resources.getIdentifier(value, "mipmap", ctx.packageName).takeIf { it != 0 } + else -> null + } + } catch (_: Throwable) { + null + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/DialtonePlayer.kt b/android/src/main/java/com/oney/WebRTCModule/voip/DialtonePlayer.kt new file mode 100644 index 000000000..3e5970e21 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/DialtonePlayer.kt @@ -0,0 +1,52 @@ +package com.oney.WebRTCModule.voip + +import android.media.AudioManager +import android.media.ToneGenerator +import android.util.Log + +/** + * Plays the outgoing-call ringback ("dialtone") while an outgoing call is + * connecting, until [stop] is called. + * + * Synthesizes the standard call ringback with [ToneGenerator] on the voice-call + * stream — no bundled asset, and correct in-call routing by construction. + * Every method is a graceful no-op on failure, so callers never need to guard. + */ +object DialtonePlayer { + private const val TAG = "FishjamVoIP.Dialtone" + + /** ToneGenerator volume (0-100). */ + private const val TONE_VOLUME = 80 + + @Volatile + private var tone: ToneGenerator? = null + + /** Starts the ringback. No-op if already playing. */ + @Synchronized + fun play() { + if (tone != null) return + try { + val tg = ToneGenerator(AudioManager.STREAM_VOICE_CALL, TONE_VOLUME) + tone = tg + // TONE_SUP_RINGTONE carries its own on/off cadence and repeats until stopped. + tg.startTone(ToneGenerator.TONE_SUP_RINGTONE) + } catch (e: Throwable) { + Log.e(TAG, "Failed to start ringback tone: ${e.localizedMessage}") + tone?.release() + tone = null + } + } + + /** Stops and releases the ringback. Safe to call when nothing is playing. */ + @Synchronized + fun stop() { + val tg = tone ?: return + tone = null + try { + tg.stopTone() + tg.release() + } catch (e: Throwable) { + Log.e(TAG, "Error stopping ringback tone: ${e.localizedMessage}") + } + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/EndCallNotificationReceiver.kt b/android/src/main/java/com/oney/WebRTCModule/voip/EndCallNotificationReceiver.kt new file mode 100644 index 000000000..dab9f20f7 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/EndCallNotificationReceiver.kt @@ -0,0 +1,31 @@ +package com.oney.WebRTCModule.voip + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.telecom.DisconnectCause +import androidx.annotation.RequiresApi + +/** + * Handles notification actions without bringing an Activity forward: decline + * and hang up, plus answering/declining a waiting (second) call. + */ +@RequiresApi(Build.VERSION_CODES.O) +class EndCallNotificationReceiver : BroadcastReceiver() { + companion object { + const val ACTION_DECLINE = "fishjam.voip.ACTION_DECLINE" + const val ACTION_HANGUP = "fishjam.voip.ACTION_HANGUP" + const val ACTION_ANSWER_WAITING = "fishjam.voip.ACTION_ANSWER_WAITING" + const val ACTION_DECLINE_WAITING = "fishjam.voip.ACTION_DECLINE_WAITING" + } + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_DECLINE -> CallManager.endCall(DisconnectCause(DisconnectCause.REJECTED)) + ACTION_HANGUP -> CallManager.endCall(DisconnectCause(DisconnectCause.LOCAL)) + ACTION_ANSWER_WAITING -> CallManager.acceptWaitingCall(context) + ACTION_DECLINE_WAITING -> CallManager.declineWaitingCall(context) + } + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/FulfillRequestManager.kt b/android/src/main/java/com/oney/WebRTCModule/voip/FulfillRequestManager.kt new file mode 100644 index 000000000..484a0d14b --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/FulfillRequestManager.kt @@ -0,0 +1,54 @@ +package com.oney.WebRTCModule.voip + +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +internal object FulfillRequestManager { + private val lock = Any() + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) + private val timeoutJobs = mutableMapOf() + + fun createRequest(timeoutMs: Long, onTimeout: (String) -> Unit): String { + val requestId = UUID.randomUUID().toString() + val timeoutJob = scope.launch(start = CoroutineStart.LAZY) { + delay(timeoutMs) + val didRemove = synchronized(lock) { + timeoutJobs.remove(requestId) != null + } + if (didRemove) { + onTimeout(requestId) + } + } + + synchronized(lock) { + timeoutJobs[requestId] = timeoutJob + } + timeoutJob.start() + return requestId + } + + fun fulfill(requestId: String): Boolean = remove(requestId) + + fun cancel(requestId: String): Boolean = remove(requestId) + + fun cancelAll() { + val jobs = synchronized(lock) { + timeoutJobs.values.toList().also { timeoutJobs.clear() } + } + jobs.forEach(Job::cancel) + } + + private fun remove(requestId: String): Boolean { + val job = synchronized(lock) { + timeoutJobs.remove(requestId) + } ?: return false + job.cancel() + return true + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/IncomingCallActivity.kt b/android/src/main/java/com/oney/WebRTCModule/voip/IncomingCallActivity.kt new file mode 100644 index 000000000..f1ace6a05 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/IncomingCallActivity.kt @@ -0,0 +1,540 @@ +package com.oney.WebRTCModule.voip + +import android.app.Activity +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.graphics.Bitmap +import android.graphics.Color +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.Bundle +import android.telecom.DisconnectCause +import android.text.TextUtils +import android.view.Gravity +import android.view.MotionEvent +import android.view.ViewGroup +import android.view.WindowManager +import android.widget.FrameLayout +import android.widget.ImageView +import android.widget.LinearLayout +import android.widget.Space +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.core.content.ContextCompat +import androidx.core.graphics.ColorUtils +import kotlin.math.abs + +/** + * Full-screen incoming-call screen shown over the lock screen via the + * notification's full-screen intent. Built programmatically so the library + * carries no layout resources. + * + * Two entry modes: + * - launched with [ACTION_ANSWER] (the notification's Answer button): answers + * immediately, brings the host app forward, and shows a minimal + * "Connecting..." view until the host activity covers it. + * - launched without an action (full-screen intent): draws the ring UI with + * Answer/Decline buttons. + * + * Auto-dismisses when the call ends elsewhere via the [ACTION_CALL_ENDED] + * broadcast that [CallManager] sends when its call tears down. + */ +@RequiresApi(Build.VERSION_CODES.O) +class IncomingCallActivity : Activity() { + companion object { + const val ACTION_ANSWER = "fishjam.voip.ACTION_ANSWER" + const val ACTION_SHOW_WAITING = "fishjam.voip.ACTION_SHOW_WAITING" + const val ACTION_CALL_ENDED = "fishjam.voip.ACTION_CALL_ENDED" + + /** + * Distinct from [ACTION_CALL_ENDED], which is about the + * current call and must not dismiss the waiting screen. + */ + const val ACTION_WAITING_ENDED = "fishjam.voip.ACTION_WAITING_ENDED" + + /** Broadcast by [CallManager] once the caller avatar has downloaded. */ + const val ACTION_AVATAR_READY = "fishjam.voip.ACTION_AVATAR_READY" + + /** Fraction of max travel the knob must cross to trigger the action. */ + private const val SWIPE_TRIGGER = 0.7f + + /** Max handset tilt (degrees) at full travel; negative = toward Decline. */ + private const val ICON_MAX_TILT = 35f + + /** How much the non-target label fades at full travel (0..1). */ + private const val LABEL_FADE = 0.75f + + /** How far (dp) the target label slides outward at full travel. */ + private const val LABEL_SHIFT_DP = 10 + } + + private val callEndedReceiver = + object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + ACTION_CALL_ENDED -> if (!isWaitingCall) finish() + ACTION_WAITING_ENDED -> if (isWaitingCall) finish() + ACTION_AVATAR_READY -> refreshAvatar() + } + } + } + private var callEndedReceiverRegistered = false + private var isAnswering = false + private var isWaitingCall = false + + /** The hero avatar container, so a late-arriving photo can replace the initials. */ + private var avatarHolder: FrameLayout? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + configureWindowForLockScreen() + + if (intent?.action == ACTION_ANSWER) { + answerAndOpenApp() + return + } + + if (intent?.action == ACTION_SHOW_WAITING) { + if (!CallManager.hasWaitingCall()) { + finish() + return + } + isWaitingCall = true + setContentView( + buildWaitingUi( + CallManager.waitingDisplayName(), + CallManager.waitingIsVideo(), + ), + ) + registerCallEndedReceiver() + return + } + + if (!CallManager.hasActiveCall()) { + finish() + return + } + + setContentView(buildUi(CallManager.currentDisplayName(), CallManager.currentIsVideo())) + registerCallEndedReceiver() + } + + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + if (intent?.action == ACTION_ANSWER) answerAndOpenApp() + } + + override fun onDestroy() { + if (callEndedReceiverRegistered) unregisterReceiver(callEndedReceiver) + super.onDestroy() + } + + private fun answerAndOpenApp() { + // Reachable from both the swipe gesture and repeated ACTION_ANSWER + // intents (onCreate + onNewIntent); only the first one may run. + if (isAnswering) return + isAnswering = true + + CallManager.answer() + // Register the lifecycle hook before launching, so the host activity + // is flagged in onActivityCreated even on the fastest cold start. + LockScreenController.onCallAnswered(applicationContext) + packageManager.getLaunchIntentForPackage(packageName)?.let { + it.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP + it.putExtra(LockScreenController.VOIP_ANSWER, true) + startActivity(it) + } + setContentView(buildConnectingUi(CallManager.currentDisplayName())) + registerCallEndedReceiver() + } + + override fun onStop() { + super.onStop() + // Finish once we're covered after any answer: our own swipe/button + // (isAnswering), or an external answer (headset/Bluetooth/Auto/watch) + // whose CallManager-launched host activity now sits on top of us. + if (isAnswering || CallManager.isAnswered()) finish() + } + + private fun registerCallEndedReceiver() { + if (callEndedReceiverRegistered) return + callEndedReceiverRegistered = true + ContextCompat.registerReceiver( + this, + callEndedReceiver, + IntentFilter(ACTION_CALL_ENDED).apply { + addAction(ACTION_WAITING_ENDED) + addAction(ACTION_AVATAR_READY) + }, + ContextCompat.RECEIVER_NOT_EXPORTED, + ) + } + + @Suppress("DEPRECATION") + private fun configureWindowForLockScreen() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true) + setTurnScreenOn(true) + } + window.addFlags( + WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, + ) + } + + // Pixel-dialer-inspired palette. + private val backgroundDark = Color.parseColor("#121316") + private val avatarGreen = Color.parseColor("#1B6B50") + private val answerGreen = Color.parseColor("#1E8E3E") + private val declineRed = Color.parseColor("#DC362E") + private val barGray = Color.parseColor("#2B2D31") + private val textPrimary = Color.parseColor("#E9EBEE") + private val textSecondary = Color.parseColor("#C4C7CC") + + private fun buildUi(displayName: String, isVideo: Boolean): ViewGroup { + val name = displayName.ifBlank { "Unknown" } + + val root = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER_HORIZONTAL + setBackgroundColor(backgroundDark) + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + setPadding(0, dp(56), 0, dp(40)) + } + + root.addView( + TextView(this).apply { + text = if (isVideo) "Incoming video call" else "Incoming voice call" + setTextColor(textSecondary) + textSize = 16f + gravity = Gravity.CENTER + } + ) + + // Big caller name; marquee-scrolls when it overflows, like the dialer. + root.addView( + TextView(this).apply { + text = name + setTextColor(textPrimary) + textSize = 38f + gravity = Gravity.CENTER + isSingleLine = true + ellipsize = TextUtils.TruncateAt.MARQUEE + marqueeRepeatLimit = -1 + isSelected = true + setPadding(0, dp(6), 0, 0) + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT, + ) + } + ) + + root.addView(Space(this), weight(1f)) + + // Large centered avatar (the audio-call variant's hero element). + root.addView(avatarView(name, sizeDp = 180, textSizeSp = 72f)) + + root.addView(Space(this), weight(1f)) + + root.addView(buildSwipeBar()) + + return root + } + + private fun buildWaitingUi(displayName: String, isVideo: Boolean): ViewGroup { + val root = buildUi(displayName, isVideo) + root.post { + if (!CallManager.hasWaitingCall()) finish() + } + return root + } + + /** Placeholder shown after answering, until the host activity covers us. */ + private fun buildConnectingUi(displayName: String): ViewGroup { + val name = displayName.ifBlank { "Unknown" } + + val root = + LinearLayout(this).apply { + orientation = LinearLayout.VERTICAL + gravity = Gravity.CENTER + setBackgroundColor(backgroundDark) + layoutParams = + ViewGroup.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.MATCH_PARENT, + ) + } + + root.addView(avatarView(name, sizeDp = 120, textSizeSp = 48f)) + + root.addView( + TextView(this).apply { + text = name + setTextColor(textPrimary) + textSize = 26f + gravity = Gravity.CENTER + setPadding(0, dp(20), 0, 0) + } + ) + + root.addView( + TextView(this).apply { + text = "Connecting…" + setTextColor(textSecondary) + textSize = 16f + gravity = Gravity.CENTER + setPadding(0, dp(8), 0, 0) + } + ) + + return root + } + + /** + * The bottom pill: "Decline" on the left, "Answer" on the right, and a white + * knob with a green phone icon in the middle. Drag the knob right to answer + * (tints green) or left to decline (tints red); release short of the + * threshold and it springs back. + */ + private fun buildSwipeBar(): FrameLayout { + val bar = + FrameLayout(this).apply { + background = pill(barGray, radiusDp = 48) + layoutParams = + LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + dp(96), + ).apply { + marginStart = dp(20) + marginEnd = dp(20) + } + } + + val declineLabel = + TextView(this).apply { + text = "Decline" + setTextColor(textPrimary) + textSize = 17f + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.START or Gravity.CENTER_VERTICAL, + ).apply { marginStart = dp(32) } + } + bar.addView(declineLabel) + + val answerLabel = + TextView(this).apply { + text = "Answer" + setTextColor(textPrimary) + textSize = 17f + layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.WRAP_CONTENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + Gravity.END or Gravity.CENTER_VERTICAL, + ).apply { marginEnd = dp(32) } + } + bar.addView(answerLabel) + + val icon = + ImageView(this).apply { + setImageResource(android.R.drawable.sym_action_call) + setColorFilter(answerGreen) + layoutParams = + FrameLayout.LayoutParams(dp(30), dp(30), Gravity.CENTER) + } + val knobBackground = pill(Color.WHITE, radiusDp = 36) + val knob = + FrameLayout(this).apply { + background = knobBackground + contentDescription = "Swipe right to answer, left to decline" + layoutParams = FrameLayout.LayoutParams(dp(120), dp(72), Gravity.CENTER) + addView(icon) + } + bar.addView(knob) + + attachSwipe(bar, knob, knobBackground, icon, declineLabel, answerLabel) + return bar + } + + private fun attachSwipe( + bar: FrameLayout, + knob: FrameLayout, + knobBackground: GradientDrawable, + icon: ImageView, + declineLabel: TextView, + answerLabel: TextView, + ) { + var downX = 0f + + // fraction is signed, in [-1, 1]; negative = dragging toward Decline. + fun applyDragVisuals(fraction: Float) { + val magnitude = abs(fraction) + val target = if (fraction >= 0) answerGreen else declineRed + + knobBackground.setColor(ColorUtils.blendARGB(Color.WHITE, target, magnitude)) + icon.setColorFilter(ColorUtils.blendARGB(answerGreen, Color.WHITE, magnitude)) + // Tilt the handset with the drag (hang-up tilt toward Decline). + icon.rotation = fraction * ICON_MAX_TILT + + if (fraction < 0) { + // Toward Decline: its label reddens and slides out; Answer fades. + declineLabel.setTextColor( + ColorUtils.blendARGB(textPrimary, declineRed, magnitude) + ) + declineLabel.translationX = -dp(LABEL_SHIFT_DP) * magnitude + declineLabel.alpha = 1f + answerLabel.setTextColor(textPrimary) + answerLabel.translationX = 0f + answerLabel.alpha = 1f - LABEL_FADE * magnitude + } else { + // Toward Answer: its label greens and slides out; Decline fades. + answerLabel.setTextColor( + ColorUtils.blendARGB(textPrimary, answerGreen, magnitude) + ) + answerLabel.translationX = dp(LABEL_SHIFT_DP) * magnitude + answerLabel.alpha = 1f + declineLabel.setTextColor(textPrimary) + declineLabel.translationX = 0f + declineLabel.alpha = 1f - LABEL_FADE * magnitude + } + } + + fun resetVisuals() { + knob.animate().translationX(0f).setDuration(150).start() + icon.animate().rotation(0f).setDuration(150).start() + knobBackground.setColor(Color.WHITE) + icon.setColorFilter(answerGreen) + for (label in listOf(declineLabel, answerLabel)) { + label.animate().translationX(0f).alpha(1f).setDuration(150).start() + label.setTextColor(textPrimary) + } + } + + knob.setOnTouchListener { view, event -> + val maxTravel = (bar.width - knob.width) / 2f - dp(12) + when (event.actionMasked) { + MotionEvent.ACTION_DOWN -> { + downX = event.rawX + true + } + MotionEvent.ACTION_MOVE -> { + if (maxTravel <= 0f) return@setOnTouchListener true + val dx = (event.rawX - downX).coerceIn(-maxTravel, maxTravel) + knob.translationX = dx + applyDragVisuals(dx / maxTravel) + true + } + MotionEvent.ACTION_UP -> { + view.performClick() + val fraction = + if (maxTravel > 0f) knob.translationX / maxTravel else 0f + when { + fraction >= SWIPE_TRIGGER -> { + if (isWaitingCall) { + CallManager.acceptWaitingCall(this@IncomingCallActivity) + } else { + answerAndOpenApp() + } + finish() + } + fraction <= -SWIPE_TRIGGER -> { + if (isWaitingCall) { + CallManager.declineWaitingCall(this@IncomingCallActivity) + } else { + CallManager.endCall(DisconnectCause(DisconnectCause.REJECTED)) + } + finish() + } + else -> resetVisuals() + } + true + } + MotionEvent.ACTION_CANCEL -> { + resetVisuals() + true + } + else -> false + } + } + } + + /** + * The hero avatar: shows the downloaded caller photo if available, otherwise + * an initials circle. Returned as a holder so [refreshAvatar] can swap in a + * photo that lands after the screen is already showing. + */ + private fun activeAvatarBitmap(): Bitmap? = + if (isWaitingCall) CallManager.currentWaitingAvatarBitmap() else CallManager.currentAvatarBitmap() + + private fun avatarView(name: String, sizeDp: Int, textSizeSp: Float): FrameLayout { + val holder = FrameLayout(this).apply { + layoutParams = LinearLayout.LayoutParams(dp(sizeDp), dp(sizeDp)) + } + val bitmap = activeAvatarBitmap() + holder.addView(if (bitmap != null) avatarImage(bitmap) else initialsAvatar(name, textSizeSp)) + avatarHolder = holder + return holder + } + + private fun initialsAvatar(name: String, textSizeSp: Float): TextView = + TextView(this).apply { + text = name.firstOrNull()?.uppercase() ?: "?" + setTextColor(Color.WHITE) + textSize = textSizeSp + gravity = Gravity.CENTER + background = + GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(avatarGreen) + } + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + + /** The already circular-cropped caller photo, sized to fill the holder. */ + private fun avatarImage(bitmap: Bitmap): ImageView = + ImageView(this).apply { + setImageBitmap(bitmap) + scaleType = ImageView.ScaleType.FIT_CENTER + layoutParams = FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.MATCH_PARENT, + ) + } + + /** Replaces the initials circle with the caller photo once it has downloaded. */ + private fun refreshAvatar() { + val holder = avatarHolder ?: return + val bitmap = activeAvatarBitmap() ?: return + holder.removeAllViews() + holder.addView(avatarImage(bitmap)) + } + + private fun pill(color: Int, radiusDp: Int): GradientDrawable = + GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dp(radiusDp).toFloat() + setColor(color) + } + + private fun weight(value: Float): LinearLayout.LayoutParams = + LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, value) + + private fun dp(value: Int): Int = + (value * resources.displayMetrics.density).toInt() +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/LockScreenController.kt b/android/src/main/java/com/oney/WebRTCModule/voip/LockScreenController.kt new file mode 100644 index 000000000..f9d99f016 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/LockScreenController.kt @@ -0,0 +1,101 @@ +package com.oney.WebRTCModule.voip + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.annotation.RequiresApi +import java.lang.ref.WeakReference + +/** + * Lets the host app's activity appear over the lock screen + * + * Two paths: + * - Cold start: [onCallAnswered] registers [Application.ActivityLifecycleCallbacks] + * so the flags land in `onActivityCreated`, before the host activity's + * window is first shown. + * - Warm start: [showOverLockScreen] flags an already-existing activity directly. + * + * [onCallEnded] clears the flags and unregisters, dropping the app back + * behind the keyguard once the call is over. + */ +@RequiresApi(Build.VERSION_CODES.O) +object LockScreenController { + const val VOIP_ANSWER = "fishjam.voip.VOIP_ANSWER" + + private var application: Application? = null + private var flaggedActivity = WeakReference(null) + + private val lifecycleCallbacks = + object : Application.ActivityLifecycleCallbacks { + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + if (activity is IncomingCallActivity || activity.intent?.getBooleanExtra(VOIP_ANSWER, false) != true) return + showOverLockScreen(activity) + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityResumed(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} + } + + /** + * Starts watching for the host activity's creation (cold-start path). + * Safe to call from any thread; registering twice is prevented. + */ + fun onCallAnswered(context: Context) { + val app = context.applicationContext as? Application ?: return + synchronized(this) { + if (application == null) { + app.registerActivityLifecycleCallbacks(lifecycleCallbacks) + application = app + } + } + } + + /** + * Flags an activity to show over the keyguard (warm-start path, or from + * the lifecycle hook on cold start). Safe to call from any thread. + */ + fun showOverLockScreen(activity: Activity) { + flaggedActivity = WeakReference(activity) + activity.runOnUiThread { + if (activity.isDestroyed) return@runOnUiThread + setLockScreenFlags(activity, show = true) + } + } + + /** + * Clears the flags on the tracked activity and stops watching, so the app + * is no longer reachable over the keyguard once the call ends. + */ + fun onCallEnded() { + synchronized(this) { + application?.unregisterActivityLifecycleCallbacks(lifecycleCallbacks) + application = null + } + val activity = flaggedActivity.get() ?: return + flaggedActivity = WeakReference(null) + activity.runOnUiThread { + if (activity.isDestroyed) return@runOnUiThread + setLockScreenFlags(activity, show = false) + } + } + + @Suppress("DEPRECATION") + private fun setLockScreenFlags(activity: Activity, show: Boolean) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + activity.setShowWhenLocked(show) + activity.setTurnScreenOn(show) + } else { + val flags = + WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON + if (show) activity.window.addFlags(flags) else activity.window.clearFlags(flags) + } + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/PushNotificationService.kt b/android/src/main/java/com/oney/WebRTCModule/voip/PushNotificationService.kt new file mode 100644 index 000000000..8341a8e1b --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/PushNotificationService.kt @@ -0,0 +1,233 @@ +package com.oney.WebRTCModule.voip + +import android.content.Context +import android.content.pm.PackageManager +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.facebook.react.ReactApplication +import com.facebook.react.internal.featureflags.ReactNativeFeatureFlags +import com.google.firebase.messaging.FirebaseMessagingService +import com.google.firebase.messaging.RemoteMessage + +/** + * Receives VoIP wake-up pushes over FCM. + * + * The signaling backend sends a high-priority **data** message (so this runs even + * when the app is backgrounded or killed, and gets the temporary foreground-service + * start exemption) with `roomName` / `displayName` / `isVideo`. We report the call + * to Core-Telecom immediately so it rings without needing JS, and hand the room + * details to [VoIPPushRegistry] for the JS layer. + * + * ## Coexistence with other push-notification libraries + * + * Android delivers every FCM message to a single `MESSAGING_EVENT` service per app, + * so this service also acts as a relay: messages that are not VoIP pushes (no + * `fishjam: "voip-incoming"` discriminator) — and every token callback — are handed + * to the app's other messaging service, named by a `VoIPFallbackMessagingService` + * manifest meta-data entry (the Expo config plugin fills it in automatically for + * known libraries). + * The fallback runs its real native code, so its killed-state behavior is preserved. + * + * Apps that need full control can instead register their own service and call + * [handleVoIPMessage] / [handleNewToken] from it. + */ +class PushNotificationService : FirebaseMessagingService() { + // firebase-messaging 25.x delivers tokens through two distinct events with no + // documented dispatch rules: the legacy NEW_TOKEN action -> onNewToken, and the + // FID-registration FCM_REGISTERED action -> onRegistered. Missing either one + // silently loses the device for VoIP pushes, so both are overridden to do the + // same thing; updateToken and forwardTokenToFallback are idempotent per token, + // making a double delivery harmless. The relay deliberately targets the + // fallback's onNewToken in both cases - that is the only token callback + // expo-notifications / RNFB implement (they predate onRegistered, whose empty + // base impl would swallow the token). + override fun onRegistered(token: String) { + handleNewToken(token) + forwardTokenToFallback(this, token) + } + + override fun onNewToken(token: String) { + handleNewToken(token) + forwardTokenToFallback(this, token) + } + + override fun onMessageReceived(message: RemoteMessage) { + if (handleVoIPMessage(this, message)) return + forwardMessageToFallback(this, message) + } + + // Owning the MESSAGING_EVENT slot means every FirebaseMessagingService callback + // lands here; any not relayed silently evaporates for the fallback. VoIP has no + // use for this one, but RNFB surfaces it to JS ("messages were dropped, resync"), + // so pass it through. + override fun onDeletedMessages() { + val fallback = fallbackServiceInstance(this) ?: return + try { + fallback.onDeletedMessages() + } catch (e: Exception) { + Log.w(TAG, "Fallback messaging service failed to handle onDeletedMessages", e) + } + } + + companion object Dispatch { + private const val TAG = "PushNotificationService" + + /** `` key naming the messaging service non-VoIP traffic is relayed to. */ + const val FALLBACK_META_KEY = "VoIPFallbackMessagingService" + + /** + * Explicit payload discriminator: a data message is ours if it carries + * `fishjam: "voip-incoming"`. + */ + const val DISCRIMINATOR_KEY = "fishjam" + const val VOIP_INCOMING = "voip-incoming" + + // onRegistered and onNewToken are distinct FCM callbacks that can both deliver + // the same token; remember the last one relayed so the fallback sees it once. + @Volatile + private var lastForwardedToken: String? = null + + /** + * Handles a Fishjam VoIP push. Returns `true` if [message] carried a VoIP + * payload (`fishjam: "voip-incoming"`) and was consumed — including pushes + * dropped because two calls are already tracked or malformed ones. Returns + * `false` for any other message, which the caller should route to its own + * notification handling. + */ + fun handleVoIPMessage(context: Context, message: RemoteMessage): Boolean { + val data = message.data + if (data[DISCRIMINATOR_KEY] != VOIP_INCOMING) { + return false + } + val roomName = data["roomName"] + if (roomName == null) { + Log.w(TAG, "VoIP push without roomName dropped") + return true + } + val displayName = data["displayName"] ?: "Incoming call" + val handle = data["handle"]?.takeIf { it.isNotEmpty() } ?: displayName + val isVideo = data["isVideo"]?.toBoolean() ?: false + val avatarUrl = data["avatarUrl"]?.takeIf { it.isNotEmpty() } + val incoming = VoIPPushRegistry.Incoming(roomName, displayName, handle, isVideo, avatarUrl) + val appContext = context.applicationContext + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + when (CallManager.reportIncomingCall(appContext, displayName, handle, isVideo, avatarUrl)) { + IncomingCallSlot.CURRENT -> { + warmUpReact(appContext) + VoIPPushRegistry.reportIncoming(incoming) + } + IncomingCallSlot.WAITING -> { + warmUpReact(appContext) + VoIPPushRegistry.bufferWaitingIncoming(incoming) + } + IncomingCallSlot.REJECTED -> { + VoIPPushRegistry.reportRejectedIncoming(incoming) + } + } + } else { + warmUpReact(appContext) + VoIPPushRegistry.reportIncoming(incoming) + } + return true + } + + /** Records a fresh FCM token for VoIP; call from a custom service's `onNewToken`. */ + fun handleNewToken(token: String) { + VoIPPushRegistry.updateToken(token) + } + + private fun forwardMessageToFallback(context: Context, message: RemoteMessage) { + val fallback = fallbackServiceInstance(context) ?: return + try { + fallback.onMessageReceived(message) + } catch (e: Exception) { + Log.w(TAG, "Fallback messaging service failed to handle a message", e) + } + } + + private fun forwardTokenToFallback(context: Context, token: String) { + if (token == lastForwardedToken) return + val fallback = fallbackServiceInstance(context) ?: return + try { + // onNewToken is the callback every FCM library implements; onRegistered + // only exists since firebase-messaging 25 and none of them override it. + fallback.onNewToken(token) + lastForwardedToken = token + } catch (e: Exception) { + Log.w(TAG, "Fallback messaging service failed to handle a token", e) + } + } + + /** + * Instantiates the configured fallback service and injects a Context the way + * Android would. attachBaseContext is protected on ContextWrapper, hence the + * reflective walk. + */ + private fun fallbackServiceInstance(context: Context): FirebaseMessagingService? { + val className = fallbackClassName(context) ?: return null + return try { + val clazz = Class.forName(className) + val instance = clazz.getDeclaredConstructor().newInstance() + if (instance !is FirebaseMessagingService) { + Log.w(TAG, "$FALLBACK_META_KEY $className is not a FirebaseMessagingService") + return null + } + // A hand-constructed service has no base context; Context calls inside + // it throw until attachBaseContext(Context) runs (Android's job during + // normal startup). It's protected and declared on ContextWrapper, but + // getDeclaredMethods() only sees one exact class - so walk the + // superclass chain and take the first declaration found: the fallback's + // own override if any (what Android would call), else ContextWrapper's. + // parameterCount == 1 pins the (Context) overload. + val attach = generateSequence>(clazz) { it.superclass } + .firstNotNullOfOrNull { c -> + c.declaredMethods.firstOrNull { + it.name == "attachBaseContext" && it.parameterCount == 1 + } + } + if (attach == null) { + Log.w(TAG, "attachBaseContext not found on $className") + return null + } + attach.isAccessible = true + attach.invoke(instance, context.applicationContext) + instance + } catch (e: Exception) { + Log.w(TAG, "Could not instantiate fallback messaging service $className", e) + null + } + } + + private fun fallbackClassName(context: Context): String? = + try { + context.packageManager + .getApplicationInfo(context.packageName, PackageManager.GET_META_DATA) + .metaData + ?.getString(FALLBACK_META_KEY) + ?.takeIf { it.isNotBlank() } + } catch (_: PackageManager.NameNotFoundException) { + null + } + + private fun warmUpReact(appContext: Context) { + val app = appContext as? ReactApplication ?: return + Handler(Looper.getMainLooper()).post { + try { + if (ReactNativeFeatureFlags.enableBridgelessArchitecture()) { + app.reactHost?.start() + } else { + val manager = app.reactNativeHost.reactInstanceManager + if (!manager.hasStartedCreatingInitialContext()) { + manager.createReactContextInBackground() + } + } + } catch (e: Exception) { + // Ignore React warm-up failures on push + } + } + } + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/VoIPForegroundRequest.java b/android/src/main/java/com/oney/WebRTCModule/voip/VoIPForegroundRequest.java new file mode 100644 index 000000000..d6bf948d9 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/VoIPForegroundRequest.java @@ -0,0 +1,69 @@ +package com.oney.WebRTCModule.voip; + +/** + * Immutable snapshot of the call-driven foreground-service requirements: which CallStyle + * notification variant to show and which media types the call itself needs (as opposed to + * room camera/microphone usage requested from JS). Built by VoIPForegroundServiceController + * and handed to {@code ForegroundServiceController}, which owns it and merges it with + * room/screen-share requirements before starting {@code WebRTCForegroundService}. + */ +public final class VoIPForegroundRequest { + public static final VoIPForegroundRequest INACTIVE = new VoIPForegroundRequest(false, false, false, "", false, 0L); + + private final boolean active; + private final boolean connecting; + private final boolean held; + private final String displayName; + private final boolean video; + private final long connectedAtMs; + + private VoIPForegroundRequest( + boolean active, boolean connecting, boolean held, String displayName, boolean video, long connectedAtMs) { + this.active = active; + this.connecting = connecting; + this.held = held; + this.displayName = displayName; + this.video = video; + this.connectedAtMs = connectedAtMs; + } + + public static VoIPForegroundRequest connecting(String displayName, boolean video) { + return new VoIPForegroundRequest(true, true, false, displayName, video, 0L); + } + + public static VoIPForegroundRequest connected(String displayName, boolean video, long connectedAtMs) { + return new VoIPForegroundRequest(true, false, false, displayName, video, connectedAtMs); + } + + public VoIPForegroundRequest withHeld(boolean held) { + return new VoIPForegroundRequest(active, connecting, held, displayName, video, connectedAtMs); + } + + public boolean isActive() { + return active; + } + + public boolean isConnecting() { + return connecting; + } + + public boolean isHeld() { + return held; + } + + public String getDisplayName() { + return displayName; + } + + public long getConnectedAtMs() { + return connectedAtMs; + } + + public boolean needsCamera() { + return active && video; + } + + public boolean needsMicrophone() { + return active; + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/VoIPForegroundServiceController.kt b/android/src/main/java/com/oney/WebRTCModule/voip/VoIPForegroundServiceController.kt new file mode 100644 index 000000000..647a2302d --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/VoIPForegroundServiceController.kt @@ -0,0 +1,34 @@ +package com.oney.WebRTCModule.voip + +import com.oney.WebRTCModule.foregroundService.ForegroundServiceController + +/** + * Translates Core-Telecom call lifecycle transitions driven by [CallManager] into an + * immutable [VoIPForegroundRequest] and hands it to [ForegroundServiceController], + * which owns that state and merges it with room media and screen-share + * foreground-service requirements. + */ +object VoIPForegroundServiceController { + private val controller: ForegroundServiceController + get() = ForegroundServiceController.getInstance() + + fun onCallConnecting(displayName: String?, isVideo: Boolean) { + controller.setVoIPRequest( + VoIPForegroundRequest.connecting(displayName.orEmpty(), isVideo), + ) + } + + fun onCallConnected(displayName: String?, isVideo: Boolean) { + controller.setVoIPRequest( + VoIPForegroundRequest.connected(displayName.orEmpty(), isVideo, System.currentTimeMillis()), + ) + } + + fun onCallEnded() { + controller.setVoIPRequest(VoIPForegroundRequest.INACTIVE) + } + + fun onCallHeld(held: Boolean) { + controller.setVoIPHeld(held) + } +} diff --git a/android/src/main/java/com/oney/WebRTCModule/voip/VoIPPushRegistry.kt b/android/src/main/java/com/oney/WebRTCModule/voip/VoIPPushRegistry.kt new file mode 100644 index 000000000..2921357f3 --- /dev/null +++ b/android/src/main/java/com/oney/WebRTCModule/voip/VoIPPushRegistry.kt @@ -0,0 +1,119 @@ +package com.oney.WebRTCModule.voip + +import com.facebook.react.bridge.Promise +import com.google.firebase.installations.FirebaseInstallations + +/** + * Process-wide bridge between [PushNotificationService] (which can run with no + * React instance alive, e.g. a push received while the app is killed) and the + * JS layer. + */ +object VoIPPushRegistry { + data class Incoming( + val roomName: String, + val displayName: String, + val handle: String, + val isVideo: Boolean, + val avatarUrl: String? = null, + ) + + interface Listener { + fun onVoIPToken(token: String) + fun onVoIPIncoming(incoming: Incoming) + fun onWaitingCallDeclined(incoming: Incoming) + } + + @Volatile + private var token: String? = null + + @Volatile + private var pendingIncoming: Incoming? = null + + + @Volatile + private var pendingWaitingIncoming: Incoming? = null + + @Volatile + private var listener: Listener? = null + + /** Registered by WebRTCModule when a React instance comes up; cleared on teardown. */ + @Synchronized + fun setListener(l: Listener?) { + listener = l + } + + @Synchronized + fun updateToken(newToken: String) { + if (token == newToken) { + return + } + token = newToken + listener?.onVoIPToken(newToken) + } + + @Synchronized + fun getToken(): String? = token + + fun resolveToken(promise: Promise) { + val cached = getToken() + if (cached != null) { + promise.resolve(cached) + } else { + fetchFid { promise.resolve(it) } + } + } + + private fun fetchFid(onResult: (String?) -> Unit) { + try { + FirebaseInstallations.getInstance().id.addOnCompleteListener { task -> + if (task.isSuccessful) { + onResult(task.result) + } else { + onResult(null) + } + } + } catch (e: IllegalStateException) { + onResult(null) + } + } + + @Synchronized + fun reportIncoming(incoming: Incoming) { + pendingIncoming = incoming + listener?.onVoIPIncoming(incoming) + } + + @Synchronized + fun pending(): Incoming? = pendingIncoming + + @Synchronized + fun clearPending() { + pendingIncoming = null + } + + @Synchronized + fun bufferWaitingIncoming(incoming: Incoming) { + pendingWaitingIncoming = incoming + } + + @Synchronized + fun revealWaitingIncoming() { + val incoming = pendingWaitingIncoming ?: return + pendingWaitingIncoming = null + reportIncoming(incoming) + } + + @Synchronized + fun discardWaitingIncoming() { + val incoming = pendingWaitingIncoming + pendingWaitingIncoming = null + if (incoming != null) { + listener?.onWaitingCallDeclined(incoming) + } + } + + @Synchronized + fun reportRejectedIncoming(incoming: Incoming) { + listener?.onWaitingCallDeclined(incoming) + } +} diff --git a/ios/RCTWebRTC/CallKitManager.h b/ios/RCTWebRTC/CallKitManager.h index 6678a6479..216e4f578 100644 --- a/ios/RCTWebRTC/CallKitManager.h +++ b/ios/RCTWebRTC/CallKitManager.h @@ -5,21 +5,45 @@ typedef void (^CallKitVoidCallback)(void); typedef void (^CallKitStringCallback)(NSString *); typedef void (^CallKitBoolCallback)(BOOL); +/** + * Where an incoming call landed relative to whatever call already exists: + * - Current: there was no call yet, so it is reported as the one call the app tracks. + * - Waiting: another call is already answered/connected, so this one rings as the second + * CallKit call; JS is not told unless it is answered. + * - Rejected: no slot left (still ringing or waiting already taken) — transient CallKit + * report for PushKit, then ended; JS signals rejection to the caller. + */ +typedef NS_ENUM(NSInteger, IncomingCallSlot) { + IncomingCallSlotCurrent, + IncomingCallSlotWaiting, + IncomingCallSlotRejected, +}; + @interface CallKitManager : NSObject -@property(nonatomic, copy) CallKitVoidCallback onCallStarted; -@property(nonatomic, copy) CallKitVoidCallback onCallAnswered; -@property(nonatomic, copy) CallKitVoidCallback onCallEnded; -@property(nonatomic, copy) CallKitStringCallback onCallFailed; -@property(nonatomic, copy) CallKitBoolCallback onCallMuted; -@property(nonatomic, copy) CallKitBoolCallback onCallHeld; -@property(nonatomic, readonly) BOOL hasActiveCall; -@property(nonatomic, readonly) BOOL isCallAnswered; +@property(copy) CallKitVoidCallback onCallStarted; +@property(copy) CallKitStringCallback onCallAnswered; +@property(copy) CallKitStringCallback onCallEnded; +@property(copy) CallKitStringCallback onCallFailed; +@property(copy) CallKitBoolCallback onCallMuted; +@property(copy) CallKitBoolCallback onCallHeld; +@property(readonly) BOOL hasActiveCall; +@property(readonly) BOOL isCallAnswered; +@property(readonly) BOOL isOutgoingCall; +@property(readonly) BOOL isCallOnHold; +@property(readonly, nullable) NSString *pendingAnswerRequestId; + (instancetype)shared; -- (void)startCallWithDisplayName:(NSString *)displayName isVideo:(BOOL)isVideo; -- (void)reportIncomingCallWithDisplayName:(NSString *)displayName isVideo:(BOOL)isVideo; -- (void)endCall; +- (void)startCallWithDisplayName:(NSString *)displayName handle:(NSString *)handle isVideo:(BOOL)isVideo; +- (IncomingCallSlot)reportIncomingCallWithDisplayName:(NSString *)displayName + handle:(NSString *)handle + isVideo:(BOOL)isVideo; +- (void)endCallWithReason:(NSString *_Nullable)reason; +- (BOOL)fulfillIncomingCallConnected:(NSString *)requestId; +- (void)failIncomingCallConnected:(NSString *)requestId; +- (void)reportOutgoingCallConnected; +- (void)setCallHeld:(BOOL)onHold; +- (void)setMuted:(BOOL)muted; @end diff --git a/ios/RCTWebRTC/CallKitManager.m b/ios/RCTWebRTC/CallKitManager.m index dd56d9429..bbb258168 100644 --- a/ios/RCTWebRTC/CallKitManager.m +++ b/ios/RCTWebRTC/CallKitManager.m @@ -2,12 +2,39 @@ #import #import +#import "DialtonePlayer.h" +#import "FulfillRequestManager.h" +#import "VoIPManager.h" + +static const NSTimeInterval kDefaultIncomingCallTimeout = 45; +static const NSTimeInterval kDefaultOutgoingCallTimeout = 60; +static const NSTimeInterval kDefaultFulfillAnswerTimeout = 10; + +static NSTimeInterval timeoutFromInfoPlist(NSString *key, NSTimeInterval fallback) { + id value = [NSBundle.mainBundle objectForInfoDictionaryKey:key]; + if ([value respondsToSelector:@selector(doubleValue)]) { + double seconds = [value doubleValue]; + if (seconds > 0) { + return seconds; + } + } + return fallback; +} @interface CallKitManager () @property(nonatomic, strong) CXCallController *callController; @property(nonatomic, strong) CXProvider *provider; -@property(nonatomic, strong) NSUUID *currentCallUUID; -@property(nonatomic, assign) BOOL isCallAnswered; +@property(strong) NSUUID *currentCallUUID; +@property(assign) BOOL isCallAnswered; +@property(assign) BOOL isOutgoingCall; +@property(assign) BOOL isCallOnHold; +@property(copy, nullable) NSString *pendingAnswerRequestId; +@property(copy, nullable) dispatch_block_t ringTimeoutBlock; +@property(strong) NSUUID *waitingCallUUID; +@property(copy, nullable) dispatch_block_t waitingRingTimeoutBlock; +@property(nonatomic, assign) NSTimeInterval incomingCallTimeout; +@property(nonatomic, assign) NSTimeInterval outgoingCallTimeout; +@property(nonatomic, assign) NSTimeInterval fulfillAnswerTimeout; @end @implementation CallKitManager @@ -28,12 +55,16 @@ - (instancetype)init { providerConfiguration.supportsVideo = YES; providerConfiguration.supportedHandleTypes = [NSSet setWithObject:@(CXHandleTypeGeneric)]; providerConfiguration.maximumCallsPerCallGroup = 1; - providerConfiguration.maximumCallGroups = 1; - providerConfiguration.includesCallsInRecents = NO; + providerConfiguration.maximumCallGroups = 2; + providerConfiguration.includesCallsInRecents = + [[NSBundle.mainBundle objectForInfoDictionaryKey:@"FishjamVoIPEnabled"] boolValue]; _provider = [[CXProvider alloc] initWithConfiguration:providerConfiguration]; [_provider setDelegate:self queue:nil]; _callController = [[CXCallController alloc] init]; + _incomingCallTimeout = timeoutFromInfoPlist(@"VoIPIncomingCallTimeout", kDefaultIncomingCallTimeout); + _outgoingCallTimeout = timeoutFromInfoPlist(@"VoIPOutgoingCallTimeout", kDefaultOutgoingCallTimeout); + _fulfillAnswerTimeout = timeoutFromInfoPlist(@"VoIPFulfillAnswerTimeout", kDefaultFulfillAnswerTimeout); } return self; } @@ -42,17 +73,38 @@ - (BOOL)hasActiveCall { return self.currentCallUUID != nil; } -- (void)startCallWithDisplayName:(NSString *)displayName isVideo:(BOOL)isVideo { - if (self.currentCallUUID != nil) { +- (void)reportCallCapabilitiesForUUID:(NSUUID *)uuid supportsHolding:(BOOL)supportsHolding { + if (uuid == nil) { + return; + } + CXCallUpdate *update = [[CXCallUpdate alloc] init]; + update.supportsHolding = supportsHolding; + update.supportsGrouping = NO; + update.supportsUngrouping = NO; + update.supportsDTMF = NO; + [self.provider reportCallWithUUID:uuid updated:update]; +} + +- (void)startCallWithDisplayName:(NSString *)displayName handle:(NSString *)handle isVideo:(BOOL)isVideo { + if (!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self startCallWithDisplayName:displayName handle:handle isVideo:isVideo]; + }); + return; + } + if (self.currentCallUUID != nil || self.waitingCallUUID != nil) { NSLog(@"[CallKitManager] Call already in progress"); return; } NSUUID *uuid = [NSUUID UUID]; self.currentCallUUID = uuid; + self.isOutgoingCall = YES; - CXHandle *handle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:displayName]; - CXStartCallAction *startCallAction = [[CXStartCallAction alloc] initWithCallUUID:uuid handle:handle]; + // The handle is the identity persisted in Recents and handed back to us in the + // redial intent, so it must be the caller's unique id. + CXHandle *callHandle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:handle]; + CXStartCallAction *startCallAction = [[CXStartCallAction alloc] initWithCallUUID:uuid handle:callHandle]; startCallAction.video = isVideo; startCallAction.contactIdentifier = displayName; @@ -68,25 +120,107 @@ - (void)startCallWithDisplayName:(NSString *)displayName isVideo:(BOOL)isVideo { if (weakSelf.onCallFailed) { weakSelf.onCallFailed(error.localizedDescription); } - [weakSelf cleanup]; + [weakSelf cleanupCurrentCall]; return; } - [weakSelf.provider reportOutgoingCallWithUUID:uuid startedConnectingAtDate:[NSDate date]]; - [weakSelf.provider reportOutgoingCallWithUUID:uuid connectedAtDate:[NSDate date]]; if (weakSelf.onCallStarted) { weakSelf.onCallStarted(); } + + CXCallUpdate *update = [[CXCallUpdate alloc] init]; + update.supportsHolding = YES; + update.supportsGrouping = NO; + update.supportsUngrouping = NO; + update.supportsDTMF = NO; + [weakSelf.provider reportCallWithUUID:uuid updated:update]; }]; } -- (void)reportIncomingCallWithDisplayName:(NSString *)displayName isVideo:(BOOL)isVideo { +- (IncomingCallSlot)reportIncomingCallWithDisplayName:(NSString *)displayName + handle:(NSString *)handle + isVideo:(BOOL)isVideo { + if (!NSThread.isMainThread) { + __block IncomingCallSlot slot; + dispatch_sync(dispatch_get_main_queue(), ^{ + slot = [self reportIncomingCallWithDisplayName:displayName handle:handle isVideo:isVideo]; + }); + return slot; + } + if (self.waitingCallUUID != nil || (self.currentCallUUID != nil && !self.isCallAnswered)) { + [self reportTransientIncomingCallAndEndWithDisplayName:displayName handle:handle isVideo:isVideo]; + return IncomingCallSlotRejected; + } + + BOOL becomesWaiting = self.currentCallUUID != nil; + NSUUID *uuid = [NSUUID UUID]; + + if (becomesWaiting) { + self.waitingCallUUID = uuid; + if (self.isCallAnswered) { + [self reportCallCapabilitiesForUUID:self.currentCallUUID supportsHolding:NO]; + } + } else { + self.currentCallUUID = uuid; + self.isCallAnswered = NO; + self.isOutgoingCall = NO; + } + + CXCallUpdate *update = [[CXCallUpdate alloc] init]; + update.remoteHandle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:handle]; + update.localizedCallerName = displayName; + update.hasVideo = isVideo; + // Waiting calls must not offer Hold & Accept + update.supportsHolding = becomesWaiting ? NO : YES; + update.supportsGrouping = NO; + update.supportsUngrouping = NO; + update.supportsDTMF = NO; + + __weak typeof(self) weakSelf = self; + [self.provider + reportNewIncomingCallWithUUID:uuid + update:update + completion:^(NSError *_Nullable error) { + typeof(self) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + if (error) { + NSLog(@"[CallKitManager] Failed to report incoming call: %@", + error.localizedDescription); + if (becomesWaiting) { + [strongSelf cleanupWaitingCall]; + } else { + if (strongSelf.onCallFailed) { + strongSelf.onCallFailed(error.localizedDescription); + } + [strongSelf cleanupCurrentCall]; + } + return; + } + if (becomesWaiting) { + [strongSelf startWaitingRingTimeoutForCall:uuid + timeout:strongSelf.incomingCallTimeout]; + } else { + [strongSelf startRingTimeoutForCall:uuid timeout:strongSelf.incomingCallTimeout]; + } + }]; + + return becomesWaiting ? IncomingCallSlotWaiting : IncomingCallSlotCurrent; +} + +/** + * PushKit requires every VoIP push to post an incoming call to CallKit. When there is no + * slot left, report a throwaway call and end it immediately without disturbing whoever + * is already ringing. + */ +- (void)reportTransientIncomingCallAndEndWithDisplayName:(NSString *)displayName + handle:(NSString *)handle + isVideo:(BOOL)isVideo { NSUUID *uuid = [NSUUID UUID]; - self.currentCallUUID = uuid; - self.isCallAnswered = NO; CXCallUpdate *update = [[CXCallUpdate alloc] init]; - update.remoteHandle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:displayName]; + update.remoteHandle = [[CXHandle alloc] initWithType:CXHandleTypeGeneric value:handle]; update.localizedCallerName = displayName; update.hasVideo = isVideo; update.supportsHolding = NO; @@ -98,72 +232,360 @@ - (void)reportIncomingCallWithDisplayName:(NSString *)displayName isVideo:(BOOL) [self.provider reportNewIncomingCallWithUUID:uuid update:update completion:^(NSError *_Nullable error) { + typeof(self) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } if (error) { - NSLog(@"[CallKitManager] Failed to report incoming call: %@", + NSLog(@"[CallKitManager] Failed to report transient incoming call: %@", error.localizedDescription); - weakSelf.currentCallUUID = nil; - if (weakSelf.onCallFailed) { - weakSelf.onCallFailed(error.localizedDescription); - } } + [strongSelf.provider reportCallWithUUID:uuid + endedAtDate:[NSDate date] + reason:CXCallEndedReasonFailed]; }]; } -- (void)endCall { +/** + * Only the reasons that must go through `reportCallWithUUID:endedAtDate:reason:` are covered + * here - `local`/`rejected` are handled separately via the `CXEndCallAction` + * transaction, since CallKit has no ended-reason case for either. + */ +- (CXCallEndedReason)cxEndedReasonForReason:(NSString *)reason { + if ([reason isEqualToString:@"missed"]) { + return CXCallEndedReasonUnanswered; + } else if ([reason isEqualToString:@"remote"]) { + return CXCallEndedReasonRemoteEnded; + } else if ([reason isEqualToString:@"answeredElsewhere"]) { + return CXCallEndedReasonAnsweredElsewhere; + } else if ([reason isEqualToString:@"failed"]) { + return CXCallEndedReasonFailed; + } + return CXCallEndedReasonRemoteEnded; +} + +- (void)endCallWithReason:(NSString *)reason { + if (!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self endCallWithReason:reason]; + }); + return; + } if (self.currentCallUUID == nil) { NSLog(@"[CallKitManager] No active call to end"); return; } - CXEndCallAction *endCallAction = [[CXEndCallAction alloc] initWithCallUUID:self.currentCallUUID]; - CXTransaction *transaction = [[CXTransaction alloc] initWithAction:endCallAction]; + if (reason == nil || [reason isEqualToString:@"local"] || [reason isEqualToString:@"rejected"]) { + CXEndCallAction *endCallAction = [[CXEndCallAction alloc] initWithCallUUID:self.currentCallUUID]; + CXTransaction *transaction = [[CXTransaction alloc] initWithAction:endCallAction]; - __weak typeof(self) weakSelf = self; + [self.callController + requestTransaction:transaction + completion:^(NSError *error) { + if (error) { + NSLog(@"[CallKitManager] Failed to end call: %@", error.localizedDescription); + return; + } + // onCallEnded fires from performEndCallAction once the transaction + // fulfills, so it is not duplicated here. + }]; + return; + } + + // (missed / remote / answeredElsewhere / failed) + NSUUID *uuid = self.currentCallUUID; + [self.provider reportCallWithUUID:uuid endedAtDate:[NSDate date] reason:[self cxEndedReasonForReason:reason]]; + if (self.onCallEnded) { + self.onCallEnded(reason); + } + [self cleanupCurrentCall]; +} + +- (BOOL)fulfillIncomingCallConnected:(NSString *)requestId { + return [[FulfillRequestManager shared] fulfill:requestId]; +} + +- (void)failIncomingCallConnected:(NSString *)requestId { + [[FulfillRequestManager shared] cancel:requestId]; +} + +- (void)reportOutgoingCallConnected { + if (!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self reportOutgoingCallConnected]; + }); + return; + } + NSUUID *uuid = self.currentCallUUID; + if (uuid == nil || !self.isOutgoingCall) { + NSLog(@"[CallKitManager] No outgoing call to report as connected"); + return; + } + + [self cancelRingTimeout]; + [[DialtonePlayer shared] stop]; + self.isCallAnswered = YES; + [self.provider reportOutgoingCallWithUUID:uuid connectedAtDate:[NSDate date]]; +} + +- (void)setCallHeld:(BOOL)onHold { + if (!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self setCallHeld:onHold]; + }); + return; + } + NSUUID *uuid = self.currentCallUUID; + if (uuid == nil) { + NSLog(@"[CallKitManager] No active call to set held"); + return; + } + + CXSetHeldCallAction *action = [[CXSetHeldCallAction alloc] initWithCallUUID:uuid onHold:onHold]; + CXTransaction *transaction = [[CXTransaction alloc] initWithAction:action]; [self.callController requestTransaction:transaction completion:^(NSError *error) { if (error) { - NSLog(@"[CallKitManager] Failed to end call: %@", error.localizedDescription); - return; + NSLog(@"[CallKitManager] Failed to set held: %@", error.localizedDescription); } - if (weakSelf.onCallEnded) { - weakSelf.onCallEnded(); + }]; +} + +- (void)setMuted:(BOOL)muted { + if (!NSThread.isMainThread) { + dispatch_async(dispatch_get_main_queue(), ^{ + [self setMuted:muted]; + }); + return; + } + NSUUID *uuid = self.currentCallUUID; + if (uuid == nil) { + NSLog(@"[CallKitManager] No active call to set muted"); + return; + } + + CXSetMutedCallAction *action = [[CXSetMutedCallAction alloc] initWithCallUUID:uuid muted:muted]; + CXTransaction *transaction = [[CXTransaction alloc] initWithAction:action]; + [self.callController requestTransaction:transaction + completion:^(NSError *error) { + if (error) { + NSLog(@"[CallKitManager] Failed to set muted: %@", error.localizedDescription); } - [weakSelf cleanup]; }]; } -- (void)cleanup { +- (void)reportAnswerFailureForCall:(NSUUID *)uuid { + if (uuid == nil || ![uuid isEqual:self.currentCallUUID]) { + return; + } + + [self.provider reportCallWithUUID:uuid endedAtDate:[NSDate date] reason:CXCallEndedReasonFailed]; + if (self.onCallEnded) { + self.onCallEnded(@"failed"); + } + [self cleanupCurrentCall]; +} + +- (void)startRingTimeoutForCall:(NSUUID *)uuid timeout:(NSTimeInterval)timeout { + [self cancelRingTimeout]; + + __weak typeof(self) weakSelf = self; + dispatch_block_t block = dispatch_block_create(0, ^{ + typeof(self) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + strongSelf.ringTimeoutBlock = nil; + if (![uuid isEqual:strongSelf.currentCallUUID] || strongSelf.isCallAnswered) { + return; + } + [strongSelf endCallWithReason:@"missed"]; + }); + self.ringTimeoutBlock = block; + dispatch_after(dispatch_walltime(NULL, (int64_t)(timeout * NSEC_PER_SEC)), dispatch_get_main_queue(), block); +} + +- (void)cancelRingTimeout { + if (self.ringTimeoutBlock != nil) { + dispatch_block_cancel(self.ringTimeoutBlock); + self.ringTimeoutBlock = nil; + } +} + +- (void)startWaitingRingTimeoutForCall:(NSUUID *)uuid timeout:(NSTimeInterval)timeout { + [self cancelWaitingRingTimeout]; + + __weak typeof(self) weakSelf = self; + dispatch_block_t block = dispatch_block_create(0, ^{ + typeof(self) strongSelf = weakSelf; + if (strongSelf == nil) { + return; + } + strongSelf.waitingRingTimeoutBlock = nil; + if (![uuid isEqual:strongSelf.waitingCallUUID]) { + return; + } + [strongSelf.provider reportCallWithUUID:uuid endedAtDate:[NSDate date] reason:CXCallEndedReasonUnanswered]; + [strongSelf cleanupWaitingCall]; + }); + self.waitingRingTimeoutBlock = block; + dispatch_after(dispatch_walltime(NULL, (int64_t)(timeout * NSEC_PER_SEC)), dispatch_get_main_queue(), block); +} + +- (void)cancelWaitingRingTimeout { + if (self.waitingRingTimeoutBlock != nil) { + dispatch_block_cancel(self.waitingRingTimeoutBlock); + self.waitingRingTimeoutBlock = nil; + } +} + +- (void)cleanupCurrentCall { + [self cancelRingTimeout]; + [[DialtonePlayer shared] stop]; self.currentCallUUID = nil; self.isCallAnswered = NO; + self.isOutgoingCall = NO; + self.isCallOnHold = NO; + self.pendingAnswerRequestId = nil; + [[FulfillRequestManager shared] cancelAll]; + [[VoIPManager shared] clearPendingIncomingCall]; +} + +- (void)cleanupWaitingCall { + [self cancelWaitingRingTimeout]; + self.waitingCallUUID = nil; + if (self.currentCallUUID != nil) { + [self reportCallCapabilitiesForUUID:self.currentCallUUID supportsHolding:YES]; + } + [[VoIPManager shared] discardPendingSecondIncomingCall]; +} + +/** + * Answering the waiting call ends whichever call was current and takes its place. + * "End & Accept" delivers a `CXEndCallAction` for the old call before the + * `CXAnswerCallAction` that triggers this, so `performEndCallAction:` normally does + * that ending; this is only a safety net in case the old call is still around. + */ +- (void)promoteWaitingCallToCurrent { + NSUUID *promoted = self.waitingCallUUID; + [self cancelWaitingRingTimeout]; + self.waitingCallUUID = nil; + + if (self.currentCallUUID != nil) { + if (self.onCallEnded) { + self.onCallEnded(@"local"); + } + [self cleanupCurrentCall]; + } + + self.currentCallUUID = promoted; + self.isCallAnswered = NO; + self.isOutgoingCall = NO; + self.isCallOnHold = NO; + [self reportCallCapabilitiesForUUID:promoted supportsHolding:YES]; + [[VoIPManager shared] revealPendingSecondIncomingCall]; } #pragma mark - CXProviderDelegate - (void)providerDidReset:(CXProvider *)provider { - [self cleanup]; + [self cleanupCurrentCall]; + [self cleanupWaitingCall]; } - (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action { + [provider reportOutgoingCallWithUUID:action.callUUID startedConnectingAtDate:[NSDate date]]; + [self startRingTimeoutForCall:action.callUUID timeout:self.outgoingCallTimeout]; [action fulfill]; } - (void)provider:(CXProvider *)provider performEndCallAction:(CXEndCallAction *)action { + NSUUID *uuid = action.callUUID; + + if ([uuid isEqual:self.waitingCallUUID]) { + // Declined (or ended) before ever being answered + [self cleanupWaitingCall]; + [action fulfill]; + return; + } + + if (![uuid isEqual:self.currentCallUUID]) { + // Already handled, safety net + [action fulfill]; + return; + } + if (self.onCallEnded) { - self.onCallEnded(); + self.onCallEnded(@"local"); } [action fulfill]; - [self cleanup]; + [self cleanupCurrentCall]; } - (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action { + NSUUID *uuid = action.callUUID; + + if ([uuid isEqual:self.waitingCallUUID]) { + [self promoteWaitingCallToCurrent]; + } + + if (![uuid isEqual:self.currentCallUUID]) { + [action fail]; + return; + } + + [self cancelRingTimeout]; self.isCallAnswered = YES; + + __weak typeof(self) weakSelf = self; + __block NSString *requestId = nil; + requestId = [[FulfillRequestManager shared] + createRequestWithTimeout:self.fulfillAnswerTimeout + completion:^(FulfillResult result) { + typeof(self) strongSelf = weakSelf; + if (strongSelf == nil) { + [action fail]; + return; + } + if ([strongSelf.pendingAnswerRequestId isEqualToString:requestId]) { + strongSelf.pendingAnswerRequestId = nil; + } + if (result == FulfillResultFulfilled) { + [action fulfill]; + } else { + [action fail]; + [strongSelf reportAnswerFailureForCall:action.callUUID]; + } + }]; + + self.pendingAnswerRequestId = requestId; if (self.onCallAnswered) { - self.onCallAnswered(); + self.onCallAnswered(requestId); } - [action fulfill]; +} + +- (void)provider:(CXProvider *)provider timedOutPerformingAction:(CXAction *)action { + if (![action isKindOfClass:[CXAnswerCallAction class]]) { + return; + } + + NSString *requestId = self.pendingAnswerRequestId; + if (requestId != nil && [[FulfillRequestManager shared] cancel:requestId]) { + return; + } + + [action fail]; + [self reportAnswerFailureForCall:((CXAnswerCallAction *)action).callUUID]; } - (void)provider:(CXProvider *)provider performSetHeldCallAction:(CXSetHeldCallAction *)action { + if (self.waitingCallUUID != nil && [action.callUUID isEqual:self.currentCallUUID] && action.isOnHold) { + [action fail]; + return; + } + + self.isCallOnHold = action.isOnHold; if (self.onCallHeld) { self.onCallHeld(action.isOnHold); } @@ -183,9 +605,17 @@ - (void)provider:(CXProvider *)provider performSetGroupCallAction:(CXSetGroupCal - (void)provider:(CXProvider *)provider didActivateAudioSession:(AVAudioSession *)audioSession { [[RTCAudioSession sharedInstance] audioSessionDidActivate:audioSession]; + // Ringback only for one of our own outgoing VoIP calls that is still + // connecting (this delegate only fires for calls on our CXProvider, and the + // guard scopes it to an active, unanswered outgoing call). The session is now + // active so the tone follows the call route. Stopped on connect / end / deactivate. + if (self.currentCallUUID != nil && self.isOutgoingCall && !self.isCallAnswered) { + [[DialtonePlayer shared] play]; + } } - (void)provider:(CXProvider *)provider didDeactivateAudioSession:(AVAudioSession *)audioSession { + [[DialtonePlayer shared] stop]; [[RTCAudioSession sharedInstance] audioSessionDidDeactivate:audioSession]; } diff --git a/ios/RCTWebRTC/DialtonePlayer.h b/ios/RCTWebRTC/DialtonePlayer.h new file mode 100644 index 000000000..216009864 --- /dev/null +++ b/ios/RCTWebRTC/DialtonePlayer.h @@ -0,0 +1,25 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +/** + * Plays the outgoing-call ringback ("dialtone") while an outgoing call is + * connecting, until `stop` is called. + * + * The tone is synthesized in memory (no bundled asset) and played through the + * CallKit-activated audio session, so it follows the call route. Start it from + * `provider:didActivateAudioSession:` while the outgoing call is still connecting. + */ +@interface DialtonePlayer : NSObject + ++ (instancetype)shared; + +/** Starts looping the ringback. No-op if already playing. */ +- (void)play; + +/** Stops the ringback. Safe to call when nothing is playing. */ +- (void)stop; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RCTWebRTC/DialtonePlayer.m b/ios/RCTWebRTC/DialtonePlayer.m new file mode 100644 index 000000000..ccbd3fb95 --- /dev/null +++ b/ios/RCTWebRTC/DialtonePlayer.m @@ -0,0 +1,115 @@ +#import "DialtonePlayer.h" + +#import +#import + +@interface DialtonePlayer () +@property(nonatomic, strong, nullable) AVAudioPlayer *player; +@end + +@implementation DialtonePlayer + ++ (instancetype)shared { + static DialtonePlayer *instance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + instance = [[DialtonePlayer alloc] init]; + }); + return instance; +} + +/** + * Synthesizes one loopable cycle of the North-American ringback (440 Hz + 480 Hz, + * 2 s on / 4 s off) as a 16 kHz mono 16-bit PCM WAV. Cached after first build. + */ ++ (NSData *)ringbackData { + static NSData *data = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + const double sr = 16000.0; + const double f1 = 440.0, f2 = 480.0; + const double amp = 0.28; + const int onN = (int)(sr * 2.0); // 2 s ring + const int offN = (int)(sr * 4.0); // 4 s silence + const int fade = (int)(sr * 0.008); // 8 ms edge fades (avoid loop clicks) + const int total = onN + offN; + + NSMutableData *pcm = [NSMutableData dataWithCapacity:total * 2]; + for (int i = 0; i < total; i++) { + double s = 0.0; + if (i < onN) { + double t = i / sr; + s = amp * (sin(2.0 * M_PI * f1 * t) + sin(2.0 * M_PI * f2 * t)) / 2.0; + if (i < fade) + s *= (double)i / fade; + if (i > onN - fade) + s *= (double)(onN - i) / fade; + } + double clamped = fmax(-1.0, fmin(1.0, s)); + int16_t v = (int16_t)(clamped * 32767.0); + [pcm appendBytes:&v length:sizeof(v)]; + } + + // Minimal 44-byte WAV header (little-endian, matches ARM byte order). + uint32_t dataLen = (uint32_t)pcm.length; + uint32_t sampleRate = (uint32_t)sr; + uint16_t channels = 1, bitsPerSample = 16; + uint32_t byteRate = sampleRate * channels * bitsPerSample / 8; + uint16_t blockAlign = channels * bitsPerSample / 8; + uint16_t audioFormat = 1; // PCM + uint32_t fmtChunkLen = 16; + uint32_t riffLen = 36 + dataLen; + + NSMutableData *wav = [NSMutableData data]; + [wav appendBytes:"RIFF" length:4]; + [wav appendBytes:&riffLen length:4]; + [wav appendBytes:"WAVE" length:4]; + [wav appendBytes:"fmt " length:4]; + [wav appendBytes:&fmtChunkLen length:4]; + [wav appendBytes:&audioFormat length:2]; + [wav appendBytes:&channels length:2]; + [wav appendBytes:&sampleRate length:4]; + [wav appendBytes:&byteRate length:4]; + [wav appendBytes:&blockAlign length:2]; + [wav appendBytes:&bitsPerSample length:2]; + [wav appendBytes:"data" length:4]; + [wav appendBytes:&dataLen length:4]; + [wav appendData:pcm]; + + data = [wav copy]; + }); + return data; +} + +- (void)play { + @synchronized(self) { + if (self.player != nil) { + return; + } + + NSError *error = nil; + AVAudioPlayer *p = [[AVAudioPlayer alloc] initWithData:[[self class] ringbackData] error:&error]; + if (p == nil) { + NSLog(@"[DialtonePlayer] Failed to create player: %@", error.localizedDescription); + return; + } + + p.numberOfLoops = -1; // loop until stopped + p.volume = 1.0; + self.player = p; + [p prepareToPlay]; + [p play]; + } +} + +- (void)stop { + @synchronized(self) { + if (self.player == nil) { + return; + } + [self.player stop]; + self.player = nil; + } +} + +@end diff --git a/ios/RCTWebRTC/FulfillRequestManager.h b/ios/RCTWebRTC/FulfillRequestManager.h new file mode 100644 index 000000000..839bad4e6 --- /dev/null +++ b/ios/RCTWebRTC/FulfillRequestManager.h @@ -0,0 +1,22 @@ +#import + +typedef NS_ENUM(NSInteger, FulfillResult) { + FulfillResultFulfilled, + FulfillResultCancelled, + FulfillResultTimedOut, +}; + +NS_ASSUME_NONNULL_BEGIN + +@interface FulfillRequestManager : NSObject + ++ (instancetype)shared; + +- (NSString *)createRequestWithTimeout:(NSTimeInterval)timeout completion:(void (^)(FulfillResult result))completion; +- (BOOL)fulfill:(NSString *)requestId; +- (BOOL)cancel:(NSString *)requestId; +- (void)cancelAll; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/RCTWebRTC/FulfillRequestManager.m b/ios/RCTWebRTC/FulfillRequestManager.m new file mode 100644 index 000000000..84ee7e86f --- /dev/null +++ b/ios/RCTWebRTC/FulfillRequestManager.m @@ -0,0 +1,90 @@ +#import "FulfillRequestManager.h" + +@interface FulfillRequestManager () +@property(nonatomic, strong) NSMutableDictionary *requests; +@property(nonatomic) dispatch_queue_t queue; +@end + +@implementation FulfillRequestManager + ++ (instancetype)shared { + static FulfillRequestManager *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[FulfillRequestManager alloc] init]; + }); + return sharedInstance; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _requests = [NSMutableDictionary dictionary]; + _queue = dispatch_queue_create("io.fishjam.voip.fulfill-requests", DISPATCH_QUEUE_SERIAL); + } + return self; +} + +- (NSString *)createRequestWithTimeout:(NSTimeInterval)timeout completion:(void (^)(FulfillResult result))completion { + NSString *requestId = [NSUUID UUID].UUIDString; + dispatch_sync(self.queue, ^{ + self.requests[requestId] = [completion copy]; + }); + + int64_t delay = (int64_t)(MAX(0, timeout) * NSEC_PER_SEC); + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, delay), self.queue, ^{ + void (^pendingCompletion)(FulfillResult) = self.requests[requestId]; + if (pendingCompletion == nil) { + return; + } + [self.requests removeObjectForKey:requestId]; + dispatch_async(dispatch_get_main_queue(), ^{ + pendingCompletion(FulfillResultTimedOut); + }); + }); + + return requestId; +} + +- (BOOL)resolveRequest:(NSString *)requestId result:(FulfillResult)result { + __block void (^completion)(FulfillResult) = nil; + dispatch_sync(self.queue, ^{ + completion = self.requests[requestId]; + if (completion != nil) { + [self.requests removeObjectForKey:requestId]; + } + }); + + if (completion == nil) { + return NO; + } + + dispatch_async(dispatch_get_main_queue(), ^{ + completion(result); + }); + return YES; +} + +- (BOOL)fulfill:(NSString *)requestId { + return [self resolveRequest:requestId result:FulfillResultFulfilled]; +} + +- (BOOL)cancel:(NSString *)requestId { + return [self resolveRequest:requestId result:FulfillResultCancelled]; +} + +- (void)cancelAll { + __block NSArray *completions = nil; + dispatch_sync(self.queue, ^{ + completions = self.requests.allValues; + [self.requests removeAllObjects]; + }); + + for (void (^completion)(FulfillResult) in completions) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(FulfillResultCancelled); + }); + } +} + +@end diff --git a/ios/RCTWebRTC/VoIPManager.h b/ios/RCTWebRTC/VoIPManager.h new file mode 100644 index 000000000..f98108907 --- /dev/null +++ b/ios/RCTWebRTC/VoIPManager.h @@ -0,0 +1,19 @@ +#import + +@interface VoIPManager : NSObject +@property(copy, readonly, nullable) NSString *token; +@property(copy, readonly, nullable) NSDictionary *pendingIncomingCall; +@property(copy, readonly, nullable) NSDictionary *pendingCallIntent; +@property(copy) void (^onTokenUpdated)(NSString *token); +@property(copy) void (^onIncomingPush)(NSDictionary *payload); +@property(copy) void (^onCallIntent)(NSDictionary *intent); +@property(copy) void (^onWaitingCallDeclined)(NSDictionary *payload); ++ (instancetype)shared; ++ (void)registerForVoIPPushes; ++ (BOOL)handleContinueUserActivity:(NSUserActivity *)userActivity NS_SWIFT_NAME(handleContinueUserActivity(_:)); +- (void)clearPendingIncomingCall; +- (void)clearPendingCallIntent; +- (void)bufferPendingSecondIncomingCall:(NSDictionary *)payload; +- (void)revealPendingSecondIncomingCall; +- (void)discardPendingSecondIncomingCall; +@end diff --git a/ios/RCTWebRTC/VoIPManager.m b/ios/RCTWebRTC/VoIPManager.m new file mode 100644 index 000000000..41a1628aa --- /dev/null +++ b/ios/RCTWebRTC/VoIPManager.m @@ -0,0 +1,190 @@ +#import "VoIPManager.h" +#import +#import +#import "CallKitManager.h" + +@interface VoIPManager () +@property(nonatomic, strong) PKPushRegistry *registry; +@property(nonatomic, strong) dispatch_queue_t registryQueue; +@property(copy, readwrite, nullable) NSString *token; +@property(copy, readwrite, nullable) NSDictionary *pendingIncomingCall; +@property(copy, readwrite, nullable) NSDictionary *pendingCallIntent; +@property(copy, nullable) NSDictionary *pendingSecondIncomingCall; +@end + +@implementation VoIPManager + ++ (instancetype)shared { + static VoIPManager *sharedInstance = nil; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[VoIPManager alloc] init]; + }); + + return sharedInstance; +} + ++ (void)registerForVoIPPushes { + [[self shared] registerForVoIPPushes]; +} + ++ (BOOL)handleContinueUserActivity:(NSUserActivity *)userActivity { + return [[self shared] handleContinueUserActivity:userActivity]; +} + +- (void)registerForVoIPPushes { + if (self.registry != nil) { + return; + } + + self.registryQueue = dispatch_queue_create("io.fishjam.voippush", DISPATCH_QUEUE_SERIAL); + self.registry = [[PKPushRegistry alloc] initWithQueue:self.registryQueue]; + self.registry.delegate = self; + self.registry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP]; +} + +#pragma mark - PKPushRegistryDelegate + +- (void)pushRegistry:(PKPushRegistry *)registry + didUpdatePushCredentials:(PKPushCredentials *)pushCredentials + forType:(PKPushType)type { + const unsigned char *bytes = pushCredentials.token.bytes; + NSMutableString *hex = [NSMutableString stringWithCapacity:pushCredentials.token.length * 2]; + for (NSUInteger i = 0; i < pushCredentials.token.length; i++) { + [hex appendFormat:@"%02x", bytes[i]]; + } + NSString *tokenString = [hex copy]; + + if ([tokenString isEqualToString:self.token]) { + return; + } + self.token = tokenString; + + if (self.onTokenUpdated) { + self.onTokenUpdated(tokenString); + } +} + +- (void)pushRegistry:(PKPushRegistry *)registry didInvalidatePushTokenForType:(PKPushType)type { + self.token = nil; +} + +- (void)pushRegistry:(PKPushRegistry *)registry + didReceiveIncomingPushWithPayload:(PKPushPayload *)payload + forType:(PKPushType)type + withCompletionHandler:(void (^)(void))completion { + NSMutableDictionary *dict = [payload.dictionaryPayload mutableCopy]; + NSString *displayName = [dict[@"displayName"] isKindOfClass:[NSString class]] ? dict[@"displayName"] : nil; + NSString *handle = [dict[@"handle"] isKindOfClass:[NSString class]] ? dict[@"handle"] : nil; + BOOL isVideo = [dict[@"isVideo"] isKindOfClass:[NSNumber class]] ? [dict[@"isVideo"] boolValue] : NO; + dict[@"isVideo"] = @(isVideo); + + if (displayName == nil || displayName.length == 0) { + displayName = @"Incoming call"; + dict[@"displayName"] = displayName; + } + + if (handle == nil || handle.length == 0) { + handle = displayName; + } + dict[@"handle"] = handle; + + dispatch_sync(dispatch_get_main_queue(), ^{ + IncomingCallSlot slot = [[CallKitManager shared] reportIncomingCallWithDisplayName:displayName + handle:handle + isVideo:isVideo]; + + switch (slot) { + case IncomingCallSlotRejected: + if (self.onWaitingCallDeclined) { + self.onWaitingCallDeclined(dict ?: @{}); + } + break; + case IncomingCallSlotCurrent: + // Buffer the payload if the app was cold-launched and JS side hasn't yet loaded + self.pendingIncomingCall = dict; + if (self.onIncomingPush) { + self.onIncomingPush(dict ?: @{}); + } + break; + case IncomingCallSlotWaiting: + [self bufferPendingSecondIncomingCall:dict ?: @{}]; + break; + } + }); + + completion(); +} + +- (void)clearPendingIncomingCall { + self.pendingIncomingCall = nil; +} + +- (void)bufferPendingSecondIncomingCall:(NSDictionary *)payload { + self.pendingSecondIncomingCall = payload; +} + +- (void)revealPendingSecondIncomingCall { + NSDictionary *payload = self.pendingSecondIncomingCall; + if (payload == nil) { + return; + } + self.pendingSecondIncomingCall = nil; + self.pendingIncomingCall = payload; + if (self.onIncomingPush) { + self.onIncomingPush(payload); + } +} + +- (void)discardPendingSecondIncomingCall { + NSDictionary *payload = self.pendingSecondIncomingCall; + self.pendingSecondIncomingCall = nil; + if (payload != nil && self.onWaitingCallDeclined) { + self.onWaitingCallDeclined(payload); + } +} + +- (void)clearPendingCallIntent { + self.pendingCallIntent = nil; +} + +- (BOOL)handleContinueUserActivity:(NSUserActivity *)userActivity { + INIntent *intent = userActivity.interaction.intent; + INPerson *person = nil; + BOOL isVideo = NO; + + // INStartAudioCallIntent/INStartVideoCallIntent are deprecated in favour of + // INStartCallIntent, but Recents redial still delivers them, so all three are handled. + if ([intent isKindOfClass:[INStartCallIntent class]]) { + INStartCallIntent *startCallIntent = (INStartCallIntent *)intent; + person = startCallIntent.contacts.firstObject; + isVideo = startCallIntent.callCapability == INCallCapabilityVideoCall; + } else if ([intent isKindOfClass:[INStartAudioCallIntent class]]) { + person = ((INStartAudioCallIntent *)intent).contacts.firstObject; + } else if ([intent isKindOfClass:[INStartVideoCallIntent class]]) { + person = ((INStartVideoCallIntent *)intent).contacts.firstObject; + isVideo = YES; + } else { + return NO; + } + + NSString *handle = person.personHandle.value; + if (handle.length == 0) { + return NO; + } + + NSString *displayName = person.displayName.length > 0 ? person.displayName : handle; + + NSDictionary *callIntent = @{ + @"handle" : handle, + @"displayName" : displayName, + @"isVideo" : @(isVideo), + }; + self.pendingCallIntent = callIntent; + if (self.onCallIntent) { + self.onCallIntent(callIntent); + } + return YES; +} + +@end diff --git a/ios/RCTWebRTC/VoipManager.h b/ios/RCTWebRTC/VoipManager.h deleted file mode 100644 index 5498681c4..000000000 --- a/ios/RCTWebRTC/VoipManager.h +++ /dev/null @@ -1,11 +0,0 @@ -#import - -@interface VoipManager : NSObject -@property(nonatomic, copy, readonly, nullable) NSString *token; -@property(nonatomic, copy, readonly, nullable) NSDictionary *pendingIncomingCall; -@property(nonatomic, copy) void (^onTokenUpdated)(NSString *token); -@property(nonatomic, copy) void (^onIncomingPush)(NSDictionary *payload); -+ (instancetype)shared; -+ (void)registerForVoIPPushes; -- (void)clearPendingIncomingCall; -@end diff --git a/ios/RCTWebRTC/VoipManager.m b/ios/RCTWebRTC/VoipManager.m deleted file mode 100644 index 913bae1d4..000000000 --- a/ios/RCTWebRTC/VoipManager.m +++ /dev/null @@ -1,95 +0,0 @@ -#import "VoipManager.h" -#import -#import "CallKitManager.h" - -@interface VoipManager () -@property(nonatomic, strong) PKPushRegistry *registry; -@property(nonatomic, strong) dispatch_queue_t registryQueue; -@property(copy, readwrite, nullable) NSString *token; -@property(copy, readwrite, nullable) NSDictionary *pendingIncomingCall; -@end - -@implementation VoipManager - -+ (instancetype)shared { - static VoipManager *sharedInstance = nil; - static dispatch_once_t onceToken; - dispatch_once(&onceToken, ^{ - sharedInstance = [[VoipManager alloc] init]; - }); - - return sharedInstance; -} - -+ (void)registerForVoIPPushes { - [[self shared] registerForVoIPPushes]; -} - -- (void)registerForVoIPPushes { - if (self.registry != nil) { - return; - } - - self.registryQueue = dispatch_queue_create("io.fishjam.voippush", DISPATCH_QUEUE_SERIAL); - self.registry = [[PKPushRegistry alloc] initWithQueue:self.registryQueue]; - self.registry.delegate = self; - self.registry.desiredPushTypes = [NSSet setWithObject:PKPushTypeVoIP]; -} - -#pragma mark - PKPushRegistryDelegate - -- (void)pushRegistry:(PKPushRegistry *)registry - didUpdatePushCredentials:(PKPushCredentials *)pushCredentials - forType:(PKPushType)type { - const unsigned char *bytes = pushCredentials.token.bytes; - NSMutableString *hex = [NSMutableString stringWithCapacity:pushCredentials.token.length * 2]; - for (NSUInteger i = 0; i < pushCredentials.token.length; i++) { - [hex appendFormat:@"%02x", bytes[i]]; - } - NSString *tokenString = [hex copy]; - - if ([tokenString isEqualToString:self.token]) { - return; - } - self.token = tokenString; - - if (self.onTokenUpdated) { - self.onTokenUpdated(tokenString); - } -} - -- (void)pushRegistry:(PKPushRegistry *)registry didInvalidatePushTokenForType:(PKPushType)type { - self.token = nil; -} - -- (void)pushRegistry:(PKPushRegistry *)registry - didReceiveIncomingPushWithPayload:(PKPushPayload *)payload - forType:(PKPushType)type - withCompletionHandler:(void (^)(void))completion { - NSMutableDictionary *dict = [payload.dictionaryPayload mutableCopy]; - NSString *displayName = dict[@"displayName"]; - BOOL isVideo = [dict[@"isVideo"] boolValue]; - dict[@"isVideo"] = @(isVideo); - - if (displayName == nil || displayName.length == 0) { - displayName = @"Incoming call"; - dict[@"displayName"] = displayName; - } - - [[CallKitManager shared] reportIncomingCallWithDisplayName:displayName isVideo:isVideo]; - - // Buffer the payload if the app was cold-launched and JS side hasn't yet loaded - self.pendingIncomingCall = dict; - - if (self.onIncomingPush) { - self.onIncomingPush(dict ?: @{}); - } - - completion(); -} - -- (void)clearPendingIncomingCall { - self.pendingIncomingCall = nil; -} - -@end diff --git a/ios/RCTWebRTC/WebRTCModule+CallKit.m b/ios/RCTWebRTC/WebRTCModule+CallKit.m index d07fe45ee..67d9134e8 100644 --- a/ios/RCTWebRTC/WebRTCModule+CallKit.m +++ b/ios/RCTWebRTC/WebRTCModule+CallKit.m @@ -23,11 +23,11 @@ - (CallKitManager *)callKitManager { manager.onCallStarted = ^{ [weakSelf sendEventWithName:kEventCallKitActionPerformed body:@{@"started" : [NSNull null]}]; }; - manager.onCallAnswered = ^{ - [weakSelf sendEventWithName:kEventCallKitActionPerformed body:@{@"answer" : [NSNull null]}]; + manager.onCallAnswered = ^(NSString *requestId) { + [weakSelf sendEventWithName:kEventCallKitActionPerformed body:@{@"answer" : requestId}]; }; - manager.onCallEnded = ^{ - [weakSelf sendEventWithName:kEventCallKitActionPerformed body:@{@"ended" : [NSNull null]}]; + manager.onCallEnded = ^(NSString *reason) { + [weakSelf sendEventWithName:kEventCallKitActionPerformed body:@{@"ended" : reason ?: @"local"}]; }; manager.onCallFailed = ^(NSString *reason) { [weakSelf sendEventWithName:kEventCallKitActionPerformed body:@{@"failed" : reason ?: @""}]; @@ -55,7 +55,8 @@ - (void)stopObserving { } RCT_EXPORT_METHOD(startCallKitSession - : (NSString *)displayName isVideo + : (NSString *)displayName handle + : (NSString *)handle isVideo : (BOOL)isVideo resolver : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { @@ -64,23 +65,90 @@ - (void)stopObserving { return; } + NSString *callHandle = handle.length > 0 ? handle : displayName; + @try { - [[self callKitManager] startCallWithDisplayName:displayName isVideo:isVideo]; + [[self callKitManager] startCallWithDisplayName:displayName handle:callHandle isVideo:isVideo]; resolve(nil); } @catch (NSException *exception) { reject(@"E_CALLKIT_START_FAILED", exception.reason, nil); } } -RCT_EXPORT_METHOD(endCallKitSession : (RCTPromiseResolveBlock)resolve rejecter : (RCTPromiseRejectBlock)reject) { +RCT_EXPORT_METHOD(endCallKitSession + : (NSString *)reason resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { @try { - [[self callKitManager] endCall]; + [[self callKitManager] endCallWithReason:reason]; resolve(nil); } @catch (NSException *exception) { reject(@"E_CALLKIT_END_FAILED", exception.reason, nil); } } +RCT_EXPORT_METHOD(fulfillIncomingCallConnected + : (NSString *)requestId resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + @try { + resolve(@([[self callKitManager] fulfillIncomingCallConnected:requestId])); + } @catch (NSException *exception) { + reject(@"E_CALLKIT_FULFILL_ANSWER_FAILED", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(failIncomingCallConnected + : (NSString *)requestId resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + @try { + [[self callKitManager] failIncomingCallConnected:requestId]; + resolve(nil); + } @catch (NSException *exception) { + reject(@"E_CALLKIT_FAIL_ANSWER_FAILED", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(reportOutgoingCallConnected + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + @try { + [[self callKitManager] reportOutgoingCallConnected]; + resolve(nil); + } @catch (NSException *exception) { + reject(@"E_CALLKIT_REPORT_OUTGOING_CONNECTED_FAILED", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(setCallKitCallHeld + : (BOOL)onHold resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + @try { + [[self callKitManager] setCallHeld:onHold]; + resolve(nil); + } @catch (NSException *exception) { + reject(@"E_CALLKIT_SET_HELD_FAILED", exception.reason, nil); + } +} + +RCT_EXPORT_METHOD(setCallKitMuted + : (BOOL)muted resolver + : (RCTPromiseResolveBlock)resolve rejecter + : (RCTPromiseRejectBlock)reject) { + @try { + [[self callKitManager] setMuted:muted]; + resolve(nil); + } @catch (NSException *exception) { + reject(@"E_CALLKIT_SET_MUTED_FAILED", exception.reason, nil); + } +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getPendingAnswerRequestId) { + return [self callKitManager].pendingAnswerRequestId ?: [NSNull null]; +} + RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(hasActiveCallKitSession) { return @([self callKitManager].hasActiveCall); } @@ -89,4 +157,8 @@ - (void)stopObserving { return @([self callKitManager].isCallAnswered); } +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(isCallKitCallHeld) { + return @([self callKitManager].isCallOnHold); +} + @end diff --git a/ios/RCTWebRTC/WebRTCModule+PushKit.m b/ios/RCTWebRTC/WebRTCModule+PushKit.m index df91ae3f3..cd89f87b8 100644 --- a/ios/RCTWebRTC/WebRTCModule+PushKit.m +++ b/ios/RCTWebRTC/WebRTCModule+PushKit.m @@ -1,48 +1,54 @@ #import "WebRTCModule+PushKit.h" -#import "VoipManager.h" +#import "VoIPManager.h" @implementation WebRTCModule (PushKit) - (void)startObservingPushKit { - VoipManager *push = [VoipManager shared]; + VoIPManager *push = [VoIPManager shared]; __weak typeof(self) weakSelf = self; push.onTokenUpdated = ^(NSString *token) { - [weakSelf sendEventWithName:kEventVoipPush body:@{@"registered" : token ?: @""}]; + [weakSelf sendEventWithName:kEventVoIPPush body:@{@"registered" : token ?: @""}]; }; push.onIncomingPush = ^(NSDictionary *payload) { - [weakSelf sendEventWithName:kEventVoipPush body:@{@"incoming" : payload ?: @{}}]; + [weakSelf sendEventWithName:kEventVoIPPush body:@{@"incoming" : payload ?: @{}}]; + }; + push.onWaitingCallDeclined = ^(NSDictionary *payload) { + [weakSelf sendEventWithName:kEventVoIPPush body:@{@"waitingDeclined" : payload ?: @{}}]; + }; + push.onCallIntent = ^(NSDictionary *intent) { + [weakSelf sendEventWithName:kEventVoIPPush body:@{@"callIntent" : intent ?: @{}}]; }; - - NSString *token = push.token; - if (token.length > 0) { - [weakSelf sendEventWithName:kEventVoipPush body:@{@"registered" : token}]; - } - - NSDictionary *pendingCall = push.pendingIncomingCall; - if (pendingCall) { - [weakSelf sendEventWithName:kEventVoipPush body:@{@"incoming" : pendingCall}]; - } } - (void)stopObservingPushKit { - VoipManager *push = [VoipManager shared]; + VoIPManager *push = [VoIPManager shared]; push.onTokenUpdated = nil; push.onIncomingPush = nil; + push.onWaitingCallDeclined = nil; + push.onCallIntent = nil; } -RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVoipToken) { - return [VoipManager shared].token ?: [NSNull null]; +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getVoIPToken) { + return [VoIPManager shared].token ?: [NSNull null]; } RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getPendingIncomingCall) { - return [VoipManager shared].pendingIncomingCall ?: [NSNull null]; + return [VoIPManager shared].pendingIncomingCall ?: [NSNull null]; } RCT_EXPORT_METHOD(clearPendingIncomingCall) { - [[VoipManager shared] clearPendingIncomingCall]; + [[VoIPManager shared] clearPendingIncomingCall]; +} + +RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getPendingCallIntent) { + return [VoIPManager shared].pendingCallIntent ?: [NSNull null]; +} + +RCT_EXPORT_METHOD(clearPendingCallIntent) { + [[VoIPManager shared] clearPendingCallIntent]; } @end diff --git a/ios/RCTWebRTC/WebRTCModule.h b/ios/RCTWebRTC/WebRTCModule.h index 703f416cc..a2ef0ac3f 100644 --- a/ios/RCTWebRTC/WebRTCModule.h +++ b/ios/RCTWebRTC/WebRTCModule.h @@ -25,7 +25,7 @@ static NSString *const kEventCallKitActionPerformed = @"callKitActionPerformed"; static NSString *const kEventAudioOutputChanged = @"audioOutputChanged"; static NSString *const kEventLivestreamStatusChanged = @"livestreamStatusChanged"; static NSString *const kMediaStreamVideoTracksChangedNotification = @"RTCMediaStreamVideoTracksChangedNotification"; -static NSString *const kEventVoipPush = @"voipPushEvent"; +static NSString *const kEventVoIPPush = @"voipPushEvent"; @class FJAudioSinkBox; diff --git a/ios/RCTWebRTC/WebRTCModule.m b/ios/RCTWebRTC/WebRTCModule.m index 2d15b4993..f5d81f6ef 100644 --- a/ios/RCTWebRTC/WebRTCModule.m +++ b/ios/RCTWebRTC/WebRTCModule.m @@ -146,7 +146,7 @@ - (dispatch_queue_t)methodQueue { kEventCallKitActionPerformed, kEventAudioOutputChanged, kEventLivestreamStatusChanged, - kEventVoipPush + kEventVoIPPush ]; } diff --git a/src/CallKit.ts b/src/CallKit.ts index be7edeb54..eefb431ee 100644 --- a/src/CallKit.ts +++ b/src/CallKit.ts @@ -1,16 +1,26 @@ import { NativeModules, Platform } from 'react-native'; +import type { CallEndedReason } from './Telecom'; + const { WebRTCModule } = NativeModules; export type CallKitConfig = { + /** Label shown in the system call UI and in Recents. */ displayName: string; + /** + * Stable identifier for the remote party (e.g. a user id). It is what iOS persists + * in Recents and hands back in the redial intent, so it must be something your app + * can resolve - `displayName` alone is ambiguous when two users share a name. + * Defaults to `displayName`. + */ + handle?: string; isVideo: boolean; }; export type CallKitAction = { started?: undefined; - answer?: undefined; - ended?: undefined; + answer?: string; + ended?: CallEndedReason; failed?: string; muted?: boolean; held?: boolean; @@ -22,14 +32,67 @@ export async function startCallKitSession( if (Platform.OS !== 'ios') { return; } - await WebRTCModule.startCallKitSession(config.displayName, config.isVideo); + await WebRTCModule.startCallKitSession( + config.displayName, + config.handle ?? config.displayName, + config.isVideo, + ); } -export async function endCallKitSession(): Promise { +export async function endCallKitSession( + reason: CallEndedReason = 'local', +): Promise { if (Platform.OS !== 'ios') { return; } - await WebRTCModule.endCallKitSession(); + await WebRTCModule.endCallKitSession(reason); +} + +export async function fulfillIncomingCallConnected( + requestId: string, +): Promise { + if (Platform.OS !== 'ios') { + return false; + } + return WebRTCModule.fulfillIncomingCallConnected(requestId); +} + +export async function failIncomingCallConnected( + requestId: string, +): Promise { + if (Platform.OS !== 'ios') { + return; + } + await WebRTCModule.failIncomingCallConnected(requestId); +} + +export async function reportOutgoingCallConnected(): Promise { + if (Platform.OS !== 'ios') { + return; + } + await WebRTCModule.reportOutgoingCallConnected(); +} + +export async function setCallKitCallHeld(onHold: boolean): Promise { + if (Platform.OS !== 'ios') { + return; + } + await WebRTCModule.setCallKitCallHeld(onHold); +} + +export async function setCallKitMuted(muted: boolean): Promise { + if (Platform.OS !== 'ios') { + return; + } + await WebRTCModule.setCallKitMuted(muted); +} + +export function getPendingAnswerRequestId(): string | null { + if (Platform.OS !== 'ios') { + return null; + } + const requestId: unknown = WebRTCModule.getPendingAnswerRequestId(); + return typeof requestId === 'string' ? requestId : null; } export function hasActiveCallKitSession(): boolean { @@ -45,3 +108,10 @@ export function isCallAnswered(): boolean { } return WebRTCModule.isCallAnswered(); } + +export function isCallKitCallHeld(): boolean { + if (Platform.OS !== 'ios') { + return false; + } + return WebRTCModule.isCallKitCallHeld(); +} diff --git a/src/EventEmitter.ts b/src/EventEmitter.ts index 579e56b6c..9b4f203af 100644 --- a/src/EventEmitter.ts +++ b/src/EventEmitter.ts @@ -2,6 +2,7 @@ import { EmitterSubscription, NativeEventEmitter, NativeModules, + Platform, } from 'react-native'; // @ts-ignore import EventEmitter from 'react-native/Libraries/vendor/emitter/EventEmitter'; @@ -29,14 +30,25 @@ const NATIVE_EVENTS = [ 'mediaStreamTrackEnded', 'callKitActionPerformed', 'voipPushEvent', + 'telecomActionPerformed', 'audioOutputChanged', 'livestreamStatusChanged', ]; +const ANDROID_ONLY_EVENTS = ['telecomActionPerformed']; + const eventEmitter = new EventEmitter(); export function setupNativeEvents() { for (const eventName of NATIVE_EVENTS) { + // Only listen to Android-only events on Android. + if ( + Platform.OS !== 'android' && + ANDROID_ONLY_EVENTS.includes(eventName) + ) { + continue; + } + nativeEmitter.addListener(eventName, (...args) => { eventEmitter.emit(eventName, ...args); }); diff --git a/src/PushKit.ts b/src/PushKit.ts deleted file mode 100644 index 9ecdb6f69..000000000 --- a/src/PushKit.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { NativeModules, Platform } from 'react-native'; - -const { WebRTCModule } = NativeModules; - -export function getVoipToken(): string | null { - if (Platform.OS !== 'ios') { - return null; - } - const token = WebRTCModule.getVoipToken(); - return typeof token === 'string' ? token : null; -} - -export function getPendingIncomingCall(): Record | null { - if (Platform.OS !== 'ios') { - return null; - } - const call = WebRTCModule.getPendingIncomingCall(); - return call && typeof call === 'object' - ? (call as Record) - : null; -} - -export function clearPendingIncomingCall(): void { - if (Platform.OS !== 'ios') { - return; - } - WebRTCModule.clearPendingIncomingCall(); -} diff --git a/src/Telecom.ts b/src/Telecom.ts new file mode 100644 index 000000000..8e926a017 --- /dev/null +++ b/src/Telecom.ts @@ -0,0 +1,137 @@ +import { NativeModules, Platform } from 'react-native'; + +const { WebRTCModule } = NativeModules; + +export type TelecomConfig = { + /** Label shown in the system call UI and in the call log. */ + displayName: string; + /** + * Stable identifier for the remote party (e.g. a user id), used as the call's Telecom + * address. Unlike iOS there is no redial-from-Recents path on Android today, so this is + * identity only - it is not handed back to the app. Defaults to `displayName`. + */ + handle?: string; + isVideo: boolean; +}; + +export type TelecomEventType = + | 'started' + | 'answer' + | 'ended' + | 'failed' + | 'muteChanged' + | 'holdChanged'; + +/** + * Cross-platform reason a call ended, surfaced from both Telecom (Android) and + * CallKit (iOS): + * - `local` — this device hung up (or the system CallKit UI's End/Decline, which + * iOS can't distinguish from a plain hang-up). + * - `rejected` — the callee actively declined a ringing incoming call (Android only + * — CallKit has no ended-reason case for a local decline, so on iOS this also + * surfaces as `local`). + * - `missed` — an incoming call rang and was never answered, including the native + * ring timeout (default 45 seconds), or an Android outgoing call did not connect + * before its native timeout (default 60 seconds). + * - `remote` — the other party ended the call. + * - `answeredElsewhere` — answered on another of the user's devices while ringing. + * - `failed` — call setup (e.g. room join) failed. + */ +export type CallEndedReason = + | 'local' + | 'rejected' + | 'missed' + | 'remote' + | 'answeredElsewhere' + | 'failed'; + +export type TelecomEvent = { + event: TelecomEventType; + requestId?: string; + reason?: CallEndedReason | string; + muted?: boolean; + held?: boolean; +}; + +const isAndroid = Platform.OS === 'android'; + +export async function startTelecomCall(config: TelecomConfig): Promise { + if (!isAndroid) { + return; + } + await WebRTCModule.startTelecomCall( + config.displayName, + config.handle ?? config.displayName, + config.isVideo, + ); +} + +export async function reportTelecomCallConnected(): Promise { + if (!isAndroid) { + return; + } + await WebRTCModule.reportOutgoingCallConnected(); +} + +export async function fulfillTelecomCallAnswered( + requestId: string, +): Promise { + if (!isAndroid) { + return false; + } + return WebRTCModule.fulfillTelecomCallAnswered(requestId); +} + +export async function failTelecomCallAnswered( + requestId: string, +): Promise { + if (!isAndroid) { + return; + } + await WebRTCModule.failTelecomCallAnswered(requestId); +} + +export function getPendingAnswerRequestId(): string | null { + if (!isAndroid) { + return null; + } + const requestId: unknown = WebRTCModule.getPendingAnswerRequestId(); + return typeof requestId === 'string' ? requestId : null; +} + +export async function endTelecomCall( + reason: CallEndedReason = 'local', +): Promise { + if (!isAndroid) { + return; + } + await WebRTCModule.endTelecomCall(reason); +} + +export async function setTelecomCallHeld(onHold: boolean): Promise { + if (!isAndroid) { + return; + } + await WebRTCModule.setTelecomCallHeld(onHold); +} + +export function hasActiveTelecomCall(): boolean { + if (!isAndroid) { + return false; + } + return WebRTCModule.hasActiveTelecomCall(); +} + +export function isTelecomCallAnswered(): boolean { + if (!isAndroid) { + return false; + } + return WebRTCModule.isTelecomCallAnswered(); +} + +export function isTelecomCallHeld(): boolean { + if (!isAndroid) { + return false; + } + return WebRTCModule.isTelecomCallHeld(); +} diff --git a/src/VoIP.ts b/src/VoIP.ts new file mode 100644 index 000000000..c235af05c --- /dev/null +++ b/src/VoIP.ts @@ -0,0 +1,143 @@ +import { NativeModules, Platform } from 'react-native'; + +import { + failIncomingCallConnected as failCallKitAnswer, + fulfillIncomingCallConnected as fulfillCallKitAnswer, + getPendingAnswerRequestId as getPendingCallKitAnswerRequestId, + isCallKitCallHeld, + reportOutgoingCallConnected as reportCallKitOutgoingCallConnected, + setCallKitCallHeld, + setCallKitMuted, +} from './CallKit'; +import { + failTelecomCallAnswered, + fulfillTelecomCallAnswered, + getPendingAnswerRequestId as getPendingTelecomAnswerRequestId, + isTelecomCallHeld, + reportTelecomCallConnected, + setTelecomCallHeld, +} from './Telecom'; + +const { WebRTCModule } = NativeModules; + +export function getVoIPToken(): Promise { + if (Platform.OS === 'ios') { + const token = WebRTCModule.getVoIPToken(); + return Promise.resolve(typeof token === 'string' ? token : null); + } + return WebRTCModule.getVoIPToken().then((token: unknown) => + typeof token === 'string' ? token : null, + ); +} + +export function getPendingIncomingCall(): Record | null { + const call = WebRTCModule.getPendingIncomingCall(); + return call && typeof call === 'object' + ? (call as Record) + : null; +} + +export function clearPendingIncomingCall(): void { + WebRTCModule.clearPendingIncomingCall(); +} + +export type VoIPCallIntent = { + /** Stable id of the party to call back - the handle originally reported to CallKit. */ + handle: string; + /** Label iOS showed for the entry. Falls back to `handle` when there is no separate label. */ + displayName: string; + isVideo: boolean; +}; + +export function getPendingCallIntent(): VoIPCallIntent | null { + if (Platform.OS !== 'ios') { + return null; + } + const intent: unknown = WebRTCModule.getPendingCallIntent(); + if (!intent || typeof intent !== 'object') { + return null; + } + + const value = intent as Record; + if ( + typeof value.handle !== 'string' || + typeof value.displayName !== 'string' || + typeof value.isVideo !== 'boolean' + ) { + return null; + } + return value as VoIPCallIntent; +} + +export function clearPendingCallIntent(): void { + if (Platform.OS === 'ios') { + WebRTCModule.clearPendingCallIntent(); + } +} + +/** + * Resolves the parked native answer action once incoming-call media is live. + * Returns false when the request has already timed out or been resolved. + */ +export function fulfillIncomingCallConnected( + requestId: string, +): Promise { + return Platform.OS === 'ios' + ? fulfillCallKitAnswer(requestId) + : fulfillTelecomCallAnswered(requestId); +} + +/** + * Aborts the parked native answer action. Safe to call after it has timed out. + */ +export function failIncomingCallConnected(requestId: string): Promise { + return Platform.OS === 'ios' + ? failCallKitAnswer(requestId) + : failTelecomCallAnswered(requestId); +} + +/** Returns the answer request that is still awaiting media, if any. */ +export function getPendingAnswerRequestId(): string | null { + return Platform.OS === 'ios' + ? getPendingCallKitAnswerRequestId() + : getPendingTelecomAnswerRequestId(); +} + +/** + * Reports that an outgoing call's media is connected — the remote party answered. + * Until this is called, the OS shows the call as "Calling…" / "Dialing…" and no + * call timer runs. No-op for incoming calls, or when there is no active outgoing + * call. + */ +export function reportOutgoingCallConnected(): Promise { + return Platform.OS === 'ios' + ? reportCallKitOutgoingCallConnected() + : reportTelecomCallConnected(); +} + +/** + * Asks the OS to hold or resume the current call. The system decides and reports back + * through `onHeldChanged`, so treat that event — not this call returning — as the point + * the call is actually held. No-op when there is no active call. + */ +export function setCallHeld(onHold: boolean): Promise { + return Platform.OS === 'ios' + ? setCallKitCallHeld(onHold) + : setTelecomCallHeld(onHold); +} + +/** + * Drives the system mute state so the OS call UI reflects an in-app mute, and lets + * the mute round-trip back through `onMuteChanged`. + */ +export function setCallMuted(muted: boolean): Promise { + return Platform.OS === 'ios' ? setCallKitMuted(muted) : Promise.resolve(); +} + +/** + * Whether the OS currently has the call on hold. Useful on mount, when no `onHeldChanged` + * event has been seen yet. + */ +export function isCallHeld(): boolean { + return Platform.OS === 'ios' ? isCallKitCallHeld() : isTelecomCallHeld(); +} diff --git a/src/index.ts b/src/index.ts index 861852e05..ce688ad0b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,7 +16,13 @@ import { type AudioExtractionOptions, type AudioTrackData, } from './AudioExtraction'; -import { type CallKitAction, type CallKitConfig } from './CallKit'; +import { + type CallKitAction, + type CallKitConfig, + isCallKitCallHeld, + setCallKitCallHeld, + setCallKitMuted, +} from './CallKit'; import { setupNativeEvents } from './EventEmitter'; import Logger from './Logger'; import mediaDevices from './MediaDevices'; @@ -24,11 +30,6 @@ import MediaStream from './MediaStream'; import MediaStreamTrack, { type MediaTrackSettings } from './MediaStreamTrack'; import MediaStreamTrackEvent from './MediaStreamTrackEvent'; import permissions from './Permissions'; -import { - clearPendingIncomingCall, - getPendingIncomingCall, - getVoipToken, -} from './PushKit'; import RTCAudioSession from './RTCAudioSession'; import RTCCertificate from './RTCCertificate'; import RTCErrorEvent from './RTCErrorEvent'; @@ -51,6 +52,29 @@ import RTCRtpTransceiver from './RTCRtpTransceiver'; import RTCSessionDescription from './RTCSessionDescription'; import RTCView, { type RTCPIPOptions, type RTCVideoViewProps } from './RTCView'; import ScreenCapturePickerView from './ScreenCapturePickerView'; +import { + type CallEndedReason, + type TelecomConfig, + type TelecomEvent, + type TelecomEventType, + isTelecomCallHeld, + setTelecomCallHeld, +} from './Telecom'; +import { + clearPendingCallIntent, + clearPendingIncomingCall, + failIncomingCallConnected, + fulfillIncomingCallConnected, + getPendingAnswerRequestId, + getPendingCallIntent, + getPendingIncomingCall, + getVoIPToken, + isCallHeld, + reportOutgoingCallConnected, + setCallHeld, + setCallMuted, + type VoIPCallIntent, +} from './VoIP'; import { AudioDeviceType, AudioOutputManager, @@ -94,10 +118,15 @@ import { type LivestreamStatus, type LivestreamStatusInfo, } from './useLivestreamStatus'; +import { + useTelecom, + useTelecomEvent, + type UseTelecomResult, +} from './useTelecom'; import { useVoIPEvents, type VoIPEventHandlers, - type VoipIncomingPayload, + type VoIPIncomingPayload, } from './useVoIPEvents'; import { Event, EventTarget } from './vendor/event-target-shim'; import writeLivestreamCredentials, { @@ -112,15 +141,20 @@ setupNativeEvents(); export { AudioDeviceType, AudioOutputManager, + clearPendingCallIntent, clearPendingIncomingCall, createCustomAudioTrack, createCustomVideoBufferPool, createCustomVideoTrack, Event, EventTarget, + failIncomingCallConnected, forwardFrame, + fulfillIncomingCallConnected, + getPendingAnswerRequestId, + getPendingCallIntent, getPendingIncomingCall, - getVoipToken, + getVoIPToken, mediaDevices, MediaStream, MediaStreamTrack, @@ -130,6 +164,10 @@ export { pushAudioSamples, pushFrame, registerGlobals, + reportOutgoingCallConnected, + isCallHeld, + isCallKitCallHeld, + isTelecomCallHeld, RTCAudioSession, RTCCertificate, RTCErrorEvent, @@ -147,18 +185,26 @@ export { startAudioExtraction, startPIP, stopPIP, + setCallHeld, + setCallKitCallHeld, + setCallKitMuted, + setCallMuted, + setTelecomCallHeld, useAudioOutput, useCallKit, useCallKitEvent, useCallKitService, useForegroundService, useLivestreamStatus, + useTelecom, + useTelecomEvent, useVoIPEvents, writeLivestreamCredentials, type AudioDevice, type AudioExtractionOptions, type AudioOutputChangedInfo, type AudioTrackData, + type CallEndedReason, type CallKitAction, type CallKitConfig, type CustomAudioSink, @@ -185,9 +231,14 @@ export { type RTCRtpEncodingParametersInit, type RTCRtpSendParametersInit, type RTCVideoViewProps, + type TelecomConfig, + type TelecomEvent, + type TelecomEventType, type UseAudioOutputResult, + type UseTelecomResult, type VoIPEventHandlers, - type VoipIncomingPayload, + type VoIPCallIntent, + type VoIPIncomingPayload, }; declare const global: any; diff --git a/src/useCallKit.ts b/src/useCallKit.ts index 31661fa05..436e69847 100644 --- a/src/useCallKit.ts +++ b/src/useCallKit.ts @@ -6,14 +6,19 @@ import { CallKitConfig, endCallKitSession, hasActiveCallKitSession, + isCallKitCallHeld, + setCallKitCallHeld, startCallKitSession, } from './CallKit'; import { addListener, removeListener } from './EventEmitter'; +import type { CallEndedReason } from './Telecom'; export type UseCallKitResult = { startCallKitSession: (config: CallKitConfig) => Promise; - endCallKitSession: () => Promise; + endCallKitSession: (reason?: CallEndedReason) => Promise; getCallKitSessionStatus: () => Promise; + setCallHeld: (onHold: boolean) => Promise; + isHeld: () => boolean; }; function useCallKitIos(): UseCallKitResult { @@ -26,27 +31,37 @@ function useCallKitIos(): UseCallKitResult { } }, []); - const endCallKitSessionCb = useCallback(async () => { - try { - await endCallKitSession(); - } catch (error) { - console.error('Failed to end CallKit session:', error); - throw error; - } - }, []); + const endCallKitSessionCb = useCallback( + async (reason?: CallEndedReason) => { + try { + await endCallKitSession(reason); + } catch (error) { + console.error('Failed to end CallKit session:', error); + throw error; + } + }, + [], + ); const getCallKitSessionStatus = useCallback(async () => { return hasActiveCallKitSession(); }, []); + const setCallHeld = useCallback( + (onHold: boolean) => setCallKitCallHeld(onHold), + [], + ); + const isHeld = useCallback(() => isCallKitCallHeld(), []); return { startCallKitSession: startCallKitSessionCb, endCallKitSession: endCallKitSessionCb, getCallKitSessionStatus, + setCallHeld, + isHeld, }; } -const useCallKitServiceIos = (config: CallKitConfig) => { +const useCallKitServiceIos = (config: Omit) => { const { displayName, isVideo } = config; const { startCallKitSession, endCallKitSession } = useCallKitIos(); diff --git a/src/useTelecom.ts b/src/useTelecom.ts new file mode 100644 index 000000000..0066f6a37 --- /dev/null +++ b/src/useTelecom.ts @@ -0,0 +1,71 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; + +import { addListener, removeListener } from './EventEmitter'; +import { + type CallEndedReason, + endTelecomCall, + hasActiveTelecomCall, + isTelecomCallAnswered, + isTelecomCallHeld, + setTelecomCallHeld, + startTelecomCall, + type TelecomConfig, + type TelecomEvent, +} from './Telecom'; + +export type UseTelecomResult = { + startCall: (config: TelecomConfig) => Promise; + endCall: (reason?: CallEndedReason) => Promise; + hasActiveCall: () => boolean; + isAnswered: () => boolean; + setCallHeld: (onHold: boolean) => Promise; + isHeld: () => boolean; +}; + +export function useTelecom(): UseTelecomResult { + const startCall = useCallback( + (config: TelecomConfig) => startTelecomCall(config), + [], + ); + const endCall = useCallback( + (reason?: CallEndedReason) => endTelecomCall(reason), + [], + ); + const hasActiveCall = useCallback(() => hasActiveTelecomCall(), []); + const isAnswered = useCallback(() => isTelecomCallAnswered(), []); + const setCallHeld = useCallback( + (onHold: boolean) => setTelecomCallHeld(onHold), + [], + ); + const isHeld = useCallback(() => isTelecomCallHeld(), []); + + return { + startCall, + endCall, + hasActiveCall, + isAnswered, + setCallHeld, + isHeld, + }; +} + +export function useTelecomEvent(callback: (event: TelecomEvent) => void): void { + const callbackRef = useRef(callback); + callbackRef.current = callback; + const listener = useRef({}); + + useEffect(() => { + if (Platform.OS !== 'android') { + return; + } + + addListener(listener.current, 'telecomActionPerformed', (event) => { + if (event && typeof event === 'object') { + callbackRef.current(event as TelecomEvent); + } + }); + const current = listener.current; + return () => removeListener(current); + }, []); +} diff --git a/src/useVoIPEvents.ts b/src/useVoIPEvents.ts index fa8182af5..b17be8035 100644 --- a/src/useVoIPEvents.ts +++ b/src/useVoIPEvents.ts @@ -1,27 +1,49 @@ import { useEffect, useRef } from 'react'; import { Platform } from 'react-native'; -import { hasActiveCallKitSession, isCallAnswered } from './CallKit'; +import { hasActiveCallKitSession } from './CallKit'; import { addListener, removeListener } from './EventEmitter'; +import { type CallEndedReason, hasActiveTelecomCall } from './Telecom'; import { + clearPendingCallIntent, clearPendingIncomingCall, + getPendingAnswerRequestId, + getPendingCallIntent, getPendingIncomingCall, - getVoipToken, -} from './PushKit'; + getVoIPToken, + type VoIPCallIntent, +} from './VoIP'; import { useCallKitEvent } from './useCallKit'; // If you don't provide displayName it will default to incoming call, isVideo to false -export type VoipIncomingPayload = { +export type VoIPIncomingPayload = { roomName: string; displayName: string; + /** + * Stable id of the caller, taken from the push payload's `handle` (falls back to + * `displayName`). On iOS this is what lands in Recents and comes back as the redial + * intent's handle; on Android it is the call's Telecom address. + */ + handle: string; isVideo: boolean; + /** + * Optional URL of the caller's avatar, forwarded verbatim from the push payload. + * On Android it is downloaded and shown in the incoming-call notification and + * full-screen UI; on iOS CallKit cannot render it, so it is provided only for + * your own in-app UI. + */ + avatarUrl?: string; }; export type VoIPEventHandlers = { - onIncoming?: (payload: VoipIncomingPayload) => void; - onAnswered?: () => void; - onEnded?: () => void; + onIncoming?: (payload: VoIPIncomingPayload) => void; + onAnswered?: (requestId: string) => void; + onEnded?: (reason?: CallEndedReason) => void; onRegistered?: (token: string) => void; + onHeldChanged?: (onHold: boolean) => void; + onMuteChanged?: (muted: boolean) => void; + onCallIntent?: (intent: VoIPCallIntent) => void; + onWaitingCallDeclined?: (payload: VoIPIncomingPayload) => void; }; const assertRoomName = (raw: unknown): string => { @@ -44,14 +66,24 @@ const useVoIPEventsIos = (handlers: VoIPEventHandlers): void => { handlersRef.current = handlers; const listener = useRef({}); - useCallKitEvent('answer', () => handlersRef.current.onAnswered?.()); - useCallKitEvent('ended', () => { + useCallKitEvent('answer', (requestId) => { + if (requestId) { + handlersRef.current.onAnswered?.(requestId); + } + }); + useCallKitEvent('ended', (reason) => { clearPendingIncomingCall(); - handlersRef.current.onEnded?.(); + handlersRef.current.onEnded?.(reason); + }); + useCallKitEvent('held', (onHold) => { + handlersRef.current.onHeldChanged?.(Boolean(onHold)); + }); + useCallKitEvent('muted', (muted) => { + handlersRef.current.onMuteChanged?.(Boolean(muted)); }); useEffect(() => { - // PushKit events (registered / incoming) arrive on the VoIP push channel. + // VoIP push events (registered / incoming) arrive on the VoIP push channel. addListener(listener.current, 'voipPushEvent', (event) => { if (!event || typeof event !== 'object') { return; @@ -66,49 +98,149 @@ const useVoIPEventsIos = (handlers: VoIPEventHandlers): void => { assertRoomName(payload.incoming); handlersRef.current.onIncoming?.( - payload.incoming as VoipIncomingPayload, + payload.incoming as VoIPIncomingPayload, ); - clearPendingIncomingCall(); + } + if ('waitingDeclined' in payload) { + assertRoomName(payload.waitingDeclined); + + handlersRef.current.onWaitingCallDeclined?.( + payload.waitingDeclined as VoIPIncomingPayload, + ); + } + if ('callIntent' in payload) { + const intent = payload.callIntent as VoIPCallIntent; + handlersRef.current.onCallIntent?.(intent); + clearPendingCallIntent(); } }); - // The VoIP token / incoming call are usually issued before JS subscribes - // so the live events above are missed. Recover them from the - // native buffer on mount. - const token = getVoipToken(); - if (token) { - handlersRef.current.onRegistered?.(token); - } + getVoIPToken().then((token) => { + if (token) { + handlersRef.current.onRegistered?.(token); + } + }); const pendingCall = getPendingIncomingCall(); if (pendingCall && hasActiveCallKitSession()) { try { assertRoomName(pendingCall); handlersRef.current.onIncoming?.( - pendingCall as unknown as VoipIncomingPayload, + pendingCall as unknown as VoIPIncomingPayload, ); - clearPendingIncomingCall(); - // The user may have accepted before JS was - // ready, so the live onAnswered was missed. Recover it from - // native state so the call connects instead of staying stuck. - if (isCallAnswered()) { - handlersRef.current.onAnswered?.(); + const requestId = getPendingAnswerRequestId(); + if (requestId) { + handlersRef.current.onAnswered?.(requestId); } } catch { // Ignore a malformed buffered payload. } } + const pendingCallIntent = getPendingCallIntent(); + if (pendingCallIntent) { + handlersRef.current.onCallIntent?.(pendingCallIntent); + clearPendingCallIntent(); + } + return () => { removeListener(listener.current); }; }, []); }; -const emptyFunction = () => {}; +const useVoIPEventsAndroid = (handlers: VoIPEventHandlers): void => { + const handlersRef = useRef(handlers); + handlersRef.current = handlers; + const listener = useRef({}); + + useEffect(() => { + addListener(listener.current, 'telecomActionPerformed', (event) => { + if (!event || typeof event !== 'object') { + return; + } + const payload = event as { + event?: string; + requestId?: string; + reason?: CallEndedReason; + held?: boolean; + muted?: boolean; + }; + if (payload.event === 'answer' && payload.requestId) { + handlersRef.current.onAnswered?.(payload.requestId); + } else if (payload.event === 'ended') { + clearPendingIncomingCall(); + handlersRef.current.onEnded?.(payload.reason); + } else if ( + payload.event === 'holdChanged' && + typeof payload.held === 'boolean' + ) { + handlersRef.current.onHeldChanged?.(payload.held); + } else if ( + payload.event === 'muteChanged' && + typeof payload.muted === 'boolean' + ) { + handlersRef.current.onMuteChanged?.(payload.muted); + } + }); + + addListener(listener.current, 'voipPushEvent', (event) => { + if (!event || typeof event !== 'object') { + return; + } + const payload = event as Record; + if ('registered' in payload) { + handlersRef.current.onRegistered?.( + payload.registered as string, + ); + } + if ('incoming' in payload) { + assertRoomName(payload.incoming); + + handlersRef.current.onIncoming?.( + payload.incoming as VoIPIncomingPayload, + ); + } + if ('waitingDeclined' in payload) { + assertRoomName(payload.waitingDeclined); + + handlersRef.current.onWaitingCallDeclined?.( + payload.waitingDeclined as VoIPIncomingPayload, + ); + } + }); + + getVoIPToken().then((token) => { + if (token) { + handlersRef.current.onRegistered?.(token); + } + }); + + const pendingCall = getPendingIncomingCall(); + if (pendingCall && hasActiveTelecomCall()) { + try { + assertRoomName(pendingCall); + handlersRef.current.onIncoming?.( + pendingCall as unknown as VoIPIncomingPayload, + ); + + const requestId = getPendingAnswerRequestId(); + if (requestId) { + handlersRef.current.onAnswered?.(requestId); + } + } catch { + // Ignore a malformed buffered payload. + } + } + + return () => { + removeListener(listener.current); + }; + }, []); +}; export const useVoIPEvents = Platform.select({ ios: useVoIPEventsIos, - default: emptyFunction, + android: useVoIPEventsAndroid, }) as typeof useVoIPEventsIos;