android: add ConnectionService

* feat(Android): implement ConnectionService

Adds basic integration with Android's ConnectionService by implementing
the outgoing call scenario.

* ref(callkit): rename _SET_CALLKIT_SUBSCRIPTIONS

* ref(callkit): move feature to call-integration directory

* feat(ConnectionService): synchronize video state

* ref(AudioMode): use ConnectionService on API >= 26

Not ready yet - few details left mentioned in the FIXMEs

* feat(ConnectionService): add debug logs

Adds logs to trace the calls.

* fix(ConnectionService): leaking ConnectionImpl instances

Turns out there is no callback fired back from the JavaScript side after
the disconnect or abort event is sent from the native. The connection
must be marked as disconnected and removed immediately.

* feat(ConnectionService): handle onCreateOutgoingConnectionFailed

* ref(ConnectionService): merge classes and move to the sdk package

* feat(CallIntegration): show Alert if outgoing call fails

* fix(ConnectionService): alternatively get call UUID from the account

Some Android flavours (or versions ?) do copy over extras to
the onCreateOutgoingConnectionFailed callback. But the call UUID is also
set as the PhoneAccount's label, so eventually it should be available
there.

* ref(ConnectionService): use call UUID as PhoneAccount ID.

The extra is not reliable on some custom Android flavours. It also makes
sense to use unique id for the account instead of the URL given that
it's created on the per call basis.

* fix(ConnectionService): abort the call when hold is requested

Turns out Android P can sometimes request HOLD even though there's no
HOLD capability added to the connection (what!?), so just abort the call
in that case.

* fix(ConnectionService): unregister account on call failure

Unregister the PhoneAccount onCreateOutgoingConnectionFailed. That's
before the ConnectionImpl instance is created which is normally
responsible for doing that.

* fix(AudioModeModule): make package private and run on the audio thread

* address other review comments
This commit is contained in:
Paweł Domas 2019-01-31 10:20:54 -06:00 committed by Saúl Ibarra Corretgé
parent 3ad99e24cf
commit f8294fb312
15 changed files with 944 additions and 109 deletions

View File

@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.MANAGE_OWN_CALLS"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
@ -26,5 +27,11 @@
android:supportsRtl="true">
<activity
android:name="com.facebook.react.devsupport.DevSettingsActivity" />
<service android:name="org.jitsi.meet.sdk.ConnectionService"
android:permission="android.permission.BIND_TELECOM_CONNECTION_SERVICE">
<intent-filter>
<action android:name="android.telecom.ConnectionService" />
</intent-filter>
</service>
</application>
</manifest>

View File

@ -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<String> routesToDeviceNames(int supportedRouteMask) {
Set<String> 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;
}

View File

@ -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<String, ConnectionImpl> connections
= new HashMap<>();
/**
* The start call Promises mapped by call UUID.
*/
static private final HashMap<String, Promise> 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<ConnectionImpl> 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 <tt>null</tt> 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());
}
}
}

View File

@ -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);
}
}

View File

@ -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<NativeModule> createNativeModules(
ReactApplicationContext reactContext) {
return Arrays.<NativeModule>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<NativeModule> nativeModules
= new ArrayList<>(Arrays.<NativeModule>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

View File

@ -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 = "<group>"; };
0BB9AD7C1F60356D001C08DB /* AppInfo.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AppInfo.m; sourceTree = "<group>"; };
0BC4B8681F8C01E100CE8B21 /* CallKitIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = CallKitIcon.png; path = ../../react/features/mobile/callkit/CallKitIcon.png; sourceTree = "<group>"; };
0BC4B8681F8C01E100CE8B21 /* CallKitIcon.png */ = {isa = PBXFileReference; lastKnownFileType = image.png; name = CallKitIcon.png; path = ../../react/features/mobile/call-integration/CallKitIcon.png; sourceTree = "<group>"; };
0BCA495C1EC4B6C600B793EE /* AudioMode.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = AudioMode.m; sourceTree = "<group>"; };
0BCA495D1EC4B6C600B793EE /* POSIX.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = POSIX.m; sourceTree = "<group>"; };
0BCA495E1EC4B6C600B793EE /* Proximity.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = Proximity.m; sourceTree = "<group>"; };

View File

@ -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';

View File

@ -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)
];
}
};
}

View File

Before

Width:  |  Height:  |  Size: 260 B

After

Width:  |  Height:  |  Size: 260 B

View File

@ -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;

View File

@ -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');

View File

@ -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;

View File

@ -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);
}

View File

@ -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');