diff --git a/CHANGELOG.md b/CHANGELOG.md index 3004414e..135314d0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,14 +3,23 @@ ## 4.0.0 - Android + - implement new autolinking react native API - update Firebase Messaging to 17.3.4 which simplifies how to obtain the FCM token - Android X migration - use gradle 5.4.1 - use API 28 - - upgrade com.twilio:voice-android to 2.1.0 + - upgrade com.twilio:voice-android to 3.3.0 + - implement `hold` to hold a call + - new event `callInviteCancelled` + - new event `callStateRinging` + - new method `getCallInvite` + - implement call ringing Twilio event + - remove `call_state` from CallInvite - iOS - - convert params for connectionDidConnect to => call_to, from => call_from - - convert params for connectionDidDisconnect to => call_to, from => call_from, error => err + - convert params for `connectionDidConnect` to => `call_to`, from => `call_from` + - convert params for `connectionDidDisconnect` to => `call_to`, from => `call_from`, `error` => `err` + +- throw an error when listening to events that do not exist ## 3.21.3 diff --git a/README.md b/README.md index 31b49f5f..88ff2846 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ This is a React Native wrapper for Twilio Programmable Voice SDK that lets you m # Twilio Programmable Voice SDK -- Android 2.1.0 (bundled within this library) +- Android 3.3.0 (bundled within this library) - iOS 2.1.0 (specified by the app's own podfile) ## Breaking changes in v4.0.0 @@ -21,12 +21,24 @@ This is a React Native wrapper for Twilio Programmable Voice SDK that lets you m ``` +Data passed to the event `deviceDidReceiveIncoming` does not contain the key `call_state`, because state of Call Invites was removed in Twilio Android v3.0.0 + - iOS: params changes for `connectionDidConnect` and `connectionDidDisconnect` to => call_to from => call_from error => err +New features + +Twilio Programmable Voice SDK v3.0.0 handles call invites directly and makes it easy to distinguish a call invites from an active call, which previously was confusing. +To ensure that an active call is displayed when the app comes to foreground you should use the promise `getActiveCall()`. +To ensure that a call invite is displayed when the app comes to foreground use the promise `getCallInvite()`. Please note that call invites don't have a `call_state` field. + +You should use `hold()` to put a call on hold. + +You can be notified when a call is `ringing` by listening for `callStateRinging` events. + ## Breaking changes in v3.0.0 - initWitToken returns an object with a property `initialized` instead of `initilized` @@ -251,44 +263,40 @@ TwilioVoice.addEventListener('deviceNotReady', function(data) { // } }) TwilioVoice.addEventListener('connectionDidConnect', function(data) { - // Android // { // call_sid: string, // Twilio call sid - // call_state: 'PENDING' | 'CONNECTED' | 'ACCEPTED' | 'CONNECTING' 'DISCONNECTED' | 'CANCELLED', + // call_state: 'PENDING' | 'CONNECTED' | 'ACCEPTED' | 'CONNECTING' | 'RINGING' | 'DISCONNECTED' | 'CANCELLED', // call_from: string, // "+441234567890" // call_to: string, // "client:bob" // } - // iOS - // { - // call_sid: string, // Twilio call sid - // call_state: 'PENDING' | 'CONNECTED' | 'ACCEPTED' | 'CONNECTING' 'DISCONNECTED' | 'CANCELLED', - // from: string, // "+441234567890" // issue 44 (https://github.com/hoxfon/react-native-twilio-programmable-voice/issues/44) - // to: string, // "client:bob" // issue 44 (https://github.com/hoxfon/react-native-twilio-programmable-voice/issues/44) - // } }) TwilioVoice.addEventListener('connectionDidDisconnect', function(data: mixed) { // | null // | { // err: string // } - // | Android - // { + // | { // call_sid: string, // Twilio call sid - // call_state: 'PENDING' | 'CONNECTED' | 'ACCEPTED' | 'CONNECTING' 'DISCONNECTED' | 'CANCELLED', + // call_state: 'PENDING' | 'CONNECTED' | 'ACCEPTED' | 'CONNECTING' | 'RINGING' | 'DISCONNECTED' | 'CANCELLED', // call_from: string, // "+441234567890" // call_to: string, // "client:bob" // err?: string, // } - // | iOS - // { - // call_sid: string, // Twilio call sid - // call_state: 'PENDING' | 'CONNECTED' | 'ACCEPTED' | 'CONNECTING' 'DISCONNECTED' | 'CANCELLED', - // call_from?: string, // "+441234567890" - // call_to?: string, // "client:bob" - // from?: string, // "+441234567890" // issue 44 (https://github.com/hoxfon/react-native-twilio-programmable-voice/issues/44) - // to?: string, // "client:bob" // issue 44 (https://github.com/hoxfon/react-native-twilio-programmable-voice/issues/44) - // error?: string, // issue 44 (https://github.com/hoxfon/react-native-twilio-programmable-voice/issues/44) - // } +}) +TwilioVoice.addEventListener('callStateRinging', function(data: mixed) { + // { + // call_sid: string, // Twilio call sid + // call_state: 'PENDING' | 'CONNECTED' | 'ACCEPTED' | 'CONNECTING' | 'RINGING' | 'DISCONNECTED' | 'CANCELLED', + // call_from: string, // "+441234567890" + // call_to: string, // "client:bob" + // } +}) +TwilioVoice.addEventListener('callInviteCancelled', function(data: mixed) { + // { + // call_sid: string, // Twilio call sid + // call_from: string, // "+441234567890" + // call_to: string, // "client:bob" + // } }) // iOS Only @@ -298,7 +306,6 @@ TwilioVoice.addEventListener('callRejected', function(value: 'callRejected') {}) TwilioVoice.addEventListener('deviceDidReceiveIncoming', function(data) { // { // call_sid: string, // Twilio call sid - // call_state: 'PENDING' | 'CONNECTED' | 'ACCEPTED' | 'CONNECTING' 'DISCONNECTED' | 'CANCELLED', // call_from: string, // "+441234567890" // call_to: string, // "client:bob" // } @@ -339,14 +346,25 @@ TwilioVoice.ignore() // mutedValue must be a boolean TwilioVoice.setMuted(mutedValue) +// put a call on hold +TwilioVoice.setOnHold(holdValue) + +// send digits TwilioVoice.sendDigits(digits) -// should be called after the app is initialized -// to catch incoming call when the app was in the background +// Ensure that an active call is displayed when the app comes to foreground TwilioVoice.getActiveCall() - .then(incomingCall => { - if (incomingCall){ - _deviceDidReceiveIncoming(incomingCall) + .then(activeCall => { + if (activeCall){ + _displayActiveCall(activeCall) + } + }) + +// Ensure that call invites are displayed when the app comes to foreground +TwilioVoice.getCallInvite() + .then(callInvite => { + if (callInvite){ + _handleCallInvite(callInvite) } }) diff --git a/android/build.gradle b/android/build.gradle index b4365da1..a716af2c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -26,11 +26,15 @@ apply plugin: 'com.android.library' def DEFAULT_COMPILE_SDK_VERSION = 28 def DEFAULT_BUILD_TOOLS_VERSION = "28.0.3" def DEFAULT_TARGET_SDK_VERSION = 28 -def DEFAULT_SUPPORT_LIB_VERSION = "28.0.0" +def DEFAULT_SUPPORT_LIB_VERSION = "28.0.3" android { compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : DEFAULT_BUILD_TOOLS_VERSION + compileOptions { + sourceCompatibility 1.8 + targetCompatibility 1.8 + } defaultConfig { minSdkVersion 16 targetSdkVersion rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : DEFAULT_TARGET_SDK_VERSION @@ -50,7 +54,7 @@ dependencies { def supportLibVersion = rootProject.hasProperty('supportLibVersion') ? rootProject.supportLibVersion : DEFAULT_SUPPORT_LIB_VERSION implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation 'com.twilio:voice-android:2.1.0' + implementation 'com.twilio:voice-android:3.3.0' implementation "com.android.support:appcompat-v7:$supportLibVersion" implementation 'com.facebook.react:react-native:+' implementation 'com.google.firebase:firebase-messaging:17.+' diff --git a/android/gradle.properties b/android/gradle.properties index aac7c9b4..af6dcbe4 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -15,3 +15,5 @@ org.gradle.jvmargs=-Xmx1536m # This option should only be used with decoupled projects. More details, visit # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true +android.useAndroidX=true +android.enableJetifier=true \ No newline at end of file diff --git a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java index 6bf9bcdc..af4e9175 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java @@ -21,6 +21,7 @@ import com.facebook.react.bridge.ReactApplicationContext; import com.twilio.voice.CallInvite; +import com.twilio.voice.CancelledCallInvite; import java.util.List; @@ -307,12 +308,18 @@ public void createHangupLocalNotification(ReactApplicationContext context, Strin } public void removeIncomingCallNotification(ReactApplicationContext context, - CallInvite callInvite, + CancelledCallInvite callInvite, int notificationId) { - Log.d(TAG, "removeIncomingCallNotification"); + if (BuildConfig.DEBUG) { + Log.d(TAG, "removeIncomingCallNotification"); + } + if (context == null) { + Log.e(TAG, "Context is null"); + return; + } NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { - if (callInvite != null && callInvite.getState() == CallInvite.State.PENDING) { + if (callInvite != null) { /* * If the incoming call message was cancelled then remove the notification by matching * it with the call sid from the list of notifications in the notification drawer. diff --git a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java index af7a98aa..40b2fed7 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java @@ -21,6 +21,9 @@ public class EventManager { public static final String EVENT_CONNECTION_DID_CONNECT = "connectionDidConnect"; public static final String EVENT_CONNECTION_DID_DISCONNECT = "connectionDidDisconnect"; public static final String EVENT_DEVICE_DID_RECEIVE_INCOMING = "deviceDidReceiveIncoming"; + public static final String EVENT_CALL_STATE_RINGING = "callStateRinging"; + public static final String EVENT_CALL_INVITE_CANCELLED = "callInviteCancelled"; + public EventManager(ReactApplicationContext context) { mContext = context; diff --git a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java index 64a2b54e..09573c77 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java @@ -43,9 +43,12 @@ import com.google.android.gms.tasks.Task; import com.google.firebase.iid.FirebaseInstanceId; import com.google.firebase.iid.InstanceIdResult; +import com.twilio.voice.AcceptOptions; import com.twilio.voice.Call; import com.twilio.voice.CallException; import com.twilio.voice.CallInvite; +import com.twilio.voice.CancelledCallInvite; +import com.twilio.voice.ConnectOptions; import com.twilio.voice.LogLevel; import com.twilio.voice.RegistrationException; import com.twilio.voice.RegistrationListener; @@ -59,6 +62,8 @@ import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_DEVICE_DID_RECEIVE_INCOMING; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_DEVICE_NOT_READY; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_DEVICE_READY; +import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CALL_STATE_RINGING; +import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CALL_INVITE_CANCELLED; public class TwilioVoiceModule extends ReactContextBaseJavaModule implements ActivityEventListener, LifecycleEventListener { @@ -78,6 +83,8 @@ public class TwilioVoiceModule extends ReactContextBaseJavaModule implements Act public static final String INCOMING_CALL_INVITE = "INCOMING_CALL_INVITE"; public static final String INCOMING_CALL_NOTIFICATION_ID = "INCOMING_CALL_NOTIFICATION_ID"; public static final String NOTIFICATION_TYPE = "NOTIFICATION_TYPE"; + public static final String CANCELLED_CALL_INVITE = "CANCELLED_CALL_INVITE"; + public static final String ACTION_INCOMING_CALL = "com.hoxfon.react.TwilioVoice.INCOMING_CALL"; public static final String ACTION_FCM_TOKEN = "com.hoxfon.react.TwilioVoice.ACTION_FCM_TOKEN"; @@ -85,6 +92,7 @@ public class TwilioVoiceModule extends ReactContextBaseJavaModule implements Act public static final String ACTION_ANSWER_CALL = "com.hoxfon.react.TwilioVoice.ANSWER_CALL"; public static final String ACTION_REJECT_CALL = "com.hoxfon.react.TwilioVoice.REJECT_CALL"; public static final String ACTION_HANGUP_CALL = "com.hoxfon.react.TwilioVoice.HANGUP_CALL"; + public static final String ACTION_CANCEL_CALL_INVITE = "com.hoxfon.react.TwilioVoice.CANCEL_CALL_INVITE"; public static final String ACTION_CLEAR_MISSED_CALLS_COUNT = "com.hoxfon.react.TwilioVoice.CLEAR_MISSED_CALLS_COUNT"; public static final String CALL_SID_KEY = "CALL_SID"; @@ -217,6 +225,34 @@ public void onError(RegistrationException error, String accessToken, String fcmT private Call.Listener callListener() { return new Call.Listener() { + /* + * This callback is emitted once before the Call.Listener.onConnected() callback when + * the callee is being alerted of a Call. The behavior of this callback is determined by + * the answerOnBridge flag provided in the Dial verb of your TwiML application + * associated with this client. If the answerOnBridge flag is false, which is the + * default, the Call.Listener.onConnected() callback will be emitted immediately after + * Call.Listener.onRinging(). If the answerOnBridge flag is true, this will cause the + * call to emit the onConnected callback only after the call is answered. + * See answeronbridge for more details on how to use it with the Dial TwiML verb. If the + * twiML response contains a Say verb, then the call will emit the + * Call.Listener.onConnected callback immediately after Call.Listener.onRinging() is + * raised, irrespective of the value of answerOnBridge being set to true or false + */ + @Override + public void onRinging(Call call) { + // TODO test this with JS app + if (BuildConfig.DEBUG) { + Log.d(TAG, "CALL RINGING callListener().onRinging call state = "+call.getState()); + Log.d(TAG, call.toString()); + } + WritableMap params = Arguments.createMap(); + if (call != null) { + params.putString("call_sid", call.getSid()); + params.putString("call_from", call.getFrom()); + params.putString("call_state", call.getState().name()); + } + eventManager.sendEvent(EVENT_CALL_STATE_RINGING, params); + } @Override public void onConnected(Call call) { if (BuildConfig.DEBUG) { @@ -245,6 +281,27 @@ public void onConnected(Call call) { eventManager.sendEvent(EVENT_CONNECTION_DID_CONNECT, params); } +// Prepare methods Twilio for v4 +// @Override +// public void onReconnecting(Call call, CallException callException) { +// if (BuildConfig.DEBUG) { +// Log.d(TAG, "CALL RECONNECTING callListener().onReconnecting call state = "+call.getState()); +// } +// // TODO implement +//// eventManager.sendEvent(XXX, params); +// +// } +// +// @Override +// public void onReconnected(Call call) { +// if (BuildConfig.DEBUG) { +// Log.d(TAG, "CALL RECONNECTED callListener().onReconnected call state = "+call.getState()); +// } +// +// // TODO implement +//// eventManager.sendEvent(XXX, params); +// } + @Override public void onDisconnected(Call call, CallException error) { unsetAudioFocus(); @@ -319,6 +376,7 @@ private void registerReceiver() { if (!isReceiverRegistered) { IntentFilter intentFilter = new IntentFilter(); intentFilter.addAction(ACTION_INCOMING_CALL); + intentFilter.addAction(ACTION_CANCEL_CALL_INVITE); intentFilter.addAction(ACTION_MISSED_CALL); LocalBroadcastManager.getInstance(getReactApplicationContext()).registerReceiver( voiceBroadcastReceiver, intentFilter); @@ -381,19 +439,13 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } private void handleIncomingCallIntent(Intent intent) { - if (intent == null || intent.getAction() == null) { - Log.e(TAG, "handleIncomingCallIntent intent is null"); - return; - } - if (intent.getAction().equals(ACTION_INCOMING_CALL)) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "handleIncomingCallIntent"); + } activeCallInvite = intent.getParcelableExtra(INCOMING_CALL_INVITE); - - if (activeCallInvite != null && (activeCallInvite.getState() == CallInvite.State.PENDING)) { + if (activeCallInvite != null) { callAccepted = false; - if (BuildConfig.DEBUG) { - Log.d(TAG, "handleIncomingCallIntent state = PENDING"); - } SoundPoolManager.getInstance(getReactApplicationContext()).playRinging(); if (getReactApplicationContext().getCurrentActivity() != null) { @@ -411,58 +463,36 @@ private void handleIncomingCallIntent(Intent intent) { WritableMap params = Arguments.createMap(); params.putString("call_sid", activeCallInvite.getCallSid()); params.putString("call_from", activeCallInvite.getFrom()); - params.putString("call_to", activeCallInvite.getTo()); - params.putString("call_state", activeCallInvite.getState().name()); + params.putString("call_to", activeCallInvite.getTo()); // this is not needed + // TODO check if this is needed + // params.putString("call_state", "PENDING"); eventManager.sendEvent(EVENT_DEVICE_DID_RECEIVE_INCOMING, params); } - - } else { + // TODO evaluate what more is needed at this point? + Log.e(TAG, "ACTION_INCOMING_CALL but not active call"); + } + } else if (intent.getAction().equals(ACTION_CANCEL_CALL_INVITE)) { + SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "activeCallInvite was cancelled by " + activeCallInvite.getFrom()); + } + if (!callAccepted) { if (BuildConfig.DEBUG) { - Log.d(TAG, "====> BEGIN handleIncomingCallIntent when activeCallInvite != PENDING"); - } - // this block is executed when the callInvite is cancelled and: - // - the call is answered (activeCall != null) - // - the call is rejected - - SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); - - // the call is not active yet - if (activeCall == null) { - - if (activeCallInvite != null) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "activeCallInvite state = " + activeCallInvite.getState()); - } - if (BuildConfig.DEBUG) { - Log.d(TAG, "activeCallInvite was cancelled by " + activeCallInvite.getFrom()); - } - if (!callAccepted) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "creating a missed call, activeCallInvite state: " + activeCallInvite.getState()); - } - callNotificationManager.createMissedCallNotification(getReactApplicationContext(), activeCallInvite); - int appImportance = callNotificationManager.getApplicationImportance(getReactApplicationContext()); - if (appImportance != RunningAppProcessInfo.IMPORTANCE_BACKGROUND) { - WritableMap params = Arguments.createMap(); - params.putString("call_sid", activeCallInvite.getCallSid()); - params.putString("call_from", activeCallInvite.getFrom()); - params.putString("call_to", activeCallInvite.getTo()); - params.putString("call_state", activeCallInvite.getState().name()); - eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); - } - } - } - clearIncomingNotification(activeCallInvite); - } else { - if (BuildConfig.DEBUG) { - Log.d(TAG, "activeCallInvite was answered. Call " + activeCall); - } + Log.d(TAG, "creating a missed call"); } - if (BuildConfig.DEBUG) { - Log.d(TAG, "====> END"); + callNotificationManager.createMissedCallNotification(getReactApplicationContext(), activeCallInvite); + int appImportance = callNotificationManager.getApplicationImportance(getReactApplicationContext()); + if (appImportance != RunningAppProcessInfo.IMPORTANCE_BACKGROUND) { + WritableMap params = Arguments.createMap(); + params.putString("call_sid", activeCallInvite.getCallSid()); + params.putString("call_from", activeCallInvite.getFrom()); + params.putString("call_to", activeCallInvite.getTo()); + params.putString("call_state", Call.State.DISCONNECTED.toString()); + eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); } } + clearIncomingNotification(activeCallInvite.getCallSid()); } else if (intent.getAction().equals(ACTION_FCM_TOKEN)) { if (BuildConfig.DEBUG) { Log.d(TAG, "handleIncomingCallIntent ACTION_FCM_TOKEN"); @@ -476,11 +506,21 @@ private class VoiceBroadcastReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "VoiceBroadcastReceiver.onReceive "+action+". Intent "+ intent.getExtras()); + } if (action.equals(ACTION_INCOMING_CALL)) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "VoiceBroadcastReceiver.onReceive ACTION_INCOMING_CALL. Intent "+ intent.getExtras()); - } handleIncomingCallIntent(intent); + } else if (action.equals(ACTION_CANCEL_CALL_INVITE)) { + CancelledCallInvite cancelledCallInvite = intent.getParcelableExtra(CANCELLED_CALL_INVITE); + clearIncomingNotification(cancelledCallInvite.getCallSid()); + WritableMap params = Arguments.createMap(); + if (cancelledCallInvite != null) { + params.putString("call_sid", cancelledCallInvite.getCallSid()); + params.putString("call_from", cancelledCallInvite.getFrom()); + params.putString("call_to", cancelledCallInvite.getTo()); + } + eventManager.sendEvent(EVENT_CALL_INVITE_CANCELLED, params); } else if (action.equals(ACTION_MISSED_CALL)) { SharedPreferences sharedPref = getReactApplicationContext().getSharedPreferences(PREFERENCE_KEY, Context.MODE_PRIVATE); SharedPreferences.Editor sharedPrefEditor = sharedPref.edit(); @@ -514,21 +554,19 @@ public void initWithAccessToken(final String accessToken, Promise promise) { promise.resolve(params); } - private void clearIncomingNotification(CallInvite callInvite) { + private void clearIncomingNotification(String callSid) { if (BuildConfig.DEBUG) { - Log.d(TAG, "clearIncomingNotification() callInvite state: "+ callInvite.getState()); + Log.d(TAG, "clearIncomingNotification() callSid: "+ callSid); } - if (callInvite != null && callInvite.getCallSid() != null) { - // remove incoming call notification - String notificationKey = INCOMING_NOTIFICATION_PREFIX + callInvite.getCallSid(); - int notificationId = 0; - if (TwilioVoiceModule.callNotificationMap.containsKey(notificationKey)) { - notificationId = TwilioVoiceModule.callNotificationMap.get(notificationKey); - } - callNotificationManager.removeIncomingCallNotification(getReactApplicationContext(), null, notificationId); - TwilioVoiceModule.callNotificationMap.remove(notificationKey); + // remove incoming call notification + String notificationKey = INCOMING_NOTIFICATION_PREFIX + callSid; + int notificationId = 0; + if (TwilioVoiceModule.callNotificationMap.containsKey(notificationKey)) { + notificationId = TwilioVoiceModule.callNotificationMap.get(notificationKey); } -// activeCallInvite = null; + callNotificationManager.removeIncomingCallNotification(getReactApplicationContext(), null, notificationId); + TwilioVoiceModule.callNotificationMap.remove(notificationKey); + activeCallInvite = null; } /* @@ -554,38 +592,36 @@ public void onComplete(@NonNull Task task) { if (BuildConfig.DEBUG) { Log.d(TAG, "Registering with FCM"); } - Voice.register(getReactApplicationContext(), accessToken, Voice.RegistrationChannel.FCM, fcmToken, registrationListener); + Voice.register(accessToken, Voice.RegistrationChannel.FCM, fcmToken, registrationListener); } } }); - } @ReactMethod public void accept() { callAccepted = true; SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); - if (activeCallInvite != null){ - if (activeCallInvite.getState() == CallInvite.State.PENDING) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "accept() activeCallInvite.getState() PENDING"); - } - activeCallInvite.accept(getReactApplicationContext(), callListener); - clearIncomingNotification(activeCallInvite); - } else { - // when the user answers a call from a notification before the react-native App - // is completely initialised, and the first event has been skipped - // re-send connectionDidConnect message to JS - WritableMap params = Arguments.createMap(); - params.putString("call_sid", activeCallInvite.getCallSid()); - params.putString("call_from", activeCallInvite.getFrom()); - params.putString("call_to", activeCallInvite.getTo()); - params.putString("call_state", activeCallInvite.getState().name()); - callNotificationManager.createHangupLocalNotification(getReactApplicationContext(), - activeCallInvite.getCallSid(), - activeCallInvite.getFrom()); - eventManager.sendEvent(EVENT_CONNECTION_DID_CONNECT, params); + if (activeCallInvite != null) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "accept()"); } + AcceptOptions acceptOptions = new AcceptOptions.Builder().build(); + activeCallInvite.accept(getReactApplicationContext(), acceptOptions, callListener); + clearIncomingNotification(activeCallInvite.getCallSid()); + + // TODO check whether this block is needed +// // when the user answers a call from a notification before the react-native App +// // is completely initialised, and the first event has been skipped +// // re-send connectionDidConnect message to JS +// WritableMap params = Arguments.createMap(); +// params.putString("call_sid", activeCallInvite.getCallSid()); +// params.putString("call_from", activeCallInvite.getFrom()); +// params.putString("call_to", activeCallInvite.getTo()); +// callNotificationManager.createHangupLocalNotification(getReactApplicationContext(), +// activeCallInvite.getCallSid(), +// activeCallInvite.getFrom()); +// eventManager.sendEvent(EVENT_CONNECTION_DID_CONNECT, params); } else { eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, null); } @@ -596,13 +632,15 @@ public void reject() { callAccepted = false; SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); WritableMap params = Arguments.createMap(); - if (activeCallInvite != null){ + if (activeCallInvite != null) { params.putString("call_sid", activeCallInvite.getCallSid()); params.putString("call_from", activeCallInvite.getFrom()); params.putString("call_to", activeCallInvite.getTo()); - params.putString("call_state", activeCallInvite.getState().name()); + params.putString("call_state", "DISCONNECTED"); + // TODO check if DISCONNECTED should be REJECTED + // params.putString("call_state", "REJECTED"); activeCallInvite.reject(getReactApplicationContext()); - clearIncomingNotification(activeCallInvite); + clearIncomingNotification(activeCallInvite.getCallSid()); } eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); } @@ -612,12 +650,12 @@ public void ignore() { callAccepted = false; SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); WritableMap params = Arguments.createMap(); - if (activeCallInvite != null){ + if (activeCallInvite != null) { params.putString("call_sid", activeCallInvite.getCallSid()); params.putString("call_from", activeCallInvite.getFrom()); params.putString("call_to", activeCallInvite.getTo()); - params.putString("call_state", activeCallInvite.getState().name()); - clearIncomingNotification(activeCallInvite); + params.putString("call_state", "BUSY"); + clearIncomingNotification(activeCallInvite.getCallSid()); } eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); } @@ -673,7 +711,11 @@ public void connect(ReadableMap params) { } } - activeCall = Voice.call(getReactApplicationContext(), accessToken, twiMLParams, callListener); + ConnectOptions connectOptions = new ConnectOptions.Builder(accessToken) + .params(twiMLParams) + .build(); + + activeCall = Voice.connect(getReactApplicationContext(), connectOptions, callListener); } @ReactMethod @@ -705,22 +747,30 @@ public void getActiveCall(Promise promise) { Log.d(TAG, "Active call found state = "+activeCall.getState()); } WritableMap params = Arguments.createMap(); + String toNum = activeCall.getTo(); + if (toNum == null) { + toNum = toNumber; + } params.putString("call_sid", activeCall.getSid()); params.putString("call_from", activeCall.getFrom()); - params.putString("call_to", activeCall.getTo()); + params.putString("call_to", toNum); params.putString("call_state", activeCall.getState().name()); promise.resolve(params); return; } + promise.resolve(null); + } + + @ReactMethod + public void getCallInvite(Promise promise) { if (activeCallInvite != null) { if (BuildConfig.DEBUG) { - Log.d(TAG, "Active call invite found state = "+activeCallInvite.getState()); + Log.d(TAG, "Call invite found "+ activeCallInvite); } WritableMap params = Arguments.createMap(); params.putString("call_sid", activeCallInvite.getCallSid()); params.putString("call_from", activeCallInvite.getFrom()); params.putString("call_to", activeCallInvite.getTo()); - params.putString("call_state", activeCallInvite.getState().name()); promise.resolve(params); return; } @@ -734,8 +784,17 @@ public void setSpeakerPhone(Boolean value) { audioManager.setSpeakerphoneOn(value); } + @ReactMethod + public void setOnHold(Boolean value) { + if (activeCall != null) { + activeCall.hold(value); + } + } + private void setAudioFocus() { if (audioManager == null) { + audioManager.setMode(originalAudioMode); + audioManager.abandonAudioFocus(null); return; } originalAudioMode = audioManager.getMode(); @@ -755,11 +814,12 @@ public void onAudioFocusChange(int i) { } .build(); audioManager.requestAudioFocus(focusRequest); } else { - audioManager.requestAudioFocus( - null, - AudioManager.STREAM_VOICE_CALL, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE - ); + int focusRequestResult = audioManager.requestAudioFocus(new AudioManager.OnAudioFocusChangeListener() { + @Override + public void onAudioFocusChange(int focusChange) {} + }, + AudioManager.STREAM_VOICE_CALL, + AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); } /* * Start by setting MODE_IN_COMMUNICATION as default audio mode. It is @@ -772,6 +832,8 @@ public void onAudioFocusChange(int i) { } private void unsetAudioFocus() { if (audioManager == null) { + audioManager.setMode(originalAudioMode); + audioManager.abandonAudioFocus(null); return; } audioManager.setMode(originalAudioMode); diff --git a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/fcm/VoiceFirebaseMessagingService.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/fcm/VoiceFirebaseMessagingService.java index ca5d4fed..520b77f1 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/fcm/VoiceFirebaseMessagingService.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/fcm/VoiceFirebaseMessagingService.java @@ -1,7 +1,5 @@ package com.hoxfon.react.RNTwilioVoice.fcm; -import android.annotation.TargetApi; - import android.app.ActivityManager; import android.content.Intent; import android.os.Handler; @@ -19,7 +17,7 @@ import com.hoxfon.react.RNTwilioVoice.BuildConfig; import com.hoxfon.react.RNTwilioVoice.CallNotificationManager; import com.twilio.voice.CallInvite; -import com.twilio.voice.MessageException; +import com.twilio.voice.CancelledCallInvite; import com.twilio.voice.MessageListener; import com.twilio.voice.Voice; @@ -29,7 +27,9 @@ import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.TAG; import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_FCM_TOKEN; import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_INCOMING_CALL; +import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_CANCEL_CALL_INVITE; import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.INCOMING_CALL_INVITE; +import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.CANCELLED_CALL_INVITE; import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.INCOMING_CALL_NOTIFICATION_ID; import com.hoxfon.react.RNTwilioVoice.SoundPoolManager; @@ -71,8 +71,7 @@ public void onMessageReceived(RemoteMessage remoteMessage) { Random randomNumberGenerator = new Random(System.currentTimeMillis()); final int notificationId = randomNumberGenerator.nextInt(); - Voice.handleMessage(this, data, new MessageListener() { - + boolean valid = Voice.handleMessage(data, new MessageListener() { @Override public void onCallInvite(final CallInvite callInvite) { @@ -102,7 +101,10 @@ public void run() { if (appImportance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { context.startActivity(launchIntent); } - VoiceFirebaseMessagingService.this.handleIncomingCall((ReactApplicationContext)context, notificationId, callInvite, launchIntent); + Intent intent = new Intent(ACTION_INCOMING_CALL); + intent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(INCOMING_CALL_INVITE, callInvite); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); } else { // Otherwise wait for construction, then handle the incoming call mReactInstanceManager.addReactInstanceEventListener(new ReactInstanceManager.ReactInstanceEventListener() { @@ -113,7 +115,13 @@ public void onReactContextInitialized(ReactContext context) { } Intent launchIntent = callNotificationManager.getLaunchIntent((ReactApplicationContext)context, notificationId, callInvite, true, appImportance); context.startActivity(launchIntent); - VoiceFirebaseMessagingService.this.handleIncomingCall((ReactApplicationContext)context, notificationId, callInvite, launchIntent); + Intent intent = new Intent(ACTION_INCOMING_CALL); + intent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(INCOMING_CALL_INVITE, callInvite); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); + callNotificationManager.createIncomingCallNotification( + (ReactApplicationContext) context, callInvite, notificationId, + launchIntent); } }); if (!mReactInstanceManager.hasStartedCreatingInitialContext()) { @@ -126,10 +134,19 @@ public void onReactContextInitialized(ReactContext context) { } @Override - public void onError(MessageException messageException) { - Log.e(TAG, "Error handling FCM message" + messageException.toString()); + public void onCancelledCallInvite(final CancelledCallInvite cancelledCallInvite) { + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(new Runnable() { + public void run() { + VoiceFirebaseMessagingService.this.sendCancelledCallInviteToActivity(cancelledCallInvite); + } + }); } }); + + if (!valid) { + Log.e(TAG, "The message was not a valid Twilio Voice SDK payload: " + remoteMessage.getData()); + } } // Check if message contains a notification payload. @@ -138,43 +155,13 @@ public void onError(MessageException messageException) { } } - private void handleIncomingCall(ReactApplicationContext context, - int notificationId, - CallInvite callInvite, - Intent launchIntent - ) { - sendIncomingCallMessageToActivity(context, callInvite, notificationId); - showNotification(context, callInvite, notificationId, launchIntent); - } - - /* - * Send the IncomingCallMessage to the TwilioVoiceModule - */ - private void sendIncomingCallMessageToActivity( - ReactApplicationContext context, - CallInvite callInvite, - int notificationId - ) { - Intent intent = new Intent(ACTION_INCOMING_CALL); - intent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); - intent.putExtra(INCOMING_CALL_INVITE, callInvite); - LocalBroadcastManager.getInstance(context).sendBroadcast(intent); - } - /* - * Show the notification in the Android notification drawer + * Send the CancelledCallInvite to the TwilioVoiceModule */ - @TargetApi(20) - private void showNotification(ReactApplicationContext context, - CallInvite callInvite, - int notificationId, - Intent launchIntent - ) { - if (callInvite != null && callInvite.getState() == CallInvite.State.PENDING) { - callNotificationManager.createIncomingCallNotification(context, callInvite, notificationId, launchIntent); - } else { - SoundPoolManager.getInstance(context.getBaseContext()).stopRinging(); - callNotificationManager.removeIncomingCallNotification(context, callInvite, 0); - } + private void sendCancelledCallInviteToActivity(CancelledCallInvite cancelledCallInvite) { + SoundPoolManager.getInstance((this)).stopRinging(); + Intent intent = new Intent(ACTION_CANCEL_CALL_INVITE); + intent.putExtra(CANCELLED_CALL_INVITE, cancelledCallInvite); + LocalBroadcastManager.getInstance(this).sendBroadcast(intent); } } diff --git a/index.js b/index.js index 79dd8841..4c3f40c3 100644 --- a/index.js +++ b/index.js @@ -17,7 +17,8 @@ const _eventHandlers = { deviceDidReceiveIncoming: new Map(), connectionDidConnect: new Map(), connectionDidDisconnect: new Map(), - //iOS specific + callStateRinging: new Map(), + callInviteCancelled: new Map(), callRejected: new Map(), } @@ -52,9 +53,7 @@ const Twilio = { connect(params = {}) { TwilioVoice.connect(params) }, - disconnect() { - TwilioVoice.disconnect() - }, + disconnect: TwilioVoice.disconnect, accept() { if (Platform.OS === IOS) { return @@ -73,23 +72,17 @@ const Twilio = { } TwilioVoice.ignore() }, - setMuted(isMuted) { - TwilioVoice.setMuted(isMuted) - }, - setSpeakerPhone(value) { - TwilioVoice.setSpeakerPhone(value) - }, - sendDigits(digits) { - TwilioVoice.sendDigits(digits) - }, + setMuted: TwilioVoice.setMuted, + setSpeakerPhone: TwilioVoice.setSpeakerPhone, + sendDigits: TwilioVoice.sendDigits, + hold: TwilioVoice.hold, requestPermissions(senderId) { if (Platform.OS === ANDROID) { TwilioVoice.requestPermissions(senderId) } }, - getActiveCall() { - return TwilioVoice.getActiveCall() - }, + getActiveCall: TwilioVoice.getActiveCall, + getCallInvite: TwilioVoice.getCallInvite, configureCallKit(params = {}) { if (Platform.OS === IOS) { TwilioVoice.configureCallKit(params) @@ -101,6 +94,10 @@ const Twilio = { } }, addEventListener(type, handler) { + if (!_eventHandlers.hasOwnProperty(type)) { + throw new Error('Event handler not found: ' + type) + } + if (_eventHandlers[type]) if (_eventHandlers[type].has(handler)) { return }