diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bbd175d..3004414e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ [Release Section](https://github.com/hoxfon/react-native-twilio-programmable-voice/releases) +## 4.0.0 + +- Android + - 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 +- iOS + - convert params for connectionDidConnect to => call_to, from => call_from + - convert params for connectionDidDisconnect to => call_to, from => call_from, error => err + ## 3.21.3 - iOS: Upgrade TwilioVoice pod to version 2.1 diff --git a/README.md b/README.md index ea7974c3..f2cd4ade 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,15 @@ # react-native-twilio-programmable-voice -This is a React Native wrapper for Twilio Programmable Voice SDK that lets you make and receive calls from your ReactNatvie App. This module is not curated nor maintained, but inspired by Twilio. +This is a React Native wrapper for Twilio Programmable Voice SDK that lets you make and receive calls from your React Native App. This module is not affiliated with or maintained by the Twilio team. This is maintained by contributions from the community. # Twilio Programmable Voice SDK -- Android 2.0.7 (bundled within this library) -- iOS 2.0.4 (specified by the app's own podfile) +- Android 5.0.2 (bundled within this library) +- iOS 5.1.1 (specified by the app's own podfile; min. version 5.x) -## Breaking changes in v3.0.0 - -- initWitToken returns an object with a property `initialized` instead of `initilized` -- iOS event `connectionDidConnect` returns the same properties as Android -move property `to` => `call_to` -move property `from` => `call_from` - -## Migrating Android from v1 to v2 (incoming call use FCM) - -You will need to make changes both on your Twilio account using Twilio Web Console and on your react native app. -Twilio Programmable Voice Android SDK uses `FCM` since version 2.0.0.beta5. - -Before you start, I strongly suggest that you read the list of Twilio changes from Android SDK v2.0.0 beta4 to beta5: -[Twilio example App: Migrating from GCM to FCM](https://github.com/twilio/voice-quickstart-android/blob/d7d4f0658e145eb94ab8f5e34f6fd17314e7ab17/README.md#migrating-from-gcm-to-fcm) - -These are all the changes required: - -- remove all the GCM related code from your `AndroidManifest.xml` and add the following code to receive `FCM` notifications -(I wasn't successful in keeping react-native-fcm working at the same time. If you know how please open an issue to share). +## Breaking changes in v4.0.0 +- Android: remove the following block from your application's `AndroidManifest.xml` ```xml - ..... - - - - - - - - - - ``` -- log into your Firebase console. Navigate to: Project settings > CLOUD MESSAGING. Copy your `Server key` -- in Twilio console add a new Push Credential, type `FCM`, fcm secret Firebase FCM `Server key` -- include in your project `google-services.json`; if you have not include it yet -- rename getIncomingCall() to getActiveCall() +- iOS: params changes for `connectionDidConnect` and `connectionDidDisconnect` -If something doesn't work as expected or you want to make a request open an issue. + to => call_to + from => call_from + error => err + +## Breaking changes in v3.0.0 + +- initWitToken returns an object with a property `initialized` instead of `initilized` +- iOS event `connectionDidConnect` returns the same properties as Android +move property `to` => `call_to` +move property `from` => `call_from` ## Help wanted! @@ -68,7 +45,7 @@ ReactNative success is directly linked to its module ecosystem. One way to make ## Installation Before starting, we recommend you get familiar with [Twilio Programmable Voice SDK](https://www.twilio.com/docs/api/voice-sdk). -It's easier to integrate this module into your react-native app if you follow the Quick start tutorial from Twilio, because it makes very clear which setup steps are required. +It's easier to integrate this module into your react-native app if you follow the Quick start tutorial from Twilio, because it makes very clear which setup steps are required. On RN 0.60+, this module can be auto-linked (Android still requires FCM setup below). ``` @@ -86,11 +63,11 @@ Edit your `Podfile` to include TwilioVoice framework source 'https://github.com/cocoapods/specs' # min version for TwilioVoice to work -platform :ios, '8.1' +platform :ios, '10.0' target do ... - pod 'TwilioVoice', '~> 2.1.0' + pod 'TwilioVoice', '~> 5.1.1' ... end @@ -105,11 +82,11 @@ Edit your `Podfile` to include TwilioVoice and RNTwilioVoice frameworks source 'https://github.com/cocoapods/specs' # min version for TwilioVoice to work -platform :ios, '8.1' +platform :ios, '10.0' target do ... - pod 'TwilioVoice', '~> 2.1.0' + pod 'TwilioVoice', '~> 5.1.1' pod 'RNTwilioVoice', path: '../node_modules/react-native-twilio-programmable-voice' ... end @@ -143,7 +120,7 @@ It contains keys and settings for all your applications under Firebase. This lib buildscript { ... dependencies { - classpath 'com.google.gms:google-services:3.1.2' + classpath 'com.google.gms:google-services:4.2.0' } } @@ -151,8 +128,9 @@ buildscript { dependencies { ... + // on React Native 0.60+, this module can be auto-linked and doesn't need a manual entry here - compile project(':react-native-twilio-programmable-voice') + implementation project(':react-native-twilio-programmable-voice') } // this plugin looks for google-services.json in your project @@ -179,22 +157,12 @@ In your `AndroidManifest.xml` - - - - - - - - ..... ``` -In `android/settings.gradle` +In `android/settings.gradle` (not necessary if auto-linking on RN 0.60+) ```gradle ... @@ -203,7 +171,7 @@ include ':react-native-twilio-programmable-voice' project(':react-native-twilio-programmable-voice').projectDir = file('../node_modules/react-native-twilio-programmable-voice/android') ``` -Register module (in `MainApplication.java`) +Register module (in `MainApplication.java`) (not necessary if auto-linking on RN 0.60+ unless you want to control microphone permission) ```java import com.hoxfon.react.RNTwilioVoice.TwilioVoicePackage; // <--- Import Package @@ -220,8 +188,8 @@ public class MainApplication extends Application implements ReactApplication { protected List getPackages() { return Arrays.asList( new MainReactPackage(), - new TwilioVoicePackage() // <---- Add the Package : by default it will ask microphone permissions - // new TwilioVoicePackage(false) // <---- pass false to handle microphone permissions in your application + new TwilioVoicePackage() // <---- Add the package + // new TwilioVoicePackage(false) // <---- pass false if you don't want to ask for microphone permissions ); } }; diff --git a/RNTwilioVoice.podspec b/RNTwilioVoice.podspec index 9b80fac6..53f4ec2d 100644 --- a/RNTwilioVoice.podspec +++ b/RNTwilioVoice.podspec @@ -9,12 +9,12 @@ Pod::Spec.new do |s| s.authors = spec['author']['name'] s.homepage = spec['homepage'] s.license = spec['license'] - s.platform = :ios, "8.1" + s.platform = :ios, "10.0" s.source_files = [ "ios/RNTwilioVoice/*.h", "ios/RNTwilioVoice/*.m"] s.source = {:path => "./RNTwilioVoice"} s.dependency 'React' - s.xcconfig = { 'FRAMEWORK_SEARCH_PATHS' => '${PODS_ROOT}/TwilioVoice' } + s.xcconfig = { 'FRAMEWORK_SEARCH_PATHS' => '${PODS_ROOT}/TwilioVoice/Build/iOS' } s.frameworks = 'TwilioVoice' end \ No newline at end of file diff --git a/android/build.gradle b/android/build.gradle index 878fc179..faa49783 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -2,11 +2,12 @@ buildscript { repositories { + google() jcenter() } dependencies { - classpath 'com.android.tools.build:gradle:2.2.1' - classpath 'com.google.gms:google-services:3.1.2' + classpath 'com.android.tools.build:gradle:3.5.2' + classpath 'com.google.gms:google-services:4.2.0' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -15,21 +16,24 @@ buildscript { allprojects { repositories { + google() jcenter() - maven { - url "https://maven.google.com" - } } } 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" + android { - compileSdkVersion 27 - buildToolsVersion "27.0.3" + compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION + buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : DEFAULT_BUILD_TOOLS_VERSION defaultConfig { minSdkVersion 16 - targetSdkVersion 27 + targetSdkVersion rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : DEFAULT_TARGET_SDK_VERSION versionCode 1 versionName "1.0" vectorDrawables.useSupportLibrary = true @@ -43,10 +47,12 @@ android { } dependencies { - compile fileTree(include: ['*.jar'], dir: 'libs') - compile 'com.twilio:voice-android:2.0.7' - compile 'com.android.support:appcompat-v7:27.0.2' - compile 'com.facebook.react:react-native:+' - compile 'com.google.firebase:firebase-messaging:17.+' - testCompile 'junit:junit:4.12' + def supportLibVersion = rootProject.hasProperty('supportLibVersion') ? rootProject.supportLibVersion : DEFAULT_SUPPORT_LIB_VERSION + + implementation fileTree(include: ['*.jar'], dir: 'libs') + implementation 'com.twilio:voice-android:5.0.2' + implementation "com.android.support:appcompat-v7:$supportLibVersion" + implementation 'com.facebook.react:react-native:+' + implementation 'com.google.firebase:firebase-messaging:17.+' + testImplementation 'junit:junit:4.12' } 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/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index 04e285f3..f6405f2b 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-2.14.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-5.4.1-all.zip diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index e69de29b..00000000 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 bace1249..eb861191 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java @@ -15,12 +15,13 @@ import android.os.Build; import android.os.Bundle; import android.service.notification.StatusBarNotification; -import android.support.v4.app.NotificationCompat; +import androidx.core.app.NotificationCompat; import android.util.Log; import android.view.WindowManager; import com.facebook.react.bridge.ReactApplicationContext; import com.twilio.voice.CallInvite; +import com.twilio.voice.CancelledCallInvite; import java.util.List; @@ -307,12 +308,12 @@ public void createHangupLocalNotification(ReactApplicationContext context, Strin } public void removeIncomingCallNotification(ReactApplicationContext context, - CallInvite callInvite, + CancelledCallInvite callInvite, int notificationId) { Log.d(TAG, "removeIncomingCallNotification"); 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 800ef6db..af7a98aa 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java @@ -1,6 +1,6 @@ package com.hoxfon.react.RNTwilioVoice; -import android.support.annotation.Nullable; +import androidx.annotation.Nullable; import android.util.Log; import com.facebook.react.bridge.ReactApplicationContext; 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 774ad936..25c4f7b2 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java @@ -15,9 +15,10 @@ import android.media.AudioManager; import android.os.Build; -import android.support.v4.app.ActivityCompat; -import android.support.v4.content.ContextCompat; -import android.support.v4.content.LocalBroadcastManager; +import androidx.annotation.NonNull; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import android.util.Log; import android.view.Window; import android.view.WindowManager; @@ -38,11 +39,14 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; -import com.google.firebase.FirebaseApp; +import com.google.android.gms.tasks.OnCompleteListener; +import com.google.android.gms.tasks.Task; import com.google.firebase.iid.FirebaseInstanceId; +import com.google.firebase.iid.InstanceIdResult; import com.twilio.voice.Call; import com.twilio.voice.CallException; import com.twilio.voice.CallInvite; +import com.twilio.voice.ConnectOptions; import com.twilio.voice.LogLevel; import com.twilio.voice.RegistrationException; import com.twilio.voice.RegistrationListener; @@ -75,8 +79,10 @@ 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_CANCEL_CALL = "com.hoxfon.react.TwilioVoice.CANCEL_CALL"; public static final String ACTION_FCM_TOKEN = "com.hoxfon.react.TwilioVoice.ACTION_FCM_TOKEN"; public static final String ACTION_MISSED_CALL = "com.hoxfon.react.TwilioVoice.MISSED_CALL"; public static final String ACTION_ANSWER_CALL = "com.hoxfon.react.TwilioVoice.ANSWER_CALL"; @@ -176,7 +182,7 @@ public void onHostPause() { public void onHostDestroy() { disconnect(); callNotificationManager.removeHangupNotification(getReactApplicationContext()); - unsetAudioFocus(); + setAudioFocus(false); } @Override @@ -214,12 +220,30 @@ 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) { + Log.d(TAG, "Ringing"); + } + @Override public void onConnected(Call call) { if (BuildConfig.DEBUG) { Log.d(TAG, "CALL CONNECTED callListener().onConnected call state = "+call.getState()); } - setAudioFocus(); + setAudioFocus(true); proximityManager.startProximitySensor(); headsetManager.startWiredHeadsetEvent(getReactApplicationContext()); @@ -241,10 +265,20 @@ public void onConnected(Call call) { } eventManager.sendEvent(EVENT_CONNECTION_DID_CONNECT, params); } + + @Override + public void onReconnecting(Call call, CallException callException) { + Log.d(TAG, "onReconnecting"); + } + + @Override + public void onReconnected(Call call) { + Log.d(TAG, "onReconnected"); + } @Override public void onDisconnected(Call call, CallException error) { - unsetAudioFocus(); + setAudioFocus(false); proximityManager.stopProximitySensor(); headsetManager.stopWiredHeadsetEvent(getReactApplicationContext()); callAccepted = false; @@ -278,7 +312,7 @@ public void onDisconnected(Call call, CallException error) { @Override public void onConnectFailure(Call call, CallException error) { - unsetAudioFocus(); + setAudioFocus(false); proximityManager.stopProximitySensor(); callAccepted = false; if (BuildConfig.DEBUG) { @@ -386,7 +420,7 @@ private void handleIncomingCallIntent(Intent intent) { if (intent.getAction().equals(ACTION_INCOMING_CALL)) { activeCallInvite = intent.getParcelableExtra(INCOMING_CALL_INVITE); - if (activeCallInvite != null && (activeCallInvite.getState() == CallInvite.State.PENDING)) { + if (activeCallInvite != null) { /* && (activeCallInvite.getState() == CallInvite.State.PENDING)) {*/ callAccepted = false; if (BuildConfig.DEBUG) { Log.d(TAG, "handleIncomingCallIntent state = PENDING"); @@ -409,57 +443,45 @@ private void handleIncomingCallIntent(Intent intent) { 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", "PENDING"); eventManager.sendEvent(EVENT_DEVICE_DID_RECEIVE_INCOMING, params); } - - } else { - 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(); + } + } else if (intent.getAction().equals(ACTION_CANCEL_CALL)) { + 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 - // the call is not active yet - if (activeCall == null) { + SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); - 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); - } - } + 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, "====> END"); + Log.d(TAG, "creating a missed call"); + } + 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", "DISCONNECTED"); + eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); } } + + clearIncomingNotification(activeCallInvite); } else if (intent.getAction().equals(ACTION_FCM_TOKEN)) { if (BuildConfig.DEBUG) { Log.d(TAG, "handleIncomingCallIntent ACTION_FCM_TOKEN"); @@ -475,7 +497,7 @@ public void onReceive(Context context, Intent intent) { String action = intent.getAction(); if (action.equals(ACTION_INCOMING_CALL)) { if (BuildConfig.DEBUG) { - Log.d(TAG, "VoiceBroadcastReceiver.onReceive ACTION_INCOMING_CALL. Intent "+ intent.getExtras()); + Log.d(TAG, "VoiceBroadcastReceiver.onReceive ACTION_INCOMING_CALL. Intent " + intent.getExtras()); } handleIncomingCallIntent(intent); } else if (action.equals(ACTION_MISSED_CALL)) { @@ -494,15 +516,16 @@ public void initWithAccessToken(final String accessToken, Promise promise) { if (accessToken.equals("")) { promise.reject(new JSApplicationIllegalArgumentException("Invalid access token")); return; - } - + } + if(!checkPermissionForMicrophone()) { - promise.reject(new AssertionException("Can't init without microphone permission")); - } + promise.reject(new AssertionException("Allow microphone permission")); + return; + } TwilioVoiceModule.this.accessToken = accessToken; if (BuildConfig.DEBUG) { - Log.d(TAG, "initWithAccessToken ACTION_FCM_TOKEN"); + Log.d(TAG, "initWithAccessToken"); } registerForCallInvites(); WritableMap params = Arguments.createMap(); @@ -512,7 +535,7 @@ public void initWithAccessToken(final String accessToken, Promise promise) { private void clearIncomingNotification(CallInvite callInvite) { if (BuildConfig.DEBUG) { - Log.d(TAG, "clearIncomingNotification() callInvite state: "+ callInvite.getState()); + // Log.d(TAG, "clearIncomingNotification() callInvite state: "+ callInvite.getState()); } if (callInvite != null && callInvite.getCallSid() != null) { // remove incoming call notification @@ -533,20 +556,28 @@ private void clearIncomingNotification(CallInvite callInvite) { * If a valid google-services.json has not been provided or the FirebaseInstanceId has not been * initialized the fcmToken will be null. * - * In the case where the FirebaseInstanceId has not yet been initialized the - * VoiceFirebaseInstanceIDService.onTokenRefresh should result in a LocalBroadcast to this - * activity which will attempt registerForCallInvites again. - * */ private void registerForCallInvites() { - FirebaseApp.initializeApp(getReactApplicationContext()); - final String fcmToken = FirebaseInstanceId.getInstance().getToken(); - if (fcmToken != null) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "Registering with FCM"); - } - Voice.register(getReactApplicationContext(), accessToken, Voice.RegistrationChannel.FCM, fcmToken, registrationListener); - } + FirebaseInstanceId.getInstance().getInstanceId() + .addOnCompleteListener(new OnCompleteListener() { + @Override + public void onComplete(@NonNull Task task) { + if (!task.isSuccessful()) { + Log.w(TAG, "getInstanceId failed", task.getException()); + return; + } + + // Get new Instance ID token + String fcmToken = task.getResult().getToken(); + if (fcmToken != null) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Registering with FCM"); + } + Voice.register(accessToken, Voice.RegistrationChannel.FCM, fcmToken, registrationListener); + } + } + }); + } @ReactMethod @@ -554,26 +585,26 @@ public void accept() { callAccepted = true; SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); if (activeCallInvite != null){ - if (activeCallInvite.getState() == CallInvite.State.PENDING) { + // if (activeCallInvite.getState() == CallInvite.State.PENDING) { if (BuildConfig.DEBUG) { Log.d(TAG, "accept() activeCallInvite.getState() PENDING"); } activeCallInvite.accept(getReactApplicationContext(), callListener); clearIncomingNotification(activeCallInvite); - } else { + // } 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); - } + // 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); + // } } else { eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, null); } @@ -588,7 +619,7 @@ public void reject() { 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", "REJECTED"); activeCallInvite.reject(getReactApplicationContext()); clearIncomingNotification(activeCallInvite); } @@ -604,7 +635,7 @@ public void ignore() { 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", "BUSY"); clearIncomingNotification(activeCallInvite); } eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); @@ -661,7 +692,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 @@ -702,13 +737,15 @@ public void getActiveCall(Promise promise) { } if (activeCallInvite != null) { if (BuildConfig.DEBUG) { - Log.d(TAG, "Active call invite found state = "+activeCallInvite.getState()); + // Log.d(TAG, "Active call invite found state = "+activeCallInvite.getState()); } 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()); + if (activeCall != null) { + params.putString("call_state", activeCall.getState().name()); + } promise.resolve(params); return; } @@ -722,53 +759,46 @@ public void setSpeakerPhone(Boolean value) { audioManager.setSpeakerphoneOn(value); } - private void setAudioFocus() { - if (audioManager == null) { - return; - } - originalAudioMode = audioManager.getMode(); - // Request audio focus before making any device switch - if (Build.VERSION.SDK_INT >= 26) { - AudioAttributes playbackAttributes = new AudioAttributes.Builder() - .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) - .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) - .build(); - focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE) - .setAudioAttributes(playbackAttributes) - .setAcceptsDelayedFocusGain(true) - .setOnAudioFocusChangeListener(new AudioManager.OnAudioFocusChangeListener() { - @Override - public void onAudioFocusChange(int i) { } - }) - .build(); - audioManager.requestAudioFocus(focusRequest); - } else { - audioManager.requestAudioFocus( - null, - AudioManager.STREAM_VOICE_CALL, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT_EXCLUSIVE - ); - } - /* - * Start by setting MODE_IN_COMMUNICATION as default audio mode. It is - * required to be in this mode when playout and/or recording starts for - * best possible VoIP performance. Some devices have difficulties with speaker mode - * if this is not set. - */ - audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); - } + private void setAudioFocus(boolean setFocus) { + if (audioManager != null) { + if (setFocus) { + savedAudioMode = audioManager.getMode(); + // Request audio focus before making any device switch. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + AudioAttributes playbackAttributes = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION) + .setContentType(AudioAttributes.CONTENT_TYPE_SPEECH) + .build(); + AudioFocusRequest focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) + .setAudioAttributes(playbackAttributes) + .setAcceptsDelayedFocusGain(true) + .setOnAudioFocusChangeListener(new AudioManager.OnAudioFocusChangeListener() { + @Override + public void onAudioFocusChange(int i) { + } + }) + .build(); + audioManager.requestAudioFocus(focusRequest); + } else { + int focusRequestResult = audioManager.requestAudioFocus(new AudioManager.OnAudioFocusChangeListener() { - private void unsetAudioFocus() { - if (audioManager == null) { - return; - } - audioManager.setMode(originalAudioMode); - if (Build.VERSION.SDK_INT >= 26) { - if (focusRequest != null) { - audioManager.abandonAudioFocusRequest(focusRequest); + @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 + * required to be in this mode when playout and/or recording starts for + * best possible VoIP performance. Some devices have difficulties with speaker mode + * if this is not set. + */ + audioManager.setMode(AudioManager.MODE_IN_COMMUNICATION); + } else { + audioManager.setMode(savedAudioMode); + audioManager.abandonAudioFocus(null); } - } else { - audioManager.abandonAudioFocus(null); } } @@ -778,14 +808,15 @@ private boolean checkPermissionForMicrophone() { } private void requestPermissionForMicrophone() { - if (getCurrentActivity() != null) { - if (ActivityCompat.shouldShowRequestPermissionRationale(getCurrentActivity(), Manifest.permission.RECORD_AUDIO)) { - // Snackbar.make(coordinatorLayout, - // "Microphone permissions needed. Please allow in your application settings.", - // SNACKBAR_DURATION).show(); - } else { - ActivityCompat.requestPermissions(getCurrentActivity(), new String[]{Manifest.permission.RECORD_AUDIO}, MIC_PERMISSION_REQUEST_CODE); - } + if (getCurrentActivity() == null) { + return; + } + if (ActivityCompat.shouldShowRequestPermissionRationale(getCurrentActivity(), Manifest.permission.RECORD_AUDIO)) { +// Snackbar.make(coordinatorLayout, +// "Microphone permissions needed. Please allow in your application settings.", +// SNACKBAR_DURATION).show(); + } else { + ActivityCompat.requestPermissions(getCurrentActivity(), new String[]{Manifest.permission.RECORD_AUDIO}, MIC_PERMISSION_REQUEST_CODE); } } } diff --git a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/fcm/VoiceFirebaseInstanceIDService.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/fcm/VoiceFirebaseInstanceIDService.java deleted file mode 100644 index 9154661c..00000000 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/fcm/VoiceFirebaseInstanceIDService.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.hoxfon.react.RNTwilioVoice.fcm; - -import android.content.Intent; -import android.support.v4.content.LocalBroadcastManager; -import android.util.Log; - -import com.google.firebase.iid.FirebaseInstanceId; -import com.google.firebase.iid.FirebaseInstanceIdService; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_FCM_TOKEN; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.TAG; - -public class VoiceFirebaseInstanceIDService extends FirebaseInstanceIdService { - - /** - * Called if InstanceID token is updated. This may occur if the security of - * the previous token had been compromised. Note that this is called when the InstanceID token - * is initially generated so this is where you would retrieve the token. - */ - // [START refresh_token] - @Override - public void onTokenRefresh() { - // Get updated InstanceID token. - String refreshedToken = FirebaseInstanceId.getInstance().getToken(); - Log.d(TAG, "Refreshed token: " + refreshedToken); - - // Notify Activity of FCM token - Intent intent = new Intent(ACTION_FCM_TOKEN); - LocalBroadcastManager.getInstance(this).sendBroadcast(intent); - } - // [END refresh_token] - - /** - * Persist token to third-party servers. - * - * Modify this method to associate the user's FCM InstanceID token with any server-side account - * maintained by your application. - * - * @param token The new token. - */ - private void sendRegistrationToServer(String token) { - // TODO: Implement this method to send token to your app server. - } -} 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 b50facdc..f1841f0e 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 @@ -4,10 +4,10 @@ import android.app.ActivityManager; import android.content.Intent; -import android.os.Build; +import android.os.Bundle; import android.os.Handler; import android.os.Looper; -import android.support.v4.content.LocalBroadcastManager; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; import android.util.Log; import com.facebook.react.ReactApplication; @@ -19,8 +19,9 @@ import com.google.firebase.messaging.RemoteMessage; import com.hoxfon.react.RNTwilioVoice.BuildConfig; import com.hoxfon.react.RNTwilioVoice.CallNotificationManager; +import com.twilio.voice.CallException; 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; @@ -28,8 +29,11 @@ import java.util.Random; 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; 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; @@ -43,6 +47,15 @@ public void onCreate() { callNotificationManager = new CallNotificationManager(); } + @Override + public void onNewToken(String token) { + Log.d(TAG, "Refreshed token: " + token); + + // Notify Activity of FCM token + Intent intent = new Intent(ACTION_FCM_TOKEN); + LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + } + /** * Called when message is received. * @@ -51,7 +64,9 @@ public void onCreate() { @Override public void onMessageReceived(RemoteMessage remoteMessage) { if (BuildConfig.DEBUG) { + Log.d(TAG, "Received onMessageReceived()"); Log.d(TAG, "Bundle data: " + remoteMessage.getData()); + Log.d(TAG, "From: " + remoteMessage.getFrom()); } // Check if message contains a data payload. @@ -62,8 +77,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(getApplicationContext(), data, new MessageListener() { @Override public void onCallInvite(final CallInvite callInvite) { @@ -117,10 +131,23 @@ 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, final CallException callException) { + Handler handler = new Handler(Looper.getMainLooper()); + handler.post(new Runnable() { + public void run() { + ReactInstanceManager mReactInstanceManager = ((ReactApplication) getApplication()).getReactNativeHost().getReactInstanceManager(); + ReactContext context = mReactInstanceManager.getCurrentReactContext(); + VoiceFirebaseMessagingService.this.cancelNotification((ReactApplicationContext)context, cancelledCallInvite); + VoiceFirebaseMessagingService.this.sendCancelledCallInviteToActivity( + cancelledCallInvite); + } + }); } }); + + if (!valid) { + Log.e(TAG, "Error handling FCM message"); + } } // Check if message contains a notification payload. @@ -152,6 +179,15 @@ private void sendIncomingCallMessageToActivity( LocalBroadcastManager.getInstance(context).sendBroadcast(intent); } + /* + * Send the CancelledCallInvite to the VoiceActivity + */ + private void sendCancelledCallInviteToActivity(CancelledCallInvite cancelledCallInvite) { + Intent intent = new Intent(ACTION_CANCEL_CALL); + intent.putExtra(CANCELLED_CALL_INVITE, cancelledCallInvite); + LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + } + /* * Show the notification in the Android notification drawer */ @@ -161,11 +197,12 @@ private void showNotification(ReactApplicationContext context, 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); - } + callNotificationManager.createIncomingCallNotification(context, callInvite, notificationId, launchIntent); } + + private void cancelNotification(ReactApplicationContext context, CancelledCallInvite cancelledCallInvite) { + SoundPoolManager.getInstance((this)).stopRinging(); + callNotificationManager.removeIncomingCallNotification(context, cancelledCallInvite, 0); + } + } diff --git a/ios/RNTwilioVoice/RNTwilioVoice.m b/ios/RNTwilioVoice/RNTwilioVoice.m index f7d725fa..b7ced30b 100644 --- a/ios/RNTwilioVoice/RNTwilioVoice.m +++ b/ios/RNTwilioVoice/RNTwilioVoice.m @@ -16,9 +16,14 @@ @interface RNTwilioVoice () *)supportedEvents { - return @[@"connectionDidConnect", @"connectionDidDisconnect", @"callRejected", @"deviceReady", @"deviceNotReady"]; + return @[@"connectionDidConnect", @"connectionDidDisconnect", @"callRejected", @"deviceReady", @"deviceNotReady", @"deviceDidReceiveIncoming"]; } @synthesize bridge = _bridge; @@ -70,6 +75,7 @@ - (void)dealloc { RCT_EXPORT_METHOD(configureCallKit: (NSDictionary *)params) { if (self.callKitCallController == nil) { + [self initRNTwilioVoice]; _settings = [[NSMutableDictionary alloc] initWithDictionary:params]; CXProviderConfiguration *configuration = [[CXProviderConfiguration alloc] initWithLocalizedName:params[@"appName"]]; configuration.maximumCallGroups = 1; @@ -93,13 +99,11 @@ - (void)dealloc { RCT_EXPORT_METHOD(connect: (NSDictionary *)params) { NSLog(@"Calling phone number %@", [params valueForKey:@"To"]); -// [TwilioVoice setLogLevel:TVOLogLevelVerbose]; - UIDevice* device = [UIDevice currentDevice]; device.proximityMonitoringEnabled = YES; if (self.call && self.call.state == TVOCallStateConnected) { - [self.call disconnect]; + [self performEndCallActionWithUUID:self.call.uuid]; } else { NSUUID *uuid = [NSUUID UUID]; NSString *handle = [params valueForKey:@"To"]; @@ -110,26 +114,27 @@ - (void)dealloc { RCT_EXPORT_METHOD(disconnect) { NSLog(@"Disconnecting call"); + self.userInitiatedDisconnect = YES; [self performEndCallActionWithUUID:self.call.uuid]; } RCT_EXPORT_METHOD(setMuted: (BOOL *)muted) { NSLog(@"Mute/UnMute call"); - self.call.muted = muted; + self.call.muted = muted ? YES : NO; } RCT_EXPORT_METHOD(setSpeakerPhone: (BOOL *)speaker) { - [self toggleAudioRoute:speaker]; + [self toggleAudioRoute:speaker ? YES : NO]; } -RCT_EXPORT_METHOD(sendDigits: (NSString *)digits){ +RCT_EXPORT_METHOD(sendDigits: (NSString *)digits) { if (self.call && self.call.state == TVOCallStateConnected) { NSLog(@"SendDigits %@", digits); [self.call sendDigits:digits]; } } -RCT_EXPORT_METHOD(unregister){ +RCT_EXPORT_METHOD(unregister) { NSLog(@"unregister"); NSString *accessToken = [self fetchAccessToken]; @@ -148,34 +153,29 @@ - (void)dealloc { RCT_REMAP_METHOD(getActiveCall, resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject){ + rejecter:(RCTPromiseRejectBlock)reject) { NSMutableDictionary *params = [[NSMutableDictionary alloc] init]; - if (self.callInvite) { - if (self.callInvite.callSid){ - [params setObject:self.callInvite.callSid forKey:@"call_sid"]; + if (self.activeCallInvites.count) { + TVOCallInvite *callInvite = [self.activeCallInvites valueForKey:[self.activeCallInvites allKeys][self.activeCallInvites.count-1]]; + if (callInvite.callSid) { + [params setObject:callInvite.callSid forKey:@"call_sid"]; } - if (self.callInvite.from){ - [params setObject:self.callInvite.from forKey:@"from"]; + if (callInvite.from) { + [params setObject:callInvite.from forKey:@"call_from"]; } - if (self.callInvite.to){ - [params setObject:self.callInvite.to forKey:@"to"]; - } - if (self.callInvite.state == TVOCallInviteStatePending) { - [params setObject:StatePending forKey:@"call_state"]; - } else if (self.callInvite.state == TVOCallInviteStateCanceled) { - [params setObject:StateDisconnected forKey:@"call_state"]; - } else if (self.callInvite.state == TVOCallInviteStateRejected) { - [params setObject:StateRejected forKey:@"call_state"]; + if (callInvite.to) { + [params setObject:callInvite.to forKey:@"call_to"]; } + [params setObject:StatePending forKey:@"call_state"]; resolve(params); } else if (self.call) { if (self.call.sid) { [params setObject:self.call.sid forKey:@"call_sid"]; } - if (self.call.to){ + if (self.call.to) { [params setObject:self.call.to forKey:@"call_to"]; } - if (self.call.from){ + if (self.call.from) { [params setObject:self.call.from forKey:@"call_from"]; } if (self.call.state == TVOCallStateConnected) { @@ -191,6 +191,19 @@ - (void)dealloc { } } +- (void)initRNTwilioVoice { + /* + * The important thing to remember when providing a TVOAudioDevice is that the device must be set + * before performing any other actions with the SDK (such as connecting a Call, or accepting an incoming Call). + * In this case we've already initialized our own `TVODefaultAudioDevice` instance which we will now set. + */ + self.audioDevice = [TVODefaultAudioDevice audioDevice]; + TwilioVoice.audioDevice = self.audioDevice; + + self.activeCallInvites = [NSMutableDictionary dictionary]; + self.activeCalls = [NSMutableDictionary dictionary]; + } + - (void)initPushRegistry { self.voipRegistry = [[PKPushRegistry alloc] initWithQueue:dispatch_get_main_queue()]; self.voipRegistry.delegate = self; @@ -258,65 +271,113 @@ - (void)pushRegistry:(PKPushRegistry *)registry didInvalidatePushTokenForType:(P } } -- (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(NSString *)type { - NSLog(@"pushRegistry:didReceiveIncomingPushWithPayload:forType"); +- (void)pushRegistry:(PKPushRegistry *)registry didReceiveIncomingPushWithPayload:(PKPushPayload *)payload forType:(PKPushType)type withCompletionHandler:(void (^)(void))completion { + NSLog(@"pushRegistry:didReceiveIncomingPushWithPayload:forType:withCompletionHandler:"); if ([type isEqualToString:PKPushTypeVoIP]) { - [TwilioVoice handleNotification:payload.dictionaryPayload - delegate:self]; - } -} - -#pragma mark - TVONotificationDelegate -- (void)callInviteReceived:(TVOCallInvite *)callInvite { - if (callInvite.state == TVOCallInviteStatePending) { - [self handleCallInviteReceived:callInvite]; - } else if (callInvite.state == TVOCallInviteStateCanceled) { - [self handleCallInviteCanceled:callInvite]; + // The Voice SDK will use main queue to invoke `cancelledCallInviteReceived:error` when delegate queue is not passed + if (![TwilioVoice handleNotification:payload.dictionaryPayload delegate:self delegateQueue:nil]) { + NSLog(@"This is not a valid Twilio Voice notification."); } -} - -- (void)handleCallInviteReceived:(TVOCallInvite *)callInvite { - NSLog(@"callInviteReceived:"); - if (self.callInvite && self.callInvite == TVOCallInviteStatePending) { - NSLog(@"Already a pending incoming call invite."); - NSLog(@" >> Ignoring call from %@", callInvite.from); - return; - } else if (self.call) { - NSLog(@"Already an active call."); - NSLog(@" >> Ignoring call from %@", callInvite.from); - return; } - - self.callInvite = callInvite; - - [self reportIncomingCallFrom:callInvite.from withUUID:callInvite.uuid]; -} - -- (void)handleCallInviteCanceled:(TVOCallInvite *)callInvite { - NSLog(@"callInviteCanceled"); - - [self performEndCallActionWithUUID:callInvite.uuid]; - - NSMutableDictionary *params = [[NSMutableDictionary alloc] init]; - if (self.callInvite.callSid){ - [params setObject:self.callInvite.callSid forKey:@"call_sid"]; + if ([[NSProcessInfo processInfo] operatingSystemVersion].majorVersion < 13) { + // Save for later when the notification is properly handled. + self.incomingPushCompletionCallback = completion; + } else { + /** + * The Voice SDK processes the call notification and returns the call invite synchronously. Report the incoming call to + * CallKit and fulfill the completion before exiting this callback method. + */ + completion(); } +} - if (self.callInvite.from){ - [params setObject:self.callInvite.from forKey:@"from"]; - } - if (self.callInvite.to){ - [params setObject:self.callInvite.to forKey:@"to"]; - } - if (self.callInvite.state == TVOCallInviteStateCanceled) { - [params setObject:StateDisconnected forKey:@"call_state"]; - } else if (self.callInvite.state == TVOCallInviteStateRejected) { - [params setObject:StateRejected forKey:@"call_state"]; - } - [self sendEventWithName:@"connectionDidDisconnect" body:params]; +- (void)incomingPushHandled { + if (self.incomingPushCompletionCallback) { + self.incomingPushCompletionCallback(); + self.incomingPushCompletionCallback = nil; + } +} - self.callInvite = nil; +#pragma mark - TVONotificationDelegate +- (void)callInviteReceived:(TVOCallInvite *)callInvite { + /** + * Calling `[TwilioVoice handleNotification:delegate:]` will synchronously process your notification payload and + * provide you a `TVOCallInvite` object. Report the incoming call to CallKit upon receiving this callback. + */ + + NSLog(@"callInviteReceived:"); + + NSString *from = @"Unknown"; + if (callInvite.from) { + from = [callInvite.from stringByReplacingOccurrencesOfString:@"client:" withString:@""]; + } + + // Always report to CallKit + [self reportIncomingCallFrom:from withUUID:callInvite.uuid]; + self.activeCallInvites[[callInvite.uuid UUIDString]] = callInvite; + if ([[NSProcessInfo processInfo] operatingSystemVersion].majorVersion < 13) { + [self incomingPushHandled]; + } + + NSMutableDictionary *params = [[NSMutableDictionary alloc] init]; + if (callInvite.callSid) { + [params setObject:callInvite.callSid forKey:@"call_sid"]; + } + + if (callInvite.from) { + [params setObject:callInvite.from forKey:@"call_from"]; + } + if (callInvite.to) { + [params setObject:callInvite.to forKey:@"call_to"]; + } + + [params setObject:StatePending forKey:@"call_state"]; + + [self sendEventWithName:@"deviceDidReceiveIncoming" body:params]; +} + +- (void)cancelledCallInviteReceived:(TVOCancelledCallInvite *)cancelledCallInvite error:(NSError *)error { + + /** + * The SDK may call `[TVONotificationDelegate callInviteReceived:error:]` asynchronously on the dispatch queue + * with a `TVOCancelledCallInvite` if the caller hangs up or the client encounters any other error before the called + * party could answer or reject the call. + */ + + NSLog(@"cancelledCallInviteReceived:"); + + TVOCallInvite *callInvite; + for (NSString *activeCallInviteId in self.activeCallInvites) { + TVOCallInvite *activeCallInvite = [self.activeCallInvites objectForKey:activeCallInviteId]; + if ([cancelledCallInvite.callSid isEqualToString:activeCallInvite.callSid]) { + callInvite = activeCallInvite; + break; + } + } + + if (callInvite) { + [self performEndCallActionWithUUID:callInvite.uuid]; + + NSMutableDictionary *params = [[NSMutableDictionary alloc] init]; + if (callInvite.callSid) { + [params setObject:callInvite.callSid forKey:@"call_sid"]; + } + if (callInvite.from) { + [params setObject:callInvite.from forKey:@"call_from"]; + } + if (callInvite.to) { + [params setObject:callInvite.to forKey:@"call_to"]; + } + + if (error.code == TVOErrorCallCancelledError) { + [params setObject:StateDisconnected forKey:@"call_state"]; + } else { + [params setObject:StateRejected forKey:@"call_state"]; + } + + [self sendEventWithName:@"connectionDidDisconnect" body:params]; + } } - (void)notificationError:(NSError *)error { @@ -325,9 +386,8 @@ - (void)notificationError:(NSError *)error { #pragma mark - TVOCallDelegate - (void)callDidConnect:(TVOCall *)call { - self.call = call; + NSLog(@"callDidConnect:"); self.callKitCompletionCallback(YES); - self.callKitCompletionCallback = nil; NSMutableDictionary *callParams = [[NSMutableDictionary alloc] init]; [callParams setObject:call.sid forKey:@"call_sid"]; @@ -337,11 +397,11 @@ - (void)callDidConnect:(TVOCall *)call { [callParams setObject:StateConnected forKey:@"call_state"]; } - if (call.from){ - [callParams setObject:call.from forKey:@"from"]; + if (call.from) { + [callParams setObject:call.from forKey:@"call_from"]; } - if (call.to){ - [callParams setObject:call.to forKey:@"to"]; + if (call.to) { + [callParams setObject:call.to forKey:@"call_to"]; } [self sendEventWithName:@"connectionDidConnect" body:callParams]; } @@ -351,67 +411,87 @@ - (void)call:(TVOCall *)call didFailToConnectWithError:(NSError *)error { self.callKitCompletionCallback(NO); [self performEndCallActionWithUUID:call.uuid]; - [self callDisconnected:error]; + [self call:call disconnectedWithError:error]; } - (void)call:(TVOCall *)call didDisconnectWithError:(NSError *)error { - NSLog(@"Call disconnected with error: %@", error); - - [self performEndCallActionWithUUID:call.uuid]; - [self callDisconnected:error]; -} - -- (void)callDisconnected:(NSError *)error { - NSMutableDictionary *params = [[NSMutableDictionary alloc] init]; if (error) { - NSString* errMsg = [error localizedDescription]; - if (error.localizedFailureReason) { - errMsg = [error localizedFailureReason]; - } - [params setObject:errMsg forKey:@"error"]; - } - if (self.call.sid) { - [params setObject:self.call.sid forKey:@"call_sid"]; - } - if (self.call.to){ - [params setObject:self.call.to forKey:@"call_to"]; - } - if (self.call.from){ - [params setObject:self.call.from forKey:@"call_from"]; + NSLog(@"Call failed: %@", error); + } else { + NSLog(@"Call disconnected"); } - if (self.call.state == TVOCallStateDisconnected) { - [params setObject:StateDisconnected forKey:@"call_state"]; + + if (!self.userInitiatedDisconnect) { + CXCallEndedReason reason = CXCallEndedReasonRemoteEnded; + if (error) { + reason = CXCallEndedReasonFailed; + } + + [self.callKitProvider reportCallWithUUID:call.uuid endedAtDate:[NSDate date] reason:reason]; } - [self sendEventWithName:@"connectionDidDisconnect" body:params]; - self.call = nil; - self.callKitCompletionCallback = nil; -} + [self call:call disconnectedWithError:error]; +} + +- (void)call:(TVOCall *)call disconnectedWithError:(NSError *)error { + if ([call isEqual:self.call]) { + self.call = nil; + } + [self.activeCalls removeObjectForKey:call.uuid.UUIDString]; + + self.userInitiatedDisconnect = NO; + + NSMutableDictionary *params = [[NSMutableDictionary alloc] init]; + if (error) { + NSString* errMsg = [error localizedDescription]; + if (error.localizedFailureReason) { + errMsg = [error localizedFailureReason]; + } + [params setObject:errMsg forKey:@"err"]; + } + if (call.sid) { + [params setObject:call.sid forKey:@"call_sid"]; + } + if (call.to) { + [params setObject:call.to forKey:@"call_to"]; + } + if (call.from) { + [params setObject:call.from forKey:@"call_from"]; + } + if (call.state == TVOCallStateDisconnected) { + [params setObject:StateDisconnected forKey:@"call_state"]; + } + [self sendEventWithName:@"connectionDidDisconnect" body:params]; + } #pragma mark - AVAudioSession -- (void)toggleAudioRoute: (BOOL *)toSpeaker { +- (void)toggleAudioRoute: (BOOL)toSpeaker { // The mode set by the Voice SDK is "VoiceChat" so the default audio route is the built-in receiver. // Use port override to switch the route. - NSError *error = nil; - NSLog(@"toggleAudioRoute"); - - if (toSpeaker) { - if (![[AVAudioSession sharedInstance] overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker - error:&error]) { - NSLog(@"Unable to reroute audio: %@", [error localizedDescription]); - } - } else { - if (![[AVAudioSession sharedInstance] overrideOutputAudioPort:AVAudioSessionPortOverrideNone - error:&error]) { - NSLog(@"Unable to reroute audio: %@", [error localizedDescription]); - } - } + self.audioDevice.block = ^ { + // We will execute `kDefaultAVAudioSessionConfigurationBlock` first. + kTVODefaultAVAudioSessionConfigurationBlock(); + + // Overwrite the audio route + AVAudioSession *session = [AVAudioSession sharedInstance]; + NSError *error = nil; + if (toSpeaker) { + if (![session overrideOutputAudioPort:AVAudioSessionPortOverrideSpeaker error:&error]) { + NSLog(@"Unable to reroute audio: %@", [error localizedDescription]); + } + } else { + if (![session overrideOutputAudioPort:AVAudioSessionPortOverrideNone error:&error]) { + NSLog(@"Unable to reroute audio: %@", [error localizedDescription]); + } + } + }; + self.audioDevice.block(); } #pragma mark - CXProviderDelegate - (void)providerDidReset:(CXProvider *)provider { NSLog(@"providerDidReset"); - TwilioVoice.audioEnabled = YES; + self.audioDevice.enabled = YES; } - (void)providerDidBegin:(CXProvider *)provider { @@ -420,12 +500,11 @@ - (void)providerDidBegin:(CXProvider *)provider { - (void)provider:(CXProvider *)provider didActivateAudioSession:(AVAudioSession *)audioSession { NSLog(@"provider:didActivateAudioSession"); - TwilioVoice.audioEnabled = YES; + self.audioDevice.enabled = YES; } - (void)provider:(CXProvider *)provider didDeactivateAudioSession:(AVAudioSession *)audioSession { NSLog(@"provider:didDeactivateAudioSession"); - TwilioVoice.audioEnabled = NO; } - (void)provider:(CXProvider *)provider timedOutPerformingAction:(CXAction *)action { @@ -435,8 +514,8 @@ - (void)provider:(CXProvider *)provider timedOutPerformingAction:(CXAction *)act - (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallAction *)action { NSLog(@"provider:performStartCallAction"); - [TwilioVoice configureAudioSession]; - TwilioVoice.audioEnabled = NO; + self.audioDevice.enabled = NO; + self.audioDevice.block(); [self.callKitProvider reportOutgoingCallWithUUID:action.callUUID startedConnectingAtDate:[NSDate date]]; @@ -455,14 +534,9 @@ - (void)provider:(CXProvider *)provider performStartCallAction:(CXStartCallActio - (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAction *)action { NSLog(@"provider:performAnswerCallAction"); - // RCP: Workaround from https://forums.developer.apple.com/message/169511 suggests configuring audio in the - // completion block of the `reportNewIncomingCallWithUUID:update:completion:` method instead of in - // `provider:performAnswerCallAction:` per the WWDC examples. - // [TwilioVoice configureAudioSession]; + self.audioDevice.enabled = NO; + self.audioDevice.block(); - NSAssert([self.callInvite.uuid isEqual:action.callUUID], @"We only support one Invite at a time."); - - TwilioVoice.audioEnabled = NO; [self performAnswerVoiceCallWithUUID:action.callUUID completion:^(BOOL success) { if (success) { [action fulfill]; @@ -477,28 +551,43 @@ - (void)provider:(CXProvider *)provider performAnswerCallAction:(CXAnswerCallAct - (void)provider:(CXProvider *)provider performEndCallAction:(CXEndCallAction *)action { NSLog(@"provider:performEndCallAction"); - TwilioVoice.audioEnabled = NO; + TVOCallInvite *callInvite = self.activeCallInvites[action.callUUID.UUIDString]; + TVOCall *call = self.activeCalls[action.callUUID.UUIDString]; - if (self.callInvite && self.callInvite.state == TVOCallInviteStatePending) { + if (callInvite) { [self sendEventWithName:@"callRejected" body:@"callRejected"]; - [self.callInvite reject]; - self.callInvite = nil; - } else if (self.call) { - [self.call disconnect]; + [callInvite reject]; + [self.activeCallInvites removeObjectForKey:callInvite.uuid.UUIDString]; + } else if (call) { + [call disconnect]; + } else { + NSLog(@"Unknown UUID to perform end-call action with"); } + self.audioDevice.enabled = YES; [action fulfill]; } - (void)provider:(CXProvider *)provider performSetHeldCallAction:(CXSetHeldCallAction *)action { - if (self.call && self.call.state == TVOCallStateConnected) { - [self.call setOnHold:action.isOnHold]; + TVOCall *call = self.activeCalls[action.callUUID.UUIDString]; + if (call) { + [call setOnHold:action.isOnHold]; [action fulfill]; } else { [action fail]; } } +- (void)provider:(CXProvider *)provider performSetMutedCallAction:(CXSetMutedCallAction *)action { + TVOCall *call = self.activeCalls[action.callUUID.UUIDString]; + if (call) { + [call setMuted:action.isMuted]; + [action fulfill]; + } else { + [action fail]; + } +} + #pragma mark - CallKit Actions - (void)performStartCallActionWithUUID:(NSUUID *)uuid handle:(NSString *)handle { if (uuid == nil || handle == nil) { @@ -542,9 +631,6 @@ - (void)reportIncomingCallFrom:(NSString *)from withUUID:(NSUUID *)uuid { [self.callKitProvider reportNewIncomingCallWithUUID:uuid update:callUpdate completion:^(NSError *error) { if (!error) { NSLog(@"Incoming call successfully reported"); - - // RCP: Workaround per https://forums.developer.apple.com/message/169511 - [TwilioVoice configureAudioSession]; } else { NSLog(@"Failed to report incoming call successfully: %@.", [error localizedDescription]); } @@ -575,20 +661,43 @@ - (void)performVoiceCallWithUUID:(NSUUID *)uuid client:(NSString *)client completion:(void(^)(BOOL success))completionHandler { - self.call = [TwilioVoice call:[self fetchAccessToken] - params:_callParams - uuid:uuid - delegate:self]; - + __weak typeof(self) weakSelf = self; + TVOConnectOptions *connectOptions = [TVOConnectOptions optionsWithAccessToken:[self fetchAccessToken] block:^(TVOConnectOptionsBuilder *builder) { + __strong typeof(self) strongSelf = weakSelf; + builder.params = strongSelf->_callParams; + builder.uuid = uuid; + }]; + TVOCall *call = [TwilioVoice connectWithOptions:connectOptions delegate:self]; + if (call) { + self.call = call; + self.activeCalls[call.uuid.UUIDString] = call; + } self.callKitCompletionCallback = completionHandler; } - (void)performAnswerVoiceCallWithUUID:(NSUUID *)uuid completion:(void(^)(BOOL success))completionHandler { + TVOCallInvite *callInvite = self.activeCallInvites[uuid.UUIDString]; + NSAssert(callInvite, @"No CallInvite matches the UUID"); + TVOAcceptOptions *acceptOptions = [TVOAcceptOptions optionsWithCallInvite:callInvite block:^(TVOAcceptOptionsBuilder *builder) { + builder.uuid = callInvite.uuid; + }]; - self.call = [self.callInvite acceptWithDelegate:self]; - self.callInvite = nil; - self.callKitCompletionCallback = completionHandler; + TVOCall *call = [callInvite acceptWithOptions:acceptOptions delegate:self]; + + if (!call) { + completionHandler(NO); + } else { + self.callKitCompletionCallback = completionHandler; + self.call = call; + self.activeCalls[call.uuid.UUIDString] = call; + } + + [self.activeCallInvites removeObjectForKey:callInvite.uuid.UUIDString]; + + if ([[NSProcessInfo processInfo] operatingSystemVersion].majorVersion < 13) { + [self incomingPushHandled]; + } } - (void)handleAppTerminateNotification { diff --git a/package.json b/package.json index 30fc16e1..769eeede 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-twilio-programmable-voice", - "version": "3.21.3", + "version": "4.0.0", "description": "React Native wrapper for Twilio Programmable Voice SDK", "main": "index.js", "scripts": {