Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions FishjamReactNativeWebrtc.podspec
Original file line number Diff line number Diff line change
Expand Up @@ -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' =>
Expand Down
14 changes: 13 additions & 1 deletion android/build.gradle
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -60,9 +63,18 @@ android {
}
}

kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_17
}
}
Comment thread
Magmusacy marked this conversation as resolved.

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'
}
120 changes: 119 additions & 1 deletion android/src/main/java/com/oney/WebRTCModule/AudioOutputManager.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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) {
Expand All @@ -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));
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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()) {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
146 changes: 146 additions & 0 deletions android/src/main/java/com/oney/WebRTCModule/TelecomController.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading