Skip to content

Commit 1678b8d

Browse files
feat: add Twilio AudioSwitch to Android
1 parent 555d832 commit 1678b8d

File tree

7 files changed

+140
-96
lines changed

7 files changed

+140
-96
lines changed

README.md

Lines changed: 39 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ Tested with:
1616

1717
The most updated branch is [feat/twilio-android-sdk-5](https://github.com/hoxfon/react-native-twilio-programmable-voice/tree/feat/twilio-android-sdk-5) which is aligned with:
1818

19-
- Android 5.1.1
19+
- Android 5.2.0
2020
- iOS 5.2.0
2121

2222
It contains breaking changes from `react-native-twilio-programmable-voice` v4, and it will be released as v5.
@@ -54,17 +54,19 @@ Allow Android to use the built in Android telephony service to make and receive
5454
- Android 4.5.0
5555
- iOS 5.2.0
5656

57-
5857
### Breaking changes in v5.0.0
5958

60-
Changes on [Android Twilio Voice SDK v5](https://www.twilio.com/docs/voice/voip-sdk/android/3x-changelog#500) are reflected in the JavaScript API, the way call invites are handled and ...
59+
Changes on [Android Twilio Voice SDK v5](https://www.twilio.com/docs/voice/voip-sdk/android/3x-changelog#500) are reflected in the JavaScript API, the way call invites are handled has changed and other v5 features like `audioSwitch` have been implemented.
60+
`setSpeakerPhone()` has been removed from Android, use selectAudioDevice(name: string) instead.
61+
62+
#### Background incoming calls
6163

62-
- when the app is not in foreground incoming calls result in a heads-up notification with action to "ACCEPT" and "REJECT"
63-
- ReactMethod `accept` does not dispatch any event. Previously it would dispatch `connectionDidDisconnect`
64-
- ReactMethod `reject` dispatch a `callInviteCancelled` event instead of `connectionDidDisconnect`
65-
- ReactMethod `ignore` does not dispatch any event. Previously it would dispatch `connectionDidDisconnect`
64+
- When the app is not in foreground incoming calls result in a heads-up notification with action to "ACCEPT" and "REJECT".
65+
- ReactMethod `accept` does not dispatch any event. In v4 it dispatched `connectionDidDisconnect`.
66+
- ReactMethod `reject` dispatches a `callInviteCancelled` event instead of `connectionDidDisconnect`.
67+
- ReactMethod `ignore` does not dispatch any event. In v4 it dispatched `connectionDidDisconnect`.
6668

67-
To allow the library to show heads up notifications you must add the following lines to your application `android/app/src/main/AndroidManifest.xml`:
69+
To show heads up notifications, you must add the following lines to your application's `android/app/src/main/AndroidManifest.xml`:
6870

6971
```xml
7072
<!-- receive calls when the app is in the background-->
@@ -97,22 +99,22 @@ To allow the library to show heads up notifications you must add the following l
9799
</application>
98100
```
99101

100-
Firebase Messaging 19.0.+ is imported by this module, so there is no need to import it in your app.
102+
Firebase Messaging 19.0.+ is imported by this module, so there is no need to import it in your app's `bundle.gradle` file.
101103

102-
Previously, in order to launch the app when receiving a call, the flow was:
104+
In v4 the flow to launch the app when receiving a call was:
103105

104-
1. the module would launch the app
105-
2. after the React app is initialised, it would always ask to the native module whether there were incoming call invites
106-
3. if there where any incoming call invites the module would send an event to the React app with the incoming call invite parameters
107-
4. the Reach app would listen to the event and launch the view with the appropriate incoming call answer/reject controls
106+
1. the module launched the app
107+
2. after the React app is initialised, it always asked to the native module whether there were incoming call invites
108+
3. if there were any incoming call invites, the module would have sent an event to the React app with the incoming call invite parameters
109+
4. the Reach app would have listened to the event and would have launched the view with the appropriate incoming call answer/reject controls
108110

109-
This loop was long and prone to race conditions. In case the event was sent before the React main view was completely initialised, it would not be handled at all.
111+
This loop was long and prone to race conditions. For example,when the event was sent before the React main view was completely initialised, it would not be handled at all.
110112

111-
Version 5.0.0 replaces the previous flow by using `getLaunchOptions()` to pass initial properties from native to React when receiving a call invite as explained here: https://reactnative.dev/docs/communication-android.
113+
V5 replaces the previous flow by using `getLaunchOptions()` to pass initial properties from the native module to React, when receiving a call invite as explained here: https://reactnative.dev/docs/communication-android.
112114

113-
The React app will be launched with the initial properties `callInvite` or `call`.
115+
The React app is launched with the initial properties `callInvite` or `call`.
114116

115-
Add the following blocks to your app's `MainActivity`:
117+
To handle correctly `lauchedOptions`, you must add the following blocks to your app's `MainActivity`:
116118

117119
```java
118120

@@ -154,6 +156,25 @@ public class MainActivity extends ReactActivity {
154156
}
155157
```
156158

159+
#### Audio Switch
160+
161+
Access to native Twilio SDK AudioSwitch module for Android has been added to the JavaScript API:
162+
163+
```javascript
164+
// getAudioDevices returns all audio devices connected
165+
// {
166+
// "Speakerphone": false,
167+
// "Earnpiece": true, // true indicates the selected device
168+
// }
169+
getAudioDevices()
170+
171+
// getSelectedAudioDevice returns the selected audio device
172+
getSelectedAudioDevice()
173+
174+
// selectAudioDevice selects the passed audio device for the current active call
175+
selectAudioDevice(name: string)
176+
```
177+
157178
## ICE
158179

159180
See https://www.twilio.com/docs/stun-turn

android/build.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ buildscript {
44
repositories {
55
google()
66
jcenter()
7+
mavenCentral()
78
}
89
dependencies {
910
classpath 'com.android.tools.build:gradle:4.1.3'
@@ -55,7 +56,8 @@ dependencies {
5556
def supportLibVersion = rootProject.hasProperty('supportLibVersion') ? rootProject.supportLibVersion : DEFAULT_SUPPORT_LIB_VERSION
5657

5758
implementation fileTree(include: ['*.jar'], dir: 'libs')
58-
implementation 'com.twilio:voice-android:5.1.1'
59+
implementation 'com.twilio:audioswitch:1.1.2'
60+
implementation 'com.twilio:voice-android:5.2.0'
5961
implementation "com.android.support:appcompat-v7:$supportLibVersion"
6062
implementation 'com.facebook.react:react-native:+'
6163
implementation 'com.google.firebase:firebase-messaging:19.0.+'

android/src/main/java/com/hoxfon/react/RNTwilioVoice/Constants.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ public class Constants {
3535
public static final String CALL_KEY = "call";
3636
public static final String CALL_INVITE_KEY = "callInvite";
3737
public static final String CALL_STATE_CONNECTED = Call.State.CONNECTED.toString();
38-
}
38+
public static final String SELECTED_AUDIO_DEVICE = "selected_audio_device";
39+
}

android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ public class EventManager {
2525
public static final String EVENT_CALL_INVITE_CANCELLED = "callInviteCancelled";
2626
public static final String EVENT_CONNECTION_IS_RECONNECTING = "connectionIsReconnecting";
2727
public static final String EVENT_CONNECTION_DID_RECONNECT = "connectionDidReconnect";
28-
28+
public static final String EVENT_AUDIO_DEVICES_UPDATED = "audioDevicesUpdated";
2929

3030
public EventManager(ReactApplicationContext context) {
3131
mContext = context;

android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java

Lines changed: 61 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -9,20 +9,17 @@
99
import android.content.IntentFilter;
1010
import android.content.SharedPreferences;
1111
import android.content.pm.PackageManager;
12-
import android.media.AudioAttributes;
13-
import android.media.AudioFocusRequest;
1412
import android.media.AudioManager;
1513
import android.os.Build;
1614

1715
import androidx.annotation.NonNull;
1816
import androidx.core.app.ActivityCompat;
1917
import androidx.core.content.ContextCompat;
2018
import androidx.localbroadcastmanager.content.LocalBroadcastManager;
19+
import kotlin.Unit;
2120

2221
import android.os.Bundle;
2322
import android.util.Log;
24-
import android.view.Window;
25-
import android.view.WindowManager;
2623

2724
import com.facebook.react.bridge.JSApplicationIllegalArgumentException;
2825
import com.facebook.react.bridge.AssertionException;
@@ -41,6 +38,9 @@
4138
import com.facebook.react.bridge.ReactMethod;
4239

4340
import com.google.firebase.iid.FirebaseInstanceId;
41+
42+
import com.twilio.audioswitch.AudioDevice;
43+
import com.twilio.audioswitch.AudioSwitch;
4444
import com.twilio.voice.AcceptOptions;
4545
import com.twilio.voice.Call;
4646
import com.twilio.voice.CallException;
@@ -54,6 +54,7 @@
5454

5555
import java.util.HashMap;
5656
import java.util.Map;
57+
import java.util.List;
5758

5859
import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_CONNECT;
5960
import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_DISCONNECT;
@@ -64,16 +65,14 @@
6465
import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CALL_INVITE_CANCELLED;
6566
import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_IS_RECONNECTING;
6667
import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_RECONNECT;
68+
import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_AUDIO_DEVICES_UPDATED;
6769

6870
public class TwilioVoiceModule extends ReactContextBaseJavaModule implements ActivityEventListener, LifecycleEventListener {
6971

7072
public static String TAG = "RNTwilioVoice";
7173

7274
private static final int MIC_PERMISSION_REQUEST_CODE = 1;
7375

74-
private AudioManager audioManager;
75-
private int savedAudioMode = AudioManager.MODE_NORMAL;
76-
7776
private boolean isReceiverRegistered = false;
7877
private VoiceBroadcastReceiver voiceBroadcastReceiver;
7978

@@ -96,13 +95,20 @@ public class TwilioVoiceModule extends ReactContextBaseJavaModule implements Act
9695
private CallInvite activeCallInvite;
9796
private Call activeCall;
9897

99-
private AudioFocusRequest focusRequest;
10098
private HeadsetManager headsetManager;
10199
private EventManager eventManager;
102100
private int existingCallInviteIntent;
103101

102+
/*
103+
* Audio device management
104+
*/
105+
private AudioSwitch audioSwitch;
106+
private int savedVolumeControlStream;
107+
AudioDevice selectedAudioDevice;
108+
Map<String, AudioDevice> availableAudioDevices;
109+
104110
public TwilioVoiceModule(ReactApplicationContext reactContext,
105-
boolean shouldAskForMicPermission) {
111+
boolean shouldAskForMicPermission) {
106112
super(reactContext);
107113

108114
if (BuildConfig.DEBUG) {
@@ -127,10 +133,8 @@ public TwilioVoiceModule(ReactApplicationContext reactContext,
127133

128134
TwilioVoiceModule.callNotificationMap = new HashMap<>();
129135

130-
/*
131-
* Needed for setting/abandoning audio focus during a call
132-
*/
133-
audioManager = (AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE);
136+
audioSwitch = new AudioSwitch(reactContext);
137+
availableAudioDevices = new HashMap<>();
134138

135139
/*
136140
* Ensure the microphone permission is enabled
@@ -142,6 +146,7 @@ public TwilioVoiceModule(ReactApplicationContext reactContext,
142146

143147
@Override
144148
public void onHostResume() {
149+
savedVolumeControlStream = getCurrentActivity().getVolumeControlStream();
145150
/*
146151
* Enable changing the volume using the up/down keys during a conversation
147152
*/
@@ -184,7 +189,11 @@ public void onHostPause() {
184189
public void onHostDestroy() {
185190
disconnect();
186191
callNotificationManager.removeHangupNotification(getReactApplicationContext());
187-
unsetAudioFocus();
192+
/*
193+
* Tear down audio device management and restore previous volume stream
194+
*/
195+
audioSwitch.stop();
196+
getCurrentActivity().setVolumeControlStream(savedVolumeControlStream);
188197
}
189198

190199
@Override
@@ -257,7 +266,7 @@ public void onConnected(@NonNull Call call) {
257266
if (BuildConfig.DEBUG) {
258267
Log.d(TAG, "Call.Listener().onConnected(). Call state: " + call.getState());
259268
}
260-
setAudioFocus();
269+
audioSwitch.activate();
261270
proximityManager.startProximitySensor();
262271
headsetManager.startWiredHeadsetEvent(getReactApplicationContext());
263272

@@ -316,7 +325,7 @@ public void onDisconnected(@NonNull Call call, CallException error) {
316325
if (BuildConfig.DEBUG) {
317326
Log.d(TAG, "Call.Listener().onDisconnected(). Call state: " + call.getState());
318327
}
319-
unsetAudioFocus();
328+
audioSwitch.deactivate();
320329
proximityManager.stopProximitySensor();
321330
headsetManager.stopWiredHeadsetEvent(getReactApplicationContext());
322331

@@ -347,11 +356,11 @@ public void onConnectFailure(@NonNull Call call, CallException error) {
347356
if (BuildConfig.DEBUG) {
348357
Log.d(TAG, "Call.Listener().onConnectFailure(). Call state: " + call.getState());
349358
}
350-
unsetAudioFocus();
359+
audioSwitch.deactivate();
351360
proximityManager.stopProximitySensor();
352361

353362
Log.e(TAG, String.format("CallListener onConnectFailure error: %d, %s",
354-
error.getErrorCode(), error.getMessage()));
363+
error.getErrorCode(), error.getMessage()));
355364

356365
WritableMap params = Arguments.createMap();
357366
params.putString(Constants.ERROR, error.getMessage());
@@ -587,6 +596,7 @@ public void initWithAccessToken(final String accessToken, Promise promise) {
587596
WritableMap params = Arguments.createMap();
588597
params.putBoolean("initialized", true);
589598
promise.resolve(params);
599+
startAudioSwitch();
590600
}
591601

592602
/*
@@ -778,73 +788,51 @@ public void getCallInvite(Promise promise) {
778788
promise.resolve(params);
779789
}
780790

781-
@ReactMethod
782-
public void setSpeakerPhone(Boolean value) {
783-
// TODO check whether it is necessary to call setAudioFocus again
784-
// setAudioFocus();
785-
audioManager.setSpeakerphoneOn(value);
786-
}
787-
788791
@ReactMethod
789792
public void setOnHold(Boolean value) {
790793
if (activeCall != null) {
791794
activeCall.hold(value);
792795
}
793796
}
794797

795-
private void setAudioFocus() {
796-
if (audioManager == null) {
797-
audioManager.setMode(savedAudioMode);
798-
audioManager.abandonAudioFocus(null);
799-
return;
800-
}
801-
savedAudioMode = audioManager.getMode();
802-
// Request audio focus before making any device switch
803-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
804-
AudioAttributes playbackAttributes = new AudioAttributes.Builder()
805-
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
806-
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
807-
.build();
808-
focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
809-
.setAudioAttributes(playbackAttributes)
810-
.setAcceptsDelayedFocusGain(true)
811-
.setOnAudioFocusChangeListener(new AudioManager.OnAudioFocusChangeListener() {
812-
@Override
813-
public void onAudioFocusChange(int i) { }
814-
})
815-
.build();
816-
audioManager.requestAudioFocus(focusRequest);
817-
} else {
818-
int focusRequestResult = audioManager.requestAudioFocus(new AudioManager.OnAudioFocusChangeListener() {
819-
@Override
820-
public void onAudioFocusChange(int focusChange) {}
821-
},
822-
AudioManager.STREAM_VOICE_CALL,
823-
AudioManager.AUDIOFOCUS_GAIN_TRANSIENT);
798+
@ReactMethod
799+
public void getAudioDevices(Promise promise) {
800+
List<AudioDevice> availableAudioDevices = audioSwitch.getAvailableAudioDevices();
801+
802+
WritableMap devices = Arguments.createMap();
803+
for (AudioDevice a : availableAudioDevices) {
804+
devices.putBoolean(a.getName(), selectedAudioDevice.getName().equals(a.getName()));
824805
}
825-
/*
826-
* Start by setting MODE_IN_COMMUNICATION as default audio mode. It is
827-
* required to be in this mode when playout and/or recording starts for
828-
* best possible VoIP performance. Some devices have difficulties with speaker mode
829-
* if this is not set.
830-
*/
831-
audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION);
806+
promise.resolve(devices);
832807
}
833808

834-
private void unsetAudioFocus() {
835-
if (audioManager == null) {
836-
audioManager.setMode(savedAudioMode);
837-
audioManager.abandonAudioFocus(null);
809+
@ReactMethod
810+
public void getSelectedAudioDevice(Promise promise) {
811+
WritableMap device = Arguments.createMap();
812+
device.putString(Constants.SELECTED_AUDIO_DEVICE, selectedAudioDevice.getName());
813+
promise.resolve(device);
814+
}
815+
816+
@ReactMethod
817+
public void selectAudioDevice(String name) {
818+
AudioDevice selected = availableAudioDevices.get(name);
819+
if (selected == null) {
838820
return;
839821
}
840-
audioManager.setMode(savedAudioMode);
841-
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
842-
if (focusRequest != null) {
843-
audioManager.abandonAudioFocusRequest(focusRequest);
822+
audioSwitch.selectDevice(selected);
823+
}
824+
825+
private void startAudioSwitch() {
826+
audioSwitch.start((devices, device) -> {
827+
selectedAudioDevice = device;
828+
WritableMap params = Arguments.createMap();
829+
for (AudioDevice a : devices) {
830+
params.putBoolean(a.getName(), device.getName().equals(a.getName()));
831+
availableAudioDevices.put(a.getName(), a);
844832
}
845-
} else {
846-
audioManager.abandonAudioFocus(null);
847-
}
833+
eventManager.sendEvent(EVENT_AUDIO_DEVICES_UPDATED, params);
834+
return Unit.INSTANCE;
835+
});
848836
}
849837

850838
private boolean checkPermissionForMicrophone() {
@@ -868,6 +856,7 @@ public static Bundle getActivityLaunchOption(Intent intent) {
868856
if (intent == null || intent.getAction() == null) {
869857
return initialProperties;
870858
}
859+
871860
Bundle callBundle = new Bundle();
872861
switch (intent.getAction()) {
873862
case Constants.ACTION_INCOMING_CALL_NOTIFICATION:

0 commit comments

Comments
 (0)