diff --git a/android/sdk/src/main/AndroidManifest.xml b/android/sdk/src/main/AndroidManifest.xml index 2a1511a39..5ff31b4ab 100644 --- a/android/sdk/src/main/AndroidManifest.xml +++ b/android/sdk/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + @@ -26,5 +27,11 @@ android:supportsRtl="true"> + + + + + diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java index c274fceb5..c00a9666f 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/AudioModeModule.java @@ -25,6 +25,8 @@ import android.content.pm.PackageManager; import android.media.AudioDeviceInfo; import android.media.AudioManager; import android.os.Build; +import android.support.annotation.RequiresApi; +import android.telecom.CallAudioState; import android.util.Log; import com.facebook.react.bridge.Arguments; @@ -100,6 +102,69 @@ class AudioModeModule */ static final String TAG = MODULE_NAME; + /** + * Converts any of the "DEVICE_" constants into the corresponding + * {@link CallAudioState} "ROUTE_" number. + * + * @param audioDevice one of the "DEVICE_" constants. + * @return a route number {@link CallAudioState#ROUTE_EARPIECE} if no match + * is found. + */ + @RequiresApi(api = Build.VERSION_CODES.M) + private static int audioDeviceToRouteInt(String audioDevice) { + if (audioDevice == null) { + return CallAudioState.ROUTE_EARPIECE; + } + switch (audioDevice) { + case DEVICE_BLUETOOTH: + return CallAudioState.ROUTE_BLUETOOTH; + case DEVICE_EARPIECE: + return CallAudioState.ROUTE_EARPIECE; + case DEVICE_HEADPHONES: + return CallAudioState.ROUTE_WIRED_HEADSET; + case DEVICE_SPEAKER: + return CallAudioState.ROUTE_SPEAKER; + default: + Log.e(TAG, "Unsupported device name: " + audioDevice); + return CallAudioState.ROUTE_EARPIECE; + } + } + + /** + * Populates given route mask into the "DEVICE_" list. + * + * @param supportedRouteMask an integer coming from + * {@link CallAudioState#getSupportedRouteMask()}. + * @return a list of device names. + */ + private static Set routesToDeviceNames(int supportedRouteMask) { + Set devices = new HashSet<>(); + if ((supportedRouteMask & CallAudioState.ROUTE_EARPIECE) + == CallAudioState.ROUTE_EARPIECE) { + devices.add(DEVICE_EARPIECE); + } + if ((supportedRouteMask & CallAudioState.ROUTE_BLUETOOTH) + == CallAudioState.ROUTE_BLUETOOTH) { + devices.add(DEVICE_BLUETOOTH); + } + if ((supportedRouteMask & CallAudioState.ROUTE_SPEAKER) + == CallAudioState.ROUTE_SPEAKER) { + devices.add(DEVICE_SPEAKER); + } + if ((supportedRouteMask & CallAudioState.ROUTE_WIRED_HEADSET) + == CallAudioState.ROUTE_WIRED_HEADSET) { + devices.add(DEVICE_HEADPHONES); + } + return devices; + } + + /** + * Whether or not the ConnectionService is used for selecting audio devices. + */ + private static boolean useConnectionService() { + return Build.VERSION.SDK_INT >= Build.VERSION_CODES.O; + } + /** * Indicator that we have lost audio focus. */ @@ -204,6 +269,15 @@ class AudioModeModule */ private String selectedDevice; + /** + * Used on API >= 26 to store the most recently reported audio devices. + * Makes it easier to compare for a change, because the devices are stored + * as a mask in the {@link CallAudioState}. The mask is populated into + * the {@link #availableDevices} on each update. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private int supportedRouteMask; + /** * User selected device. When null the default is used depending on the * mode. @@ -224,21 +298,25 @@ class AudioModeModule = (AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE); - // Setup runtime device change detection. - setupAudioRouteChangeDetection(); + // Starting Oreo the ConnectionImpl from ConnectionService us used to + // detect the available devices. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { + // Setup runtime device change detection. + setupAudioRouteChangeDetection(); - // Do an initial detection on Android >= M. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - runInAudioThread(onAudioDeviceChangeRunner); - } else { - // On Android < M, detect if we have an earpiece. - PackageManager pm = reactContext.getPackageManager(); - if (pm.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) { - availableDevices.add(DEVICE_EARPIECE); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + // Do an initial detection on Android >= M. + runInAudioThread(onAudioDeviceChangeRunner); + } else { + // On Android < M, detect if we have an earpiece. + PackageManager pm = reactContext.getPackageManager(); + if (pm.hasSystemFeature(PackageManager.FEATURE_TELEPHONY)) { + availableDevices.add(DEVICE_EARPIECE); + } + + // Always assume there is a speaker. + availableDevices.add(DEVICE_SPEAKER); } - - // Always assume there is a speaker. - availableDevices.add(DEVICE_SPEAKER); } } @@ -354,6 +432,38 @@ class AudioModeModule }); } + @RequiresApi(api = Build.VERSION_CODES.O) + void onCallAudioStateChange(final CallAudioState callAudioState) { + runInAudioThread(new Runnable() { + @Override + public void run() { + int newSupportedRoutes = callAudioState.getSupportedRouteMask(); + boolean audioDevicesChanged + = supportedRouteMask != newSupportedRoutes; + if (audioDevicesChanged) { + supportedRouteMask = newSupportedRoutes; + availableDevices = routesToDeviceNames(supportedRouteMask); + Log.d(TAG, + "Available audio devices: " + + availableDevices.toString()); + } + + boolean audioRouteChanged + = audioDeviceToRouteInt(selectedDevice) + != callAudioState.getRoute(); + + if (audioRouteChanged || audioDevicesChanged) { + // Reset user selection + userSelectedDevice = null; + + if (mode != -1) { + updateAudioRoute(mode); + } + } + } + }); + } + /** * {@link AudioManager.OnAudioFocusChangeListener} interface method. Called * when the audio focus of the system is updated. @@ -417,6 +527,31 @@ class AudioModeModule }); } + /** + * The API >= 26 way of adjusting the audio route. + * + * @param audioDevice one of the "DEVICE_" names to set as the audio route. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + private void setAudioRoute(String audioDevice) { + int newAudioRoute = audioDeviceToRouteInt(audioDevice); + + RNConnectionService.setAudioRoute(newAudioRoute); + } + + /** + * The API < 26 way of adjusting the audio route. + * + * @param audioDevice one of the "DEVICE_" names to set as the audio route. + */ + private void setAudioRoutePreO(String audioDevice) { + // Turn bluetooth on / off + setBluetoothAudioRoute(audioDevice.equals(DEVICE_BLUETOOTH)); + + // Turn speaker on / off + audioManager.setSpeakerphoneOn(audioDevice.equals(DEVICE_SPEAKER)); + } + /** * Helper method to set the output route to a Bluetooth device. * @@ -475,7 +610,7 @@ class AudioModeModule /** * Setup the audio route change detection mechanism. We use the - * {@link android.media.AudioDeviceCallback} API on Android >= 23 only. + * {@link android.media.AudioDeviceCallback} on 23 >= Android API < 26. */ private void setupAudioRouteChangeDetection() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { @@ -486,7 +621,7 @@ class AudioModeModule } /** - * Audio route change detection mechanism for Android API >= 23. + * Audio route change detection mechanism for 23 >= Android API < 26. */ @TargetApi(Build.VERSION_CODES.M) private void setupAudioRouteChangeDetectionM() { @@ -542,27 +677,31 @@ class AudioModeModule Log.d(TAG, "Update audio route for mode: " + mode); if (mode == DEFAULT) { - audioFocusLost = false; - audioManager.setMode(AudioManager.MODE_NORMAL); - audioManager.abandonAudioFocus(this); - audioManager.setSpeakerphoneOn(false); - setBluetoothAudioRoute(false); + if (!useConnectionService()) { + audioFocusLost = false; + audioManager.setMode(AudioManager.MODE_NORMAL); + audioManager.abandonAudioFocus(this); + audioManager.setSpeakerphoneOn(false); + setBluetoothAudioRoute(false); + } selectedDevice = null; userSelectedDevice = null; return true; } - audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - audioManager.setMicrophoneMute(false); + if (!useConnectionService()) { + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + audioManager.setMicrophoneMute(false); - if (audioManager.requestAudioFocus( + if (audioManager.requestAudioFocus( this, AudioManager.STREAM_VOICE_CALL, AudioManager.AUDIOFOCUS_GAIN) - == AudioManager.AUDIOFOCUS_REQUEST_FAILED) { - Log.d(TAG, "Audio focus request failed"); - return false; + == AudioManager.AUDIOFOCUS_REQUEST_FAILED) { + Log.d(TAG, "Audio focus request failed"); + return false; + } } boolean bluetoothAvailable = availableDevices.contains(DEVICE_BLUETOOTH); @@ -596,11 +735,11 @@ class AudioModeModule selectedDevice = audioDevice; Log.d(TAG, "Selected audio device: " + audioDevice); - // Turn bluetooth on / off - setBluetoothAudioRoute(audioDevice.equals(DEVICE_BLUETOOTH)); - - // Turn speaker on / off - audioManager.setSpeakerphoneOn(audioDevice.equals(DEVICE_SPEAKER)); + if (useConnectionService()) { + setAudioRoute(audioDevice); + } else { + setAudioRoutePreO(audioDevice); + } return true; } diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ConnectionService.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ConnectionService.java new file mode 100644 index 000000000..18af5dd9a --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ConnectionService.java @@ -0,0 +1,435 @@ +package org.jitsi.meet.sdk; + +import android.content.ComponentName; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.RequiresApi; +import android.telecom.CallAudioState; +import android.telecom.Connection; +import android.telecom.ConnectionRequest; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import android.telecom.VideoProfile; +import android.util.Log; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.WritableNativeMap; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Jitsi Meet implementation of {@link ConnectionService}. At the time of this + * writing it implements only the outgoing call scenario. + * + * NOTE the class needs to be public, but is not part of the SDK API and should + * never be used directly. + * + * @author Pawel Domas + */ +@RequiresApi(api = Build.VERSION_CODES.O) +public class ConnectionService extends android.telecom.ConnectionService { + + /** + * Tag used for logging. + */ + static final String TAG = "JitsiConnectionService"; + + /** + * The extra added to the {@link ConnectionImpl} and + * {@link ConnectionRequest} which stores the {@link PhoneAccountHandle} + * created for the call. + */ + static final String EXTRA_PHONE_ACCOUNT_HANDLE + = "org.jitsi.meet.sdk.connection_service.PHONE_ACCOUNT_HANDLE"; + + /** + * Connections mapped by call UUID. + */ + static private final Map connections + = new HashMap<>(); + + /** + * The start call Promises mapped by call UUID. + */ + static private final HashMap startCallPromises + = new HashMap<>(); + + /** + * Adds {@link ConnectionImpl} to the list. + * + * @param connection - {@link ConnectionImpl} + */ + static void addConnection(ConnectionImpl connection) { + connections.put(connection.getCallUUID(), connection); + } + + /** + * Returns all {@link ConnectionImpl} instances held in this list. + * + * @return a list of {@link ConnectionImpl}. + */ + static List getConnections() { + return new ArrayList<>(connections.values()); + } + + /** + * Registers a start call promise. + * + * @param uuid - the call UUID to which the start call promise belongs to. + * @param promise - the Promise instance to be stored for later use. + */ + static void registerStartCallPromise(String uuid, Promise promise) { + startCallPromises.put(uuid, promise); + } + + /** + * Removes {@link ConnectionImpl} from the list. + * + * @param connection - {@link ConnectionImpl} + */ + static void removeConnection(ConnectionImpl connection) { + connections.remove(connection.getCallUUID()); + } + + /** + * Used to adjusts the connection's state to + * {@link android.telecom.Connection#STATE_ACTIVE}. + * + * @param callUUID the call UUID which identifies the connection. + */ + static void setConnectionActive(String callUUID) { + ConnectionImpl connection = connections.get(callUUID); + + if (connection != null) { + connection.setActive(); + } else { + Log.e(TAG, String.format( + "setConnectionActive - no connection for UUID: %s", + callUUID)); + } + } + + /** + * Used to adjusts the connection's state to + * {@link android.telecom.Connection#STATE_DISCONNECTED}. + * + * @param callUUID the call UUID which identifies the connection. + * @param cause disconnection reason. + */ + static void setConnectionDisconnected(String callUUID, DisconnectCause cause) { + ConnectionImpl connection = connections.get(callUUID); + + if (connection != null) { + // Note that the connection is not removed from the list here, but + // in ConnectionImpl's state changed callback. It's a safer + // approach, because in case the app would crash on the JavaScript + // side the calls would be cleaned up by the system they would still + // be removed from the ConnectionList. + connection.setDisconnected(cause); + connection.destroy(); + } else { + Log.e(TAG, "endCall no connection for UUID: " + callUUID); + } + } + + /** + * Unregisters a start call promise. Must be called after the Promise is + * rejected or resolved. + * + * @param uuid the call UUID which identifies the call to which the promise + * belongs to. + * @return the unregistered Promise instance or null if there + * wasn't any for the given call UUID. + */ + static Promise unregisterStartCallPromise(String uuid) { + return startCallPromises.remove(uuid); + } + + /** + * Used to adjusts the call's state. + * + * @param callUUID the call UUID which identifies the connection. + * @param callState a map which carries the properties to be modified. See + * "KEY_*" constants in {@link ConnectionImpl} for the list of keys. + */ + static void updateCall(String callUUID, ReadableMap callState) { + ConnectionImpl connection = connections.get(callUUID); + + if (connection != null) { + if (callState.hasKey(ConnectionImpl.KEY_HAS_VIDEO)) { + boolean hasVideo + = callState.getBoolean(ConnectionImpl.KEY_HAS_VIDEO); + + Log.d(TAG, String.format( + "updateCall: %s hasVideo: %s", callUUID, hasVideo)); + connection.setVideoState( + hasVideo + ? VideoProfile.STATE_BIDIRECTIONAL + : VideoProfile.STATE_AUDIO_ONLY); + } + } else { + Log.e(TAG, "updateCall no connection for UUID: " + callUUID); + } + } + + @Override + public Connection onCreateOutgoingConnection( + PhoneAccountHandle accountHandle, ConnectionRequest request) { + ConnectionImpl connection = new ConnectionImpl(); + + connection.setConnectionProperties(Connection.PROPERTY_SELF_MANAGED); + connection.setAddress( + request.getAddress(), + TelecomManager.PRESENTATION_ALLOWED); + connection.setExtras(request.getExtras()); + // NOTE there's a time gap between the placeCall and this callback when + // things could get out of sync, but they are put back in sync once + // the startCall Promise is resolved below. That's because on + // the JavaScript side there's a logic to sync up in .then() callback. + connection.setVideoState(request.getVideoState()); + + Bundle moreExtras = new Bundle(); + + moreExtras.putParcelable( + EXTRA_PHONE_ACCOUNT_HANDLE, + Objects.requireNonNull(request.getAccountHandle(), "accountHandle")); + connection.putExtras(moreExtras); + + addConnection(connection); + + Promise startCallPromise + = unregisterStartCallPromise(connection.getCallUUID()); + + if (startCallPromise != null) { + Log.d(TAG, + "onCreateOutgoingConnection " + connection.getCallUUID()); + startCallPromise.resolve(null); + } else { + Log.e(TAG, String.format( + "onCreateOutgoingConnection: no start call Promise for %s", + connection.getCallUUID())); + } + + return connection; + } + + @Override + public Connection onCreateIncomingConnection( + PhoneAccountHandle accountHandle, ConnectionRequest request) { + throw new RuntimeException("Not implemented"); + } + + @Override + public void onCreateIncomingConnectionFailed( + PhoneAccountHandle accountHandle, ConnectionRequest request) { + throw new RuntimeException("Not implemented"); + } + + @Override + public void onCreateOutgoingConnectionFailed( + PhoneAccountHandle accountHandle, ConnectionRequest request) { + PhoneAccountHandle theAccountHandle = request.getAccountHandle(); + String callUUID = theAccountHandle.getId(); + + Log.e(TAG, "onCreateOutgoingConnectionFailed " + callUUID); + + if (callUUID != null) { + Promise startCallPromise = unregisterStartCallPromise(callUUID); + + if (startCallPromise != null) { + startCallPromise.reject( + "CREATE_OUTGOING_CALL_FAILED", + "The request has been denied by the system"); + } else { + Log.e(TAG, String.format( + "startCallFailed - no start call Promise for UUID: %s", + callUUID)); + } + } else { + Log.e(TAG, "onCreateOutgoingConnectionFailed - no call UUID"); + } + + unregisterPhoneAccount(theAccountHandle); + } + + private void unregisterPhoneAccount(PhoneAccountHandle phoneAccountHandle) { + TelecomManager telecom = getSystemService(TelecomManager.class); + if (telecom != null) { + if (phoneAccountHandle != null) { + telecom.unregisterPhoneAccount(phoneAccountHandle); + } else { + Log.e(TAG, "unregisterPhoneAccount - account handle is null"); + } + } else { + Log.e(TAG, "unregisterPhoneAccount - telecom is null"); + } + } + + /** + * Registers new {@link PhoneAccountHandle}. + * + * @param context the current Android context. + * @param address the phone account's address. At the time of this writing + * it's the call handle passed from the Java Script side. + * @param callUUID the call's UUID for which the account is to be created. + * It will be used as the account's id. + * @return {@link PhoneAccountHandle} described by the given arguments. + */ + static PhoneAccountHandle registerPhoneAccount( + Context context, Uri address, String callUUID) { + PhoneAccountHandle phoneAccountHandle + = new PhoneAccountHandle( + new ComponentName(context, ConnectionService.class), + callUUID); + + PhoneAccount.Builder builder + = PhoneAccount.builder(phoneAccountHandle, address.toString()) + .setAddress(address) + .setCapabilities(PhoneAccount.CAPABILITY_SELF_MANAGED | + PhoneAccount.CAPABILITY_VIDEO_CALLING | + PhoneAccount.CAPABILITY_SUPPORTS_VIDEO_CALLING) + .addSupportedUriScheme(PhoneAccount.SCHEME_SIP); + + PhoneAccount account = builder.build(); + + TelecomManager telecomManager + = context.getSystemService(TelecomManager.class); + telecomManager.registerPhoneAccount(account); + + return phoneAccountHandle; + } + + /** + * Connection implementation for Jitsi Meet's {@link ConnectionService}. + * + * @author Pawel Domas + */ + class ConnectionImpl extends Connection { + + /** + * The constant which defines the key for the "has video" property. + * The key is used in the map which carries the call's state passed as + * the argument of the {@link RNConnectionService#updateCall} method. + */ + static final String KEY_HAS_VIDEO = "hasVideo"; + + /** + * Called when system wants to disconnect the call. + * + * {@inheritDoc} + */ + @Override + public void onDisconnect() { + Log.d(TAG, "onDisconnect " + getCallUUID()); + WritableNativeMap data = new WritableNativeMap(); + data.putString("callUUID", getCallUUID()); + ReactContextUtils.emitEvent( + null, + "org.jitsi.meet:features/connection_service#disconnect", + data); + // The JavaScript side will not go back to the native with + // 'endCall', so the Connection must be removed immediately. + setConnectionDisconnected( + getCallUUID(), + new DisconnectCause(DisconnectCause.LOCAL)); + } + + /** + * Called when system wants to abort the call. + * + * {@inheritDoc} + */ + @Override + public void onAbort() { + Log.d(TAG, "onAbort " + getCallUUID()); + WritableNativeMap data = new WritableNativeMap(); + data.putString("callUUID", getCallUUID()); + ReactContextUtils.emitEvent( + null, + "org.jitsi.meet:features/connection_service#abort", + data); + // The JavaScript side will not go back to the native with + // 'endCall', so the Connection must be removed immediately. + setConnectionDisconnected( + getCallUUID(), + new DisconnectCause(DisconnectCause.CANCELED)); + } + + @Override + public void onHold() { + // What ?! Android will still call this method even if we do not add + // the HOLD capability, so do the same thing as on abort. + // TODO implement HOLD + Log.d(TAG, String.format( + "onHold %s - HOLD is not supported, aborting the call...", + getCallUUID())); + this.onAbort(); + } + + /** + * Called when there's change to the call audio state. Either by + * the system after the connection is initialized or in response to + * {@link #setAudioRoute(int)}. + * + * @param state the new {@link CallAudioState} + */ + @Override + public void onCallAudioStateChanged(CallAudioState state) { + Log.d(TAG, "onCallAudioStateChanged: " + state); + AudioModeModule audioModeModule + = ReactInstanceManagerHolder + .getNativeModule(AudioModeModule.class); + if (audioModeModule != null) { + audioModeModule.onCallAudioStateChange(state); + } + } + + /** + * Unregisters the account when the call is disconnected. + * + * @param state - the new connection's state. + */ + @Override + public void onStateChanged(int state) { + Log.d(TAG, + String.format("onStateChanged: %s %s", + Connection.stateToString(state), + getCallUUID())); + + if (state == STATE_DISCONNECTED) { + removeConnection(this); + unregisterPhoneAccount(getPhoneAccountHandle()); + } + } + + /** + * Retrieves the UUID of the call associated with this connection. + * + * @return call UUID + */ + String getCallUUID() { + return getPhoneAccountHandle().getId(); + } + + private PhoneAccountHandle getPhoneAccountHandle() { + return getExtras().getParcelable( + ConnectionService.EXTRA_PHONE_ACCOUNT_HANDLE); + } + + @Override + public String toString() { + return String.format( + "ConnectionImpl[adress=%s, uuid=%s]@%d", + getAddress(), getCallUUID(), hashCode()); + } + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/RNConnectionService.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/RNConnectionService.java new file mode 100644 index 000000000..15f16a76e --- /dev/null +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/RNConnectionService.java @@ -0,0 +1,165 @@ +package org.jitsi.meet.sdk; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.support.annotation.RequiresApi; +import android.telecom.DisconnectCause; +import android.telecom.PhoneAccount; +import android.telecom.PhoneAccountHandle; +import android.telecom.TelecomManager; +import android.telecom.VideoProfile; +import android.util.Log; + +import com.facebook.react.bridge.Promise; +import com.facebook.react.bridge.ReactApplicationContext; +import com.facebook.react.bridge.ReactContextBaseJavaModule; +import com.facebook.react.bridge.ReactMethod; +import com.facebook.react.bridge.ReadableMap; + +/** + * The react-native side of Jitsi Meet's {@link ConnectionService}. Exposes + * the Java Script API. + * + * @author Pawel Domas + */ +@RequiresApi(api = Build.VERSION_CODES.O) +class RNConnectionService + extends ReactContextBaseJavaModule { + + private final static String TAG = ConnectionService.TAG; + + /** + * Sets the audio route on all existing {@link android.telecom.Connection}s + * + * @param audioRoute the new audio route to be set. See + * {@link android.telecom.CallAudioState} constants prefixed with "ROUTE_". + */ + @RequiresApi(api = Build.VERSION_CODES.O) + static void setAudioRoute(int audioRoute) { + for (ConnectionService.ConnectionImpl c + : ConnectionService.getConnections()) { + c.setAudioRoute(audioRoute); + } + } + + RNConnectionService(ReactApplicationContext reactContext) { + super(reactContext); + } + + /** + * Starts a new outgoing call. + * + * @param callUUID - unique call identifier assigned by Jitsi Meet to + * a conference call. + * @param handle - a call handle which by default is Jitsi Meet room's URL. + * @param hasVideo - whether or not user starts with the video turned on. + * @param promise - the Promise instance passed by the React-native bridge, + * so that this method returns a Promise on the JS side. + * + * NOTE regarding the "missingPermission" suppress - SecurityException will + * be handled as part of the Exception try catch block and the Promise will + * be rejected. + */ + @SuppressLint("MissingPermission") + @ReactMethod + public void startCall( + String callUUID, + String handle, + boolean hasVideo, + Promise promise) { + Log.d(TAG, + String.format("startCall UUID=%s, h=%s, v=%s", + callUUID, + handle, + hasVideo)); + + ReactApplicationContext ctx = getReactApplicationContext(); + + Uri address = Uri.fromParts(PhoneAccount.SCHEME_SIP, handle, null); + PhoneAccountHandle accountHandle + = ConnectionService.registerPhoneAccount( + getReactApplicationContext(), address, callUUID); + + Bundle extras = new Bundle(); + extras.putParcelable( + TelecomManager.EXTRA_PHONE_ACCOUNT_HANDLE, + accountHandle); + extras.putInt( + TelecomManager.EXTRA_START_CALL_WITH_VIDEO_STATE, + hasVideo + ? VideoProfile.STATE_BIDIRECTIONAL + : VideoProfile.STATE_AUDIO_ONLY); + + ConnectionService.registerStartCallPromise(callUUID, promise); + + try { + TelecomManager tm + = (TelecomManager) ctx.getSystemService( + Context.TELECOM_SERVICE); + + tm.placeCall(address, extras); + } catch (Exception e) { + ConnectionService.unregisterStartCallPromise(callUUID); + promise.reject(e); + } + } + + /** + * Called by the JS side of things to mark the call as failed. + * + * @param callUUID - the call's UUID. + */ + @ReactMethod + public void reportCallFailed(String callUUID) { + Log.d(TAG, "reportCallFailed " + callUUID); + ConnectionService.setConnectionDisconnected( + callUUID, + new DisconnectCause(DisconnectCause.ERROR)); + } + + /** + * Called by the JS side of things to mark the call as disconnected. + * + * @param callUUID - the call's UUID. + */ + @ReactMethod + public void endCall(String callUUID) { + Log.d(TAG, "endCall " + callUUID); + ConnectionService.setConnectionDisconnected( + callUUID, + new DisconnectCause(DisconnectCause.LOCAL)); + } + + /** + * Called by the JS side of things to mark the call as active. + * + * @param callUUID - the call's UUID. + */ + @ReactMethod + public void reportConnectedOutgoingCall(String callUUID) { + Log.d(TAG, "reportConnectedOutgoingCall " + callUUID); + ConnectionService.setConnectionActive(callUUID); + } + + @Override + public String getName() { + return "ConnectionService"; + } + + /** + * Called by the JS side to update the call's state. + * + * @param callUUID - the call's UUID. + * @param callState - the map which carries infor about the current call's + * state. See static fields in {@link ConnectionService.ConnectionImpl} + * prefixed with "KEY_" for the values supported by the Android + * implementation. + */ + @ReactMethod + public void updateCall(String callUUID, ReadableMap callState) { + ConnectionService.updateCall(callUUID, callState); + } +} diff --git a/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactInstanceManagerHolder.java b/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactInstanceManagerHolder.java index 9fe88a014..6b81641ac 100644 --- a/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactInstanceManagerHolder.java +++ b/android/sdk/src/main/java/org/jitsi/meet/sdk/ReactInstanceManagerHolder.java @@ -25,11 +25,17 @@ import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.common.LifecycleState; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; class ReactInstanceManagerHolder { /** + * FIXME (from linter): Do not place Android context classes in static + * fields (static reference to ReactInstanceManager which has field + * mApplicationContext pointing to Context); this is a memory leak (and + * also breaks Instant Run). + * * React Native bridge. The instance manager allows embedding applications * to create multiple root views off the same JavaScript bundle. */ @@ -37,19 +43,26 @@ class ReactInstanceManagerHolder { private static List createNativeModules( ReactApplicationContext reactContext) { - return Arrays.asList( - new AndroidSettingsModule(reactContext), - new AppInfoModule(reactContext), - new AudioModeModule(reactContext), - new ExternalAPIModule(reactContext), - new LocaleDetector(reactContext), - new PictureInPictureModule(reactContext), - new ProximityModule(reactContext), - new WiFiStatsModule(reactContext), - new org.jitsi.meet.sdk.dropbox.Dropbox(reactContext), - new org.jitsi.meet.sdk.invite.InviteModule(reactContext), - new org.jitsi.meet.sdk.net.NAT64AddrInfoModule(reactContext) - ); + List nativeModules + = new ArrayList<>(Arrays.asList( + new AndroidSettingsModule(reactContext), + new AppInfoModule(reactContext), + new AudioModeModule(reactContext), + new ExternalAPIModule(reactContext), + new LocaleDetector(reactContext), + new PictureInPictureModule(reactContext), + new ProximityModule(reactContext), + new WiFiStatsModule(reactContext), + new org.jitsi.meet.sdk.dropbox.Dropbox(reactContext), + new org.jitsi.meet.sdk.invite.InviteModule(reactContext), + new org.jitsi.meet.sdk.net.NAT64AddrInfoModule(reactContext))); + + if (android.os.Build.VERSION.SDK_INT + >= android.os.Build.VERSION_CODES.O) { + nativeModules.add(new RNConnectionService(reactContext)); + } + + return nativeModules; } /** @@ -58,7 +71,7 @@ class ReactInstanceManagerHolder { * @param eventName {@code String} containing the event name. * @param data {@code Object} optional ancillary data for the event. */ - public static boolean emitEvent( + static boolean emitEvent( String eventName, @Nullable Object data) { ReactInstanceManager reactInstanceManager diff --git a/ios/sdk/sdk.xcodeproj/project.pbxproj b/ios/sdk/sdk.xcodeproj/project.pbxproj index a10eceba3..19c00f237 100644 --- a/ios/sdk/sdk.xcodeproj/project.pbxproj +++ b/ios/sdk/sdk.xcodeproj/project.pbxproj @@ -73,7 +73,7 @@ 0BB9AD781F5EC6D7001C08DB /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; 0BB9AD7A1F5EC8F4001C08DB /* CallKit.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = CallKit.m; sourceTree = ""; }; 0BB9AD7C1F60356D001C08DB /* AppInfo.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppInfo.m; sourceTree = ""; }; - 0BC4B8681F8C01E100CE8B21 /* CallKitIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = CallKitIcon.png; path = ../../react/features/mobile/callkit/CallKitIcon.png; sourceTree = ""; }; + 0BC4B8681F8C01E100CE8B21 /* CallKitIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = CallKitIcon.png; path = ../../react/features/mobile/call-integration/CallKitIcon.png; sourceTree = ""; }; 0BCA495C1EC4B6C600B793EE /* AudioMode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AudioMode.m; sourceTree = ""; }; 0BCA495D1EC4B6C600B793EE /* POSIX.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = POSIX.m; sourceTree = ""; }; 0BCA495E1EC4B6C600B793EE /* Proximity.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Proximity.m; sourceTree = ""; }; diff --git a/react/features/app/components/App.native.js b/react/features/app/components/App.native.js index 4e1ecf170..1354627b6 100644 --- a/react/features/app/components/App.native.js +++ b/react/features/app/components/App.native.js @@ -15,7 +15,7 @@ import { import '../../google-api'; import '../../mobile/audio-mode'; import '../../mobile/background'; -import '../../mobile/callkit'; +import '../../mobile/call-integration'; import '../../mobile/external-api'; import '../../mobile/full-screen'; import '../../mobile/permissions'; diff --git a/react/features/mobile/callkit/CallKit.js b/react/features/mobile/call-integration/CallKit.js similarity index 60% rename from react/features/mobile/callkit/CallKit.js rename to react/features/mobile/call-integration/CallKit.js index ed00c24eb..9b3f76830 100644 --- a/react/features/mobile/callkit/CallKit.js +++ b/react/features/mobile/call-integration/CallKit.js @@ -1,5 +1,7 @@ import { NativeModules, NativeEventEmitter } from 'react-native'; +import { getName } from '../../app'; + /** * Thin wrapper around Apple's CallKit functionality. * @@ -32,7 +34,32 @@ if (CallKit) { CallKit = { ...CallKit, - addListener: eventEmitter.addListener.bind(eventEmitter) + addListener: eventEmitter.addListener.bind(eventEmitter), + registerSubscriptions(context, delegate) { + CallKit.setProviderConfiguration({ + iconTemplateImageName: 'CallKitIcon', + localizedName: getName() + }); + + return [ + CallKit.addListener( + 'performEndCallAction', + delegate._onPerformEndCallAction, + context), + CallKit.addListener( + 'performSetMutedCallAction', + delegate._onPerformSetMutedCallAction, + context), + + // According to CallKit's documentation, when the system resets + // we should terminate all calls. Hence, providerDidReset is + // the same to us as performEndCallAction. + CallKit.addListener( + 'providerDidReset', + delegate._onPerformEndCallAction, + context) + ]; + } }; } diff --git a/react/features/mobile/callkit/CallKitIcon.png b/react/features/mobile/call-integration/CallKitIcon.png similarity index 100% rename from react/features/mobile/callkit/CallKitIcon.png rename to react/features/mobile/call-integration/CallKitIcon.png diff --git a/react/features/mobile/call-integration/ConnectionService.js b/react/features/mobile/call-integration/ConnectionService.js new file mode 100644 index 000000000..32d82c08e --- /dev/null +++ b/react/features/mobile/call-integration/ConnectionService.js @@ -0,0 +1,33 @@ +import { NativeEventEmitter, NativeModules } from 'react-native'; + +let ConnectionService = NativeModules.ConnectionService; + +// XXX Rather than wrapping ConnectionService in a new class and forwarding +// the many methods of the latter to the former, add the one additional +// method that we need to ConnectionService. +if (ConnectionService) { + const eventEmitter = new NativeEventEmitter(ConnectionService); + + ConnectionService = { + ...ConnectionService, + addListener: eventEmitter.addListener.bind(eventEmitter), + registerSubscriptions(context, delegate) { + return [ + ConnectionService.addListener( + 'org.jitsi.meet:features/connection_service#disconnect', + delegate._onPerformEndCallAction, + context), + ConnectionService.addListener( + 'org.jitsi.meet:features/connection_service#abort', + delegate._onPerformEndCallAction, + context) + ]; + }, + setMuted() { + // Currently no-op, but remember to remove when implemented on + // the native side + } + }; +} + +export default ConnectionService; diff --git a/react/features/mobile/call-integration/actionTypes.js b/react/features/mobile/call-integration/actionTypes.js new file mode 100644 index 000000000..c06ebec7b --- /dev/null +++ b/react/features/mobile/call-integration/actionTypes.js @@ -0,0 +1,13 @@ +/** + * The type of redux action to set CallKit's and ConnectionService's event + * subscriptions. + * + * { + * type: _SET_CALL_INTEGRATION_SUBSCRIPTIONS, + * subscriptions: Array|undefined + * } + * + * @protected + */ +export const _SET_CALL_INTEGRATION_SUBSCRIPTIONS + = Symbol('_SET_CALL_INTEGRATION_SUBSCRIPTIONS'); diff --git a/react/features/mobile/callkit/index.js b/react/features/mobile/call-integration/index.js similarity index 100% rename from react/features/mobile/callkit/index.js rename to react/features/mobile/call-integration/index.js diff --git a/react/features/mobile/callkit/middleware.js b/react/features/mobile/call-integration/middleware.js similarity index 81% rename from react/features/mobile/callkit/middleware.js rename to react/features/mobile/call-integration/middleware.js index 6c1ed9d08..72933a1e3 100644 --- a/react/features/mobile/callkit/middleware.js +++ b/react/features/mobile/call-integration/middleware.js @@ -1,9 +1,10 @@ // @flow +import { Alert } from 'react-native'; import uuid from 'uuid'; import { createTrackMutedEvent, sendAnalytics } from '../../analytics'; -import { appNavigate, getName } from '../../app'; +import { appNavigate } from '../../app'; import { APP_WILL_MOUNT, APP_WILL_UNMOUNT } from '../../base/app'; import { CONFERENCE_FAILED, @@ -27,8 +28,12 @@ import { isLocalTrackMuted } from '../../base/tracks'; -import { _SET_CALLKIT_SUBSCRIPTIONS } from './actionTypes'; +import { _SET_CALL_INTEGRATION_SUBSCRIPTIONS } from './actionTypes'; + import CallKit from './CallKit'; +import ConnectionService from './ConnectionService'; + +const CallIntegration = CallKit || ConnectionService; /** * Middleware that captures system actions and hooks up CallKit. @@ -36,9 +41,9 @@ import CallKit from './CallKit'; * @param {Store} store - The redux store. * @returns {Function} */ -CallKit && MiddlewareRegistry.register(store => next => action => { +CallIntegration && MiddlewareRegistry.register(store => next => action => { switch (action.type) { - case _SET_CALLKIT_SUBSCRIPTIONS: + case _SET_CALL_INTEGRATION_SUBSCRIPTIONS: return _setCallKitSubscriptions(store, next, action); case APP_WILL_MOUNT: @@ -46,7 +51,7 @@ CallKit && MiddlewareRegistry.register(store => next => action => { case APP_WILL_UNMOUNT: store.dispatch({ - type: _SET_CALLKIT_SUBSCRIPTIONS, + type: _SET_CALL_INTEGRATION_SUBSCRIPTIONS, subscriptions: undefined }); break; @@ -91,36 +96,21 @@ CallKit && MiddlewareRegistry.register(store => next => action => { function _appWillMount({ dispatch, getState }, next, action) { const result = next(action); - CallKit.setProviderConfiguration({ - iconTemplateImageName: 'CallKitIcon', - localizedName: getName() - }); - const context = { dispatch, getState }; - const subscriptions = [ - CallKit.addListener( - 'performEndCallAction', - _onPerformEndCallAction, - context), - CallKit.addListener( - 'performSetMutedCallAction', - _onPerformSetMutedCallAction, - context), - // According to CallKit's documentation, when the system resets we - // should terminate all calls. Hence, providerDidReset is the same to us - // as performEndCallAction. - CallKit.addListener( - 'providerDidReset', - _onPerformEndCallAction, - context) - ]; + const delegate = { + _onPerformSetMutedCallAction, + _onPerformEndCallAction + }; - dispatch({ - type: _SET_CALLKIT_SUBSCRIPTIONS, + const subscriptions + = CallIntegration.registerSubscriptions(context, delegate); + + subscriptions && dispatch({ + type: _SET_CALL_INTEGRATION_SUBSCRIPTIONS, subscriptions }); @@ -150,7 +140,7 @@ function _conferenceFailed(store, next, action) { const { callUUID } = action.conference; if (callUUID) { - CallKit.reportCallFailed(callUUID); + CallIntegration.reportCallFailed(callUUID); } } @@ -176,7 +166,7 @@ function _conferenceJoined(store, next, action) { const { callUUID } = action.conference; if (callUUID) { - CallKit.reportConnectedOutgoingCall(callUUID); + CallIntegration.reportConnectedOutgoingCall(callUUID); } return result; @@ -201,7 +191,7 @@ function _conferenceLeft(store, next, action) { const { callUUID } = action.conference; if (callUUID) { - CallKit.endCall(callUUID); + CallIntegration.endCall(callUUID); } return result; @@ -220,7 +210,7 @@ function _conferenceLeft(store, next, action) { * @private * @returns {*} The value returned by {@code next(action)}. */ -function _conferenceWillJoin({ getState }, next, action) { +function _conferenceWillJoin({ dispatch, getState }, next, action) { const result = next(action); const { conference } = action; @@ -234,7 +224,7 @@ function _conferenceWillJoin({ getState }, next, action) { // it upper cased. conference.callUUID = (callUUID || uuid.v4()).toUpperCase(); - CallKit.startCall(conference.callUUID, handle, hasVideo) + CallIntegration.startCall(conference.callUUID, handle, hasVideo) .then(() => { const { callee } = state['features/base/jwt']; const displayName @@ -247,9 +237,30 @@ function _conferenceWillJoin({ getState }, next, action) { state['features/base/tracks'], MEDIA_TYPE.AUDIO); - // eslint-disable-next-line object-property-newline - CallKit.updateCall(conference.callUUID, { displayName, hasVideo }); - CallKit.setMuted(conference.callUUID, muted); + CallIntegration.updateCall( + conference.callUUID, + { + displayName, + hasVideo + }); + CallIntegration.setMuted(conference.callUUID, muted); + }) + .catch(error => { + // Currently this error code is emitted only by Android. + if (error.code === 'CREATE_OUTGOING_CALL_FAILED') { + // We're not tracking the call anymore - it doesn't exist on + // the native side. + delete conference.callUUID; + dispatch(appNavigate(undefined)); + Alert.alert( + 'Call aborted', + 'There\'s already another call in progress.' + + ' Please end it first and try again.', + [ + { text: 'OK' } + ], + { cancelable: false }); + } }); return result; @@ -288,7 +299,8 @@ function _onPerformSetMutedCallAction({ callUUID, muted }) { if (conference && conference.callUUID === callUUID) { muted = Boolean(muted); // eslint-disable-line no-param-reassign - sendAnalytics(createTrackMutedEvent('audio', 'callkit', muted)); + sendAnalytics( + createTrackMutedEvent('audio', 'call-integration', muted)); dispatch(setAudioMuted(muted, /* ensureTrack */ true)); } } @@ -319,7 +331,7 @@ function _setAudioOnly({ getState }, next, action) { const conference = getCurrentConference(state); if (conference && conference.callUUID) { - CallKit.updateCall( + CallIntegration.updateCall( conference.callUUID, { hasVideo: !action.audioOnly }); } @@ -329,20 +341,21 @@ function _setAudioOnly({ getState }, next, action) { /** * Notifies the feature callkit that the action - * {@link _SET_CALLKIT_SUBSCRIPTIONS} is being dispatched within a specific - * redux {@code store}. + * {@link _SET_CALL_INTEGRATION_SUBSCRIPTIONS} is being dispatched within + * a specific redux {@code store}. * * @param {Store} store - The redux store in which the specified {@code action} * is being dispatched. * @param {Dispatch} next - The redux {@code dispatch} function to dispatch the * specified {@code action} in the specified {@code store}. - * @param {Action} action - The redux action {@code _SET_CALLKIT_SUBSCRIPTIONS} - * which is being dispatched in the specified {@code store}. + * @param {Action} action - The redux action + * {@code _SET_CALL_INTEGRATION_SUBSCRIPTIONS} which is being dispatched in + * the specified {@code store}. * @private * @returns {*} The value returned by {@code next(action)}. */ function _setCallKitSubscriptions({ getState }, next, action) { - const { subscriptions } = getState()['features/callkit']; + const { subscriptions } = getState()['features/call-integration']; if (subscriptions) { for (const subscription of subscriptions) { @@ -377,11 +390,11 @@ function _syncTrackState({ getState }, next, action) { const tracks = state['features/base/tracks']; const muted = isLocalTrackMuted(tracks, MEDIA_TYPE.AUDIO); - CallKit.setMuted(conference.callUUID, muted); + CallIntegration.setMuted(conference.callUUID, muted); break; } case 'video': { - CallKit.updateCall( + CallIntegration.updateCall( conference.callUUID, { hasVideo: !isVideoMutedByAudioOnly(state) }); break; diff --git a/react/features/mobile/callkit/reducer.js b/react/features/mobile/call-integration/reducer.js similarity index 50% rename from react/features/mobile/callkit/reducer.js rename to react/features/mobile/call-integration/reducer.js index 320685a1f..2983827e1 100644 --- a/react/features/mobile/callkit/reducer.js +++ b/react/features/mobile/call-integration/reducer.js @@ -1,13 +1,14 @@ import { assign, ReducerRegistry } from '../../base/redux'; -import { _SET_CALLKIT_SUBSCRIPTIONS } from './actionTypes'; +import { _SET_CALL_INTEGRATION_SUBSCRIPTIONS } from './actionTypes'; import CallKit from './CallKit'; +import ConnectionService from './ConnectionService'; -CallKit && ReducerRegistry.register( - 'features/callkit', +(CallKit || ConnectionService) && ReducerRegistry.register( + 'features/call-integration', (state = {}, action) => { switch (action.type) { - case _SET_CALLKIT_SUBSCRIPTIONS: + case _SET_CALL_INTEGRATION_SUBSCRIPTIONS: return assign(state, 'subscriptions', action.subscriptions); } diff --git a/react/features/mobile/callkit/actionTypes.js b/react/features/mobile/callkit/actionTypes.js deleted file mode 100644 index d1109d77c..000000000 --- a/react/features/mobile/callkit/actionTypes.js +++ /dev/null @@ -1,11 +0,0 @@ -/** - * The type of redux action to set CallKit's event subscriptions. - * - * { - * type: _SET_CALLKIT_SUBSCRIPTIONS, - * subscriptions: Array|undefined - * } - * - * @protected - */ -export const _SET_CALLKIT_SUBSCRIPTIONS = Symbol('_SET_CALLKIT_SUBSCRIPTIONS');