diff --git a/README.md b/README.md index e9061d65..9e30eb8e 100644 --- a/README.md +++ b/README.md @@ -16,12 +16,13 @@ Tested with: The most updated branch is [feat/twilio-android-sdk-5](https://github.com/hoxfon/react-native-twilio-programmable-voice/tree/feat/twilio-android-sdk-5) which is aligned with: -- Android 5.0.2 +- Android 5.4.2 - iOS 5.2.0 It contains breaking changes from `react-native-twilio-programmable-voice` v4, and it will be released as v5. You can install it with: + ```bash # Yarn yarn add https://github.com/hoxfon/react-native-twilio-programmable-voice#feat/twilio-android-sdk-5 @@ -53,6 +54,159 @@ Allow Android to use the built in Android telephony service to make and receive - Android 4.5.0 - iOS 5.2.0 +### Breaking changes in v5.0.0 + +Changes on [Android Twilio Voice SDK v5](https://www.twilio.com/docs/voice/voip-sdk/android/3x-changelog#500) are reflected in the JavaScript API, the way call invites are handled has changed and other v5 features like `audioSwitch` have been implemented. +`setSpeakerPhone()` has been removed from Android, use selectAudioDevice(name: string) instead. + +#### Background incoming calls + +- When the app is not in foreground incoming calls result in a heads-up notification with action to "ACCEPT" and "REJECT". +- ReactMethod `accept` does not dispatch any event. In v4 it dispatched `connectionDidDisconnect`. +- ReactMethod `reject` dispatches a `callInviteCancelled` event instead of `connectionDidDisconnect`. +- ReactMethod `ignore` does not dispatch any event. In v4 it dispatched `connectionDidDisconnect`. + +To show heads up notifications, you must add the following lines to your application's `android/app/src/main/AndroidManifest.xml`: + +```xml + + + + + + + + + + + + + + + + + + + + + +``` + +Firebase Messaging 19.0.+ is imported by this module, so there is no need to import it in your app's `bundle.gradle` file. + +In v4 the flow to launch the app when receiving a call was: + +1. the module launched the app +2. after the React app is initialised, it always asked to the native module whether there were incoming call invites +3. if there were any incoming call invites, the module would have sent an event to the React app with the incoming call invite parameters +4. the Reach app would have listened to the event and would have launched the view with the appropriate incoming call answer/reject controls + +This loop was long and prone to race conditions. For example,when the event was sent before the React main view was completely initialised, it would not be handled at all. + +V5 replaces the previous flow by using `getLaunchOptions()` to pass initial properties from the native module to React, when receiving a call invite as explained here: https://reactnative.dev/docs/communication-android. + +The React app is launched with the initial properties `callInvite` or `call`. + +To handle correctly `lauchedOptions`, you must add the following blocks to your app's `MainActivity`: + +```java + +import com.hoxfon.react.RNTwilioVoice.TwilioModule; +... + +public class MainActivity extends ReactActivity { + + @Override + protected ReactActivityDelegate createReactActivityDelegate() { + return new ReactActivityDelegate(this, getMainComponentName()) { + @Override + protected ReactRootView createRootView() { + return new RNGestureHandlerEnabledRootView(MainActivity.this); + } + @Override + protected Bundle getLaunchOptions() { + return TwilioModule.getActivityLaunchOption(this.getPlainActivity().getIntent()); + } + }; + } + + // ... + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) { + setShowWhenLocked(true); + setTurnScreenOn(true); + } + getWindow().addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + | WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + | WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD); + } + + // ... +} +``` + +#### Audio Switch + +Access to native Twilio SDK AudioSwitch module for Android has been added to the JavaScript API: + +```javascript +// getAudioDevices returns all audio devices connected +// { +// "Speakerphone": false, +// "Earnpiece": true, // true indicates the selected device +// } +getAudioDevices() + +// getSelectedAudioDevice returns the selected audio device +getSelectedAudioDevice() + +// selectAudioDevice selects the passed audio device for the current active call +selectAudioDevice(name: string) +``` + +#### Event deviceDidReceiveIncoming + +When a call invite is received, the [SHAKEN/STIR](https://www.twilio.com/docs/voice/trusted-calling-using-shakenstir) `caller_verification` field has been added to the list of params for `deviceDidReceiveIncoming`. Values are: `verified`, `unverified`, `unknown`. + +## ICE + +See https://www.twilio.com/docs/stun-turn + +```bash +curl -X POST https://api.twilio.com/2010-04-01/Accounts/ACb0b56ae3bf07ce4045620249c3c90b40/Tokens.json \ +-u ACb0b56ae3bf07ce4045620249c3c90b40:f5c84f06e5c02b55fa61696244a17c84 +``` + +```java +Set iceServers = new HashSet<>(); +// server URLs returned by calling the Twilio Rest API to generate a new token +iceServers.add(new IceServer("stun:global.stun.twilio.com:3478?transport=udp")); +iceServers.add(new IceServer("turn:global.turn.twilio.com:3478?transport=udp","8e6467be547b969ad913f7bdcfb73e411b35f648bd19f2c1cb4161b4d4a067be","n8zwmkgjIOphHN93L/aQxnkUp1xJwrZVLKc/RXL0ZpM=")); +iceServers.add(new IceServer("turn:global.turn.twilio.com:3478?transport=tcp","8e6467be547b969ad913f7bdcfb73e411b35f648bd19f2c1cb4161b4d4a067be","n8zwmkgjIOphHN93L/aQxnkUp1xJwrZVLKc/RXL0ZpM=")); +iceServers.add(new IceServer("turn:global.turn.twilio.com:443?transport=tcp","8e6467be547b969ad913f7bdcfb73e411b35f648bd19f2c1cb4161b4d4a067be","n8zwmkgjIOphHN93L/aQxnkUp1xJwrZVLKc/RXL0ZpM=")); + +IceOptions iceOptions = new IceOptions.Builder() + .iceServers(iceServers) + .build(); + +ConnectOptions connectOptions = new ConnectOptions.Builder(accessToken) + .iceOptions(iceOptions) + .enableDscp(true) + .params(twiMLParams) + .build(); +``` + ### Breaking changes in v4.0.0 The module implements [react-native autolinking](https://github.com/react-native-community/cli/blob/master/docs/autolinking.md) as many other native libraries > react-native 0.60.0, therefore it doesn't need to be linked manually. @@ -192,7 +346,8 @@ apply plugin: 'com.google.gms.google-services' + android:name="com.hoxfon.react.RNTwilioVoice.fcm.VoiceFirebaseMessagingService" + android:stopWithTask="false"> @@ -410,7 +565,7 @@ TwilioVoice.getCallInvite() } }) -// Unregister device with Twilio (iOS only) +// Unregister device with Twilio TwilioVoice.unregister() ``` diff --git a/android/build.gradle b/android/build.gradle index fe752fa4..f0033873 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,13 +1,29 @@ // Top-level build file where you can add configuration options common to all sub-projects/modules. buildscript { + + ext.versions = [ + 'java' : JavaVersion.VERSION_1_8, + 'androidGradlePlugin': '4.1.3', + 'googleServices' : '4.3.4', + 'compileSdk' : 31, + 'buildTools' : '30.0.2', + 'minSdk' : 23, + 'targetSdk' : 30, + 'firebase' : '19.0.+', + 'voiceAndroid' : '6.0.0', + 'audioSwitch' : '1.1.2', + 'androidxLifecycle' : '2.2.0', + ] + repositories { google() jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:3.5.4' - classpath 'com.google.gms:google-services:4.3.4' + classpath "com.android.tools.build:gradle:${versions.androidGradlePlugin}" + classpath "com.google.gms:google-services:${versions.googleServices}" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -23,22 +39,16 @@ allprojects { apply plugin: 'com.android.library' -def DEFAULT_MIN_SDK_VERSION = 23 -def DEFAULT_COMPILE_SDK_VERSION = 30 -def DEFAULT_BUILD_TOOLS_VERSION = "29.0.3" -def DEFAULT_TARGET_SDK_VERSION = 29 -def DEFAULT_SUPPORT_LIB_VERSION = "29.0.3" - android { - compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : DEFAULT_COMPILE_SDK_VERSION - buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : DEFAULT_BUILD_TOOLS_VERSION + compileSdkVersion rootProject.hasProperty('compileSdkVersion') ? rootProject.compileSdkVersion : versions.compileSdk + buildToolsVersion rootProject.hasProperty('buildToolsVersion') ? rootProject.buildToolsVersion : versions.buildTools compileOptions { - sourceCompatibility 1.8 - targetCompatibility 1.8 + sourceCompatibility versions.java + targetCompatibility versions.java } defaultConfig { - minSdkVersion rootProject.hasProperty('minSdkVersion') ? rootProject.minSdkVersion : DEFAULT_MIN_SDK_VERSION - targetSdkVersion rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : DEFAULT_TARGET_SDK_VERSION + minSdkVersion rootProject.hasProperty('minSdkVersion') ? rootProject.minSdkVersion : versions.minSdk + targetSdkVersion rootProject.hasProperty('targetSdkVersion') ? rootProject.targetSdkVersion : versions.targetSdk versionCode 1 versionName "1.0" vectorDrawables.useSupportLibrary = true @@ -52,12 +62,11 @@ android { } dependencies { - def supportLibVersion = rootProject.hasProperty('supportLibVersion') ? rootProject.supportLibVersion : DEFAULT_SUPPORT_LIB_VERSION - implementation fileTree(include: ['*.jar'], dir: 'libs') - implementation 'com.twilio:voice-android:4.5.0' - implementation "com.android.support:appcompat-v7:$supportLibVersion" - implementation 'com.facebook.react:react-native:+' - implementation 'com.google.firebase:firebase-messaging:17.6.+' - testImplementation 'junit:junit:4.12' + implementation "com.twilio:audioswitch:${versions.audioSwitch}" + implementation "com.twilio:voice-android:${versions.voiceAndroid}" + implementation "com.facebook.react:react-native:+" + implementation "com.google.firebase:firebase-messaging:${versions.firebase}" + implementation "androidx.lifecycle:lifecycle-extensions:${versions.androidxLifecycle}" + testImplementation "junit:junit:4.12" } diff --git a/android/gradle.properties b/android/gradle.properties index af6dcbe4..a066d344 100644 --- a/android/gradle.properties +++ b/android/gradle.properties @@ -16,4 +16,4 @@ org.gradle.jvmargs=-Xmx1536m # 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 +android.enableJetifier=true diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties index f6405f2b..a24470fe 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-5.4.1-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip 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 af4e9175..44ed14bc 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java @@ -1,5 +1,6 @@ package com.hoxfon.react.RNTwilioVoice; +import android.annotation.TargetApi; import android.app.ActivityManager; import android.app.Notification; import android.app.NotificationChannel; @@ -14,37 +15,17 @@ import android.graphics.Color; import android.os.Build; import android.os.Bundle; -import android.service.notification.StatusBarNotification; -import androidx.core.app.NotificationCompat; import android.util.Log; -import android.view.WindowManager; + +import androidx.core.app.NotificationCompat; import com.facebook.react.bridge.ReactApplicationContext; -import com.twilio.voice.CallInvite; -import com.twilio.voice.CancelledCallInvite; import java.util.List; import static android.content.Context.ACTIVITY_SERVICE; import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.TAG; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_ANSWER_CALL; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_REJECT_CALL; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_HANGUP_CALL; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_INCOMING_CALL; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_MISSED_CALL; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.INCOMING_CALL_INVITE; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.INCOMING_CALL_NOTIFICATION_ID; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.NOTIFICATION_TYPE; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.CALL_SID_KEY; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.INCOMING_NOTIFICATION_PREFIX; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.MISSED_CALLS_GROUP; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.MISSED_CALLS_NOTIFICATION_ID; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.HANGUP_NOTIFICATION_ID; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.PREFERENCE_KEY; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.ACTION_CLEAR_MISSED_CALLS_COUNT; -import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.CLEAR_MISSED_CALLS_NOTIFICATION_ID; - public class CallNotificationManager { @@ -72,7 +53,7 @@ public int getApplicationImportance(ReactApplicationContext context) { return 0; } - public Class getMainActivityClass(ReactApplicationContext context) { + public static Class getMainActivityClass(Context context) { String packageName = context.getPackageName(); Intent launchIntent = context.getPackageManager().getLaunchIntentForPackage(packageName); String className = launchIntent.getComponent().getClassName(); @@ -84,170 +65,69 @@ public Class getMainActivityClass(ReactApplicationContext context) { } } - public Intent getLaunchIntent(ReactApplicationContext context, - int notificationId, - CallInvite callInvite, - Boolean shouldStartNewTask, - int appImportance - ) { - Intent launchIntent = new Intent(context, getMainActivityClass(context)); - - int launchFlag = Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP; - if (shouldStartNewTask || appImportance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { - launchFlag = Intent.FLAG_ACTIVITY_NEW_TASK; - } - - launchIntent.setAction(ACTION_INCOMING_CALL) - .putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId) - .addFlags( - launchFlag + - WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED + - WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD + - WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON + - WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON - ); - - if (callInvite != null) { - launchIntent.putExtra(INCOMING_CALL_INVITE, callInvite); - } - return launchIntent; - } - - public void createIncomingCallNotification(ReactApplicationContext context, - CallInvite callInvite, - int notificationId, - Intent launchIntent) - { + public void createMissedCallNotification(ReactApplicationContext context, String callSid, String callFrom) { if (BuildConfig.DEBUG) { - Log.d(TAG, "createIncomingCallNotification intent "+launchIntent.getFlags()); + Log.d(TAG, "createMissedCallNotification()"); } - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT); - - NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - - /* - * Pass the notification id and call sid to use as an identifier to cancel the - * notification later - */ - Bundle extras = new Bundle(); - extras.putInt(INCOMING_CALL_NOTIFICATION_ID, notificationId); - extras.putString(CALL_SID_KEY, callInvite.getCallSid()); - extras.putString(NOTIFICATION_TYPE, ACTION_INCOMING_CALL); - /* - * Create the notification shown in the notification drawer - */ - initCallNotificationsChannel(notificationManager); - - NotificationCompat.Builder notificationBuilder = - new NotificationCompat.Builder(context, VOICE_CHANNEL) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setSmallIcon(R.drawable.ic_call_white_24dp) - .setContentTitle("Incoming call") - .setContentText(callInvite.getFrom() + " is calling") - .setOngoing(true) - .setAutoCancel(true) - .setExtras(extras) - .setFullScreenIntent(pendingIntent, true); - - // build notification large icon - Resources res = context.getResources(); - int largeIconResId = res.getIdentifier("ic_launcher", "mipmap", context.getPackageName()); - Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId); - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - if (largeIconResId != 0) { - notificationBuilder.setLargeIcon(largeIconBitmap); - } - } - - // Reject action - Intent rejectIntent = new Intent(ACTION_REJECT_CALL) - .putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - PendingIntent pendingRejectIntent = PendingIntent.getBroadcast(context, 1, rejectIntent, - PendingIntent.FLAG_UPDATE_CURRENT); - notificationBuilder.addAction(0, "DISMISS", pendingRejectIntent); - - // Answer action - Intent answerIntent = new Intent(ACTION_ANSWER_CALL); - answerIntent - .putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId) - .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - PendingIntent pendingAnswerIntent = PendingIntent.getBroadcast(context, 0, answerIntent, - PendingIntent.FLAG_UPDATE_CURRENT); - notificationBuilder.addAction(R.drawable.ic_call_white_24dp, "ANSWER", pendingAnswerIntent); - - notificationManager.notify(notificationId, notificationBuilder.build()); - TwilioVoiceModule.callNotificationMap.put(INCOMING_NOTIFICATION_PREFIX+callInvite.getCallSid(), notificationId); - } - - public void initCallNotificationsChannel(NotificationManager notificationManager) { - if (Build.VERSION.SDK_INT < 26) { - return; - } - NotificationChannel channel = new NotificationChannel(VOICE_CHANNEL, - "Primary Voice Channel", NotificationManager.IMPORTANCE_DEFAULT); - channel.setLightColor(Color.GREEN); - channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); - notificationManager.createNotificationChannel(channel); - } - - public void createMissedCallNotification(ReactApplicationContext context, CallInvite callInvite) { - SharedPreferences sharedPref = context.getSharedPreferences(PREFERENCE_KEY, Context.MODE_PRIVATE); + SharedPreferences sharedPref = context.getSharedPreferences(Constants.PREFERENCE_KEY, Context.MODE_PRIVATE); SharedPreferences.Editor sharedPrefEditor = sharedPref.edit(); - /* - * Create a PendingIntent to specify the action when the notification is - * selected in the notification drawer - */ Intent intent = new Intent(context, getMainActivityClass(context)); - intent.setAction(ACTION_MISSED_CALL) - .putExtra(INCOMING_CALL_NOTIFICATION_ID, MISSED_CALLS_NOTIFICATION_ID) + intent.setAction(Constants.ACTION_MISSED_CALL) + .putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, Constants.MISSED_CALLS_NOTIFICATION_ID) .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); - PendingIntent pendingIntent = PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT); - Intent clearMissedCallsCountIntent = new Intent(ACTION_CLEAR_MISSED_CALLS_COUNT) - .putExtra(INCOMING_CALL_NOTIFICATION_ID, CLEAR_MISSED_CALLS_NOTIFICATION_ID); - PendingIntent clearMissedCallsCountPendingIntent = PendingIntent.getBroadcast(context, 0, clearMissedCallsCountIntent, 0); + PendingIntent pendingIntent = PendingIntent.getActivity( + context, + 0, + intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); + + PendingIntent clearMissedCallsCountPendingIntent = PendingIntent.getBroadcast( + context, + 0, + new Intent(Constants.ACTION_CLEAR_MISSED_CALLS_COUNT) + .putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, Constants.CLEAR_MISSED_CALLS_NOTIFICATION_ID), + 0 + ); /* * Pass the notification id and call sid to use as an identifier to open the notification */ Bundle extras = new Bundle(); - extras.putInt(INCOMING_CALL_NOTIFICATION_ID, MISSED_CALLS_NOTIFICATION_ID); - extras.putString(CALL_SID_KEY, callInvite.getCallSid()); - extras.putString(NOTIFICATION_TYPE, ACTION_MISSED_CALL); + extras.putInt(Constants.INCOMING_CALL_NOTIFICATION_ID, Constants.MISSED_CALLS_NOTIFICATION_ID); + extras.putString(Constants.CALL_SID_KEY, callSid); /* * Create the notification shown in the notification drawer */ + String title = context.getString(R.string.call_missed_title); NotificationCompat.Builder notification = new NotificationCompat.Builder(context, VOICE_CHANNEL) - .setGroup(MISSED_CALLS_GROUP) + .setGroup(Constants.MISSED_CALLS_GROUP) .setGroupSummary(true) .setPriority(NotificationCompat.PRIORITY_DEFAULT) .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) .setCategory(NotificationCompat.CATEGORY_MESSAGE) .setSmallIcon(R.drawable.ic_call_missed_white_24dp) - .setContentTitle("Missed call") - .setContentText(callInvite.getFrom() + " called") + .setContentTitle(title) + .setContentText(callFrom + context.getString(R.string.call_missed_from)) .setAutoCancel(true) .setShowWhen(true) .setExtras(extras) .setDeleteIntent(clearMissedCallsCountPendingIntent) .setContentIntent(pendingIntent); - int missedCalls = sharedPref.getInt(MISSED_CALLS_GROUP, 0); + int missedCalls = sharedPref.getInt(Constants.MISSED_CALLS_GROUP, 0); missedCalls++; if (missedCalls == 1) { inboxStyle = new NotificationCompat.InboxStyle(); - inboxStyle.setBigContentTitle("Missed call"); + inboxStyle.setBigContentTitle(title); } else { - inboxStyle.setBigContentTitle(String.valueOf(missedCalls) + " missed calls"); + inboxStyle.setBigContentTitle(String.valueOf(missedCalls) + " " + context.getString(R.string.call_missed_title_plural)); } - inboxStyle.addLine("from: " +callInvite.getFrom()); - sharedPrefEditor.putInt(MISSED_CALLS_GROUP, missedCalls); + inboxStyle.addLine(context.getString(R.string.call_missed_more) + " " + callFrom); + sharedPrefEditor.putInt(Constants.MISSED_CALLS_GROUP, missedCalls); sharedPrefEditor.commit(); notification.setStyle(inboxStyle); @@ -262,96 +142,82 @@ public void createMissedCallNotification(ReactApplicationContext context, CallIn } NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.notify(MISSED_CALLS_NOTIFICATION_ID, notification.build()); + notificationManager.notify(Constants.MISSED_CALLS_NOTIFICATION_ID, notification.build()); } - public void createHangupLocalNotification(ReactApplicationContext context, String callSid, String caller) { - PendingIntent pendingHangupIntent = PendingIntent.getBroadcast( + public void createHangupNotification(ReactApplicationContext context, String callSid, String caller) { + Intent intent = new Intent(context, getMainActivityClass(context)); + intent.setAction(Constants.ACTION_OPEN_CALL_IN_PROGRESS) + .putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, Constants.HANGUP_NOTIFICATION_ID) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + + PendingIntent pendingIntent = PendingIntent.getActivity( context, 0, - new Intent(ACTION_HANGUP_CALL).putExtra(INCOMING_CALL_NOTIFICATION_ID, HANGUP_NOTIFICATION_ID), - PendingIntent.FLAG_UPDATE_CURRENT + intent, + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT ); - Intent launchIntent = new Intent(context, getMainActivityClass(context)); - launchIntent.setAction(ACTION_INCOMING_CALL) - .putExtra(INCOMING_CALL_NOTIFICATION_ID, HANGUP_NOTIFICATION_ID) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - PendingIntent activityPendingIntent = PendingIntent.getActivity(context, 0, launchIntent, PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent hangupPendingIntent = PendingIntent.getBroadcast( + context, + 0, + new Intent(Constants.ACTION_HANGUP_CALL) + .putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, Constants.HANGUP_NOTIFICATION_ID), + PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT + ); - /* - * Pass the notification id and call sid to use as an identifier to cancel the - * notification later - */ Bundle extras = new Bundle(); - extras.putInt(INCOMING_CALL_NOTIFICATION_ID, HANGUP_NOTIFICATION_ID); - extras.putString(CALL_SID_KEY, callSid); - extras.putString(NOTIFICATION_TYPE, ACTION_HANGUP_CALL); + extras.putInt(Constants.INCOMING_CALL_NOTIFICATION_ID, Constants.HANGUP_NOTIFICATION_ID); + extras.putString(Constants.CALL_SID_KEY, callSid); - NotificationCompat.Builder notification = new NotificationCompat.Builder(context, VOICE_CHANNEL) - .setContentTitle("Call in progress") - .setContentText(caller) - .setSmallIcon(R.drawable.ic_call_white_24dp) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setPriority(NotificationCompat.PRIORITY_HIGH) - .setCategory(NotificationCompat.CATEGORY_CALL) - .setOngoing(true) - .setUsesChronometer(true) - .setExtras(extras) - .setContentIntent(activityPendingIntent); - - notification.addAction(0, "HANG UP", pendingHangupIntent); + Notification notification; NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - // Create notifications channel (required for API > 25) - initCallNotificationsChannel(notificationManager); - notificationManager.notify(HANGUP_NOTIFICATION_ID, notification.build()); - } - - public void removeIncomingCallNotification(ReactApplicationContext context, - CancelledCallInvite callInvite, - int notificationId) { - 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) { - /* - * 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. - */ - StatusBarNotification[] activeNotifications = notificationManager.getActiveNotifications(); - for (StatusBarNotification statusBarNotification : activeNotifications) { - Notification notification = statusBarNotification.getNotification(); - String notificationType = notification.extras.getString(NOTIFICATION_TYPE); - if (callInvite.getCallSid().equals(notification.extras.getString(CALL_SID_KEY)) && - notificationType != null && notificationType.equals(ACTION_INCOMING_CALL)) { - notificationManager.cancel(notification.extras.getInt(INCOMING_CALL_NOTIFICATION_ID)); - } - } - } else if (notificationId != 0) { - notificationManager.cancel(notificationId); - } + String title = context.getString(R.string.call_in_progress); + String actionText = context.getString(R.string.hangup); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + notification = new NotificationCompat.Builder(context, createChannel(title, notificationManager)) + .setContentTitle(title) + .setContentText(caller) + .setSmallIcon(R.drawable.ic_call_white_24dp) + .setCategory(Notification.CATEGORY_CALL) + .setExtras(extras) + .setOngoing(true) + .setUsesChronometer(true) + .setFullScreenIntent(pendingIntent, true) + .addAction(0, actionText, hangupPendingIntent) + .build(); } else { - if (notificationId != 0) { - notificationManager.cancel(notificationId); - } else if (callInvite != null) { - String notificationKey = INCOMING_NOTIFICATION_PREFIX+callInvite.getCallSid(); - if (TwilioVoiceModule.callNotificationMap.containsKey(notificationKey)) { - notificationId = TwilioVoiceModule.callNotificationMap.get(notificationKey); - notificationManager.cancel(notificationId); - TwilioVoiceModule.callNotificationMap.remove(notificationKey); - } - } + // noinspection deprecation + notification = new NotificationCompat.Builder(context) + .setContentTitle(title) + .setContentText(caller) + .setSmallIcon(R.drawable.ic_call_white_24dp) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_CALL) + .setOngoing(true) + .setUsesChronometer(true) + .setExtras(extras) + .setContentIntent(pendingIntent) + .addAction(0, actionText, hangupPendingIntent) + .build(); } + notificationManager.notify(Constants.HANGUP_NOTIFICATION_ID, notification); + } + + @TargetApi(Build.VERSION_CODES.O) + private String createChannel(String channelName, NotificationManager notificationManager) { + String channelId = VOICE_CHANNEL; + NotificationChannel channel = new NotificationChannel(channelId, + channelName, NotificationManager.IMPORTANCE_DEFAULT); + channel.setLightColor(Color.GREEN); + channel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + notificationManager.createNotificationChannel(channel); + return channelId; } public void removeHangupNotification(ReactApplicationContext context) { NotificationManager notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.cancel(HANGUP_NOTIFICATION_ID); + notificationManager.cancel(Constants.HANGUP_NOTIFICATION_ID); } } diff --git a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/Constants.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/Constants.java new file mode 100644 index 00000000..f90b7413 --- /dev/null +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/Constants.java @@ -0,0 +1,45 @@ +package com.hoxfon.react.RNTwilioVoice; + +import com.twilio.voice.Call; + +public class Constants { + public static final String MISSED_CALLS_GROUP = "MISSED_CALLS"; + public static final int MISSED_CALLS_NOTIFICATION_ID = 1; + public static final int HANGUP_NOTIFICATION_ID = 11; + public static final int CLEAR_MISSED_CALLS_NOTIFICATION_ID = 21; + public static final String PREFERENCE_KEY = "com.hoxfon.react.RNTwilioVoice.PREFERENCE_FILE_KEY"; + + public static final String CALL_SID_KEY = "CALL_SID"; + public static final String VOICE_CHANNEL_LOW_IMPORTANCE = "notification-channel-low-importance"; + public static final String VOICE_CHANNEL_HIGH_IMPORTANCE = "notification-channel-high-importance"; + public static final String INCOMING_CALL_INVITE = "INCOMING_CALL_INVITE"; + public static final String CANCELLED_CALL_INVITE = "CANCELLED_CALL_INVITE"; + public static final String CANCELLED_CALL_INVITE_EXCEPTION = "CANCELLED_CALL_INVITE_ERROR"; + public static final String INCOMING_CALL_NOTIFICATION_ID = "INCOMING_CALL_NOTIFICATION_ID"; + public static final String ACTION_ACCEPT = "com.hoxfon.react.RNTwilioVoice.ACTION_ACCEPT"; + public static final String ACTION_REJECT = "com.hoxfon.react.RNTwilioVoice.ACTION_REJECT"; + public static final String ACTION_MISSED_CALL = "MISSED_CALL"; + public static final String ACTION_HANGUP_CALL = "HANGUP_CALL"; + public static final String ACTION_INCOMING_CALL = "ACTION_INCOMING_CALL"; + public static final String ACTION_INCOMING_CALL_NOTIFICATION = "ACTION_INCOMING_CALL_NOTIFICATION"; + public static final String ACTION_CANCEL_CALL = "ACTION_CANCEL_CALL"; + public static final String ACTION_FCM_TOKEN = "ACTION_FCM_TOKEN"; + public static final String ACTION_CLEAR_MISSED_CALLS_COUNT = "CLEAR_MISSED_CALLS_COUNT"; + public static final String ACTION_OPEN_CALL_IN_PROGRESS = "CALL_IN_PROGRESS"; + public static final String ACTION_JS_ANSWER = "ACTION_JS_ANSWER"; + public static final String ACTION_JS_REJECT = "ACTION_JS_REJECT"; + + public static final String CALL_SID = "call_sid"; + public static final String CALL_STATE = "call_state"; + public static final String CALL_FROM = "call_from"; + public static final String CALL_TO = "call_to"; + public static final String ERROR = "err"; + public static final String CALL_KEY = "call"; + public static final String CALL_INVITE_KEY = "callInvite"; + public static final String CALL_STATE_CONNECTED = Call.State.CONNECTED.toString(); + public static final String SELECTED_AUDIO_DEVICE = "selected_audio_device"; + public static final String CALLER_VERIFICATION_STATUS = "caller_verification"; + public static final String CALLER_VERIFICATION_VERIFIED = "verified"; + public static final String CALLER_VERIFICATION_UNVERIFIED = "unverified"; + public static final String CALLER_VERIFICATION_UNKNOWN = "unknown"; +} 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 753c2b32..a843205e 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/EventManager.java @@ -25,7 +25,7 @@ public class EventManager { public static final String EVENT_CALL_INVITE_CANCELLED = "callInviteCancelled"; public static final String EVENT_CONNECTION_IS_RECONNECTING = "connectionIsReconnecting"; public static final String EVENT_CONNECTION_DID_RECONNECT = "connectionDidReconnect"; - + public static final String EVENT_AUDIO_DEVICES_UPDATED = "audioDevicesUpdated"; public EventManager(ReactApplicationContext context) { mContext = context; diff --git a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/IncomingCallNotificationService.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/IncomingCallNotificationService.java new file mode 100644 index 00000000..c78175c0 --- /dev/null +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/IncomingCallNotificationService.java @@ -0,0 +1,330 @@ +package com.hoxfon.react.RNTwilioVoice; + +import android.annotation.TargetApi; +import android.app.Notification; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.app.PendingIntent; +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Color; + +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.text.Spannable; +import android.text.SpannableString; +import android.text.style.ForegroundColorSpan; +import android.util.Log; + +import androidx.annotation.ColorRes; +import androidx.annotation.StringRes; +import androidx.core.app.NotificationCompat; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.ProcessLifecycleOwner; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import com.twilio.voice.CallInvite; + +import static com.hoxfon.react.RNTwilioVoice.CallNotificationManager.getMainActivityClass; +import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.TAG; + +public class IncomingCallNotificationService extends Service { + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "IncomingCallNotificationService onStartCommand() intent: " + intent + ", flags: " + flags); + } + String action = intent.getAction(); + + CallInvite callInvite = intent.getParcelableExtra(Constants.INCOMING_CALL_INVITE); + int notificationId = intent.getIntExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, 0); + + switch (action) { + // when a callInvite is received in the background + case Constants.ACTION_INCOMING_CALL: + handleIncomingCall(callInvite, notificationId); + break; + + case Constants.ACTION_ACCEPT: + accept(callInvite, notificationId); + break; + + case Constants.ACTION_REJECT: + reject(callInvite, notificationId); + break; + + case Constants.ACTION_CANCEL_CALL: + handleCancelledCall(intent); + break; + + case Constants.ACTION_JS_ANSWER: + endForeground(); + break; + + case Constants.ACTION_JS_REJECT: + endForeground(); + break; + + default: + break; + } + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private Notification createNotification(CallInvite callInvite, int notificationId, int channelImportance) { + Context context = getApplicationContext(); + + Intent intent = new Intent(this, getMainActivityClass(context)); + intent.setAction(Constants.ACTION_INCOMING_CALL_NOTIFICATION); + intent.putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(Constants.INCOMING_CALL_INVITE, callInvite); + intent.putExtra(Constants.CALL_SID, callInvite.getCallSid()); + intent.putExtra(Constants.CALL_FROM, callInvite.getFrom()); + intent.putExtra(Constants.CALL_TO, callInvite.getTo()); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + + PendingIntent pendingIntent = + PendingIntent.getActivity(this, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT); + + /* + * Pass the notification id and call sid to use as an identifier to cancel the + * notification later + */ + Bundle extras = new Bundle(); + extras.putString(Constants.CALL_SID_KEY, callInvite.getCallSid()); + + String contentText = callInvite.getFrom() + " " + getString(R.string.call_incoming_content); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + return buildNotification(contentText, + pendingIntent, + extras, + callInvite, + notificationId, + createChannel(channelImportance)); + } else { + // noinspection deprecation + return new NotificationCompat.Builder(this) + .setSmallIcon(R.drawable.ic_call_white_24dp) + .setContentTitle(getString(R.string.call_incoming_title)) + .setContentText(contentText) + .setAutoCancel(true) + .setExtras(extras) + .setContentIntent(pendingIntent) + .setGroup("test_app_notification") + .setColor(Color.rgb(214, 10, 37)) + .build(); + } + } + + private Spannable getActionText(Context context, @StringRes int stringRes, @ColorRes int colorRes) { + Spannable spannable = new SpannableString(context.getText(stringRes)); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N_MR1) { + spannable.setSpan( + new ForegroundColorSpan(context.getColor(colorRes)), + 0, + spannable.length(), + 0 + ); + } + return spannable; + } + + private PendingIntent createActionPendingIntent(Context context, Intent intent) { + return PendingIntent.getService( + context, + 0, + intent, + PendingIntent.FLAG_UPDATE_CURRENT + ); + } + + /** + * Build a notification. + * + * @param text the text of the notification + * @param pendingIntent the body, pending intent for the notification + * @param extras extras passed with the notification + * @return the builder + */ + @TargetApi(Build.VERSION_CODES.O) + private Notification buildNotification(String text, + PendingIntent pendingIntent, + Bundle extras, + final CallInvite callInvite, + int notificationId, + String channelId) { + Context context = getApplicationContext(); + + Intent rejectIntent = new Intent(context, IncomingCallNotificationService.class); + rejectIntent.setAction(Constants.ACTION_REJECT); + rejectIntent.putExtra(Constants.INCOMING_CALL_INVITE, callInvite); + rejectIntent.putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, notificationId); + NotificationCompat.Action rejectAction = new NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_delete, + getActionText(context, R.string.reject, R.color.red), + createActionPendingIntent(context, rejectIntent) + ).build(); + + Intent acceptIntent = new Intent(context, IncomingCallNotificationService.class); + acceptIntent.setAction(Constants.ACTION_ACCEPT); + acceptIntent.putExtra(Constants.INCOMING_CALL_INVITE, callInvite); + acceptIntent.putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, notificationId); + NotificationCompat.Action answerAction = new NotificationCompat.Action.Builder( + android.R.drawable.ic_menu_call, + getActionText(context, R.string.accept, R.color.green), + createActionPendingIntent(context, acceptIntent) + ).build(); + + NotificationCompat.Builder builder = + new NotificationCompat.Builder(context, channelId) + .setSmallIcon(R.drawable.ic_call_white_24dp) + .setContentTitle(getString(R.string.call_incoming_title)) + .setContentText(text) + .setExtras(extras) + .setAutoCancel(true) + .addAction(rejectAction) + .addAction(answerAction) + .setFullScreenIntent(pendingIntent, true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(Notification.CATEGORY_CALL) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + ; + + // build notification large icon + Resources res = context.getResources(); + int largeIconResId = res.getIdentifier("ic_launcher", "mipmap", context.getPackageName()); + Bitmap largeIconBitmap = BitmapFactory.decodeResource(res, largeIconResId); + + if (largeIconResId != 0) { + builder.setLargeIcon(largeIconBitmap); + } + + return builder.build(); + } + + @TargetApi(Build.VERSION_CODES.O) + private String createChannel(int channelImportance) { + String channelId = Constants.VOICE_CHANNEL_HIGH_IMPORTANCE; + if (channelImportance == NotificationManager.IMPORTANCE_LOW) { + channelId = Constants.VOICE_CHANNEL_LOW_IMPORTANCE; + } + NotificationChannel callInviteChannel = new NotificationChannel(channelId, + "Incoming calls", channelImportance); + callInviteChannel.setLightColor(Color.GREEN); + + // TODO set sound for background incoming call +// Uri defaultRingtoneUri = RingtoneManager.getActualDefaultRingtoneUri(this, RingtoneManager.TYPE_RINGTONE); +// AudioAttributes audioAttributes = new AudioAttributes.Builder() +// .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) +// .setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE) +// .build(); +// callInviteChannel.setSound(defaultRingtoneUri, audioAttributes); + + callInviteChannel.setLockscreenVisibility(Notification.VISIBILITY_PUBLIC); + NotificationManager notificationManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + notificationManager.createNotificationChannel(callInviteChannel); + + return channelId; + } + + private void accept(CallInvite callInvite, int notificationId) { + endForeground(); + Intent activeCallIntent = new Intent(this, getMainActivityClass(this)); + activeCallIntent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + activeCallIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + activeCallIntent.putExtra(Constants.INCOMING_CALL_INVITE, callInvite); + activeCallIntent.putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, notificationId); + activeCallIntent.putExtra(Constants.CALL_SID, callInvite.getCallSid()); + activeCallIntent.putExtra(Constants.CALL_FROM, callInvite.getFrom()); + activeCallIntent.putExtra(Constants.CALL_TO, callInvite.getTo()); + activeCallIntent.setAction(Constants.ACTION_ACCEPT); + this.startActivity(activeCallIntent); + } + + private void reject(CallInvite callInvite, int notificationId) { + SoundPoolManager.getInstance(this).stopRinging(); + endForeground(); + callInvite.reject(getApplicationContext()); + } + + private void handleCancelledCall(Intent intent) { + SoundPoolManager.getInstance(this).stopRinging(); + endForeground(); + LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + } + + private void handleIncomingCall(CallInvite callInvite, int notificationId) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + setCallInProgressNotification(callInvite, notificationId); + } + sendCallInviteToActivity(callInvite, notificationId); + } + + private void endForeground() { + stopForeground(true); + } + + @TargetApi(Build.VERSION_CODES.O) + private void setCallInProgressNotification(CallInvite callInvite, int notificationId) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "setCallInProgressNotification()"); + } + int importance = NotificationManager.IMPORTANCE_LOW; + if (!isAppVisible()) { + if (BuildConfig.DEBUG) { + Log.i(TAG, "app is NOT visible."); + } + importance = NotificationManager.IMPORTANCE_HIGH; + } + this.startForeground(notificationId, createNotification(callInvite, notificationId, importance)); + } + + /* + * Send the CallInvite to the Activity. Start the activity if it is not running already. + */ + private void sendCallInviteToActivity(CallInvite callInvite, int notificationId) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "sendCallInviteToActivity(). Android SDK: " + Build.VERSION.SDK_INT + " app visible: " + isAppVisible()); + } + SoundPoolManager.getInstance(this).playRinging(); + + // From Android SDK 29 apps are prevented to start an activity from the background + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !isAppVisible()) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "sendCallInviteToActivity(). DO NOTHING"); + } + return; + } + if (BuildConfig.DEBUG) { + Log.d(TAG, "sendCallInviteToActivity(). startActivity()"); + } + // Android SDK < 29 or app is visible + Intent intent = new Intent(this, getMainActivityClass(this)); + intent.setAction(Constants.ACTION_INCOMING_CALL); + intent.putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(Constants.INCOMING_CALL_INVITE, callInvite); + intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + this.startActivity(intent); + } + + private boolean isAppVisible() { + return ProcessLifecycleOwner + .get() + .getLifecycle() + .getCurrentState() + .isAtLeast(Lifecycle.State.STARTED); + } +} \ No newline at end of file diff --git a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/SoundPoolManager.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/SoundPoolManager.java index cda96606..b79852db 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/SoundPoolManager.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/SoundPoolManager.java @@ -1,6 +1,7 @@ package com.hoxfon.react.RNTwilioVoice; import android.content.Context; +import android.media.AudioAttributes; import android.media.Ringtone; import android.media.RingtoneManager; import android.net.Uri; @@ -14,6 +15,11 @@ public class SoundPoolManager { private SoundPoolManager(Context context) { Uri ringtoneSound = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_RINGTONE); ringtone = RingtoneManager.getRingtone(context, ringtoneSound); + AudioAttributes alarmAttribute = new AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .build(); + ringtone.setAudioAttributes(alarmAttribute); } public static SoundPoolManager getInstance(Context 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 d9c627fb..7874a1ca 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java @@ -3,25 +3,22 @@ import android.Manifest; import android.app.Activity; import android.app.ActivityManager.RunningAppProcessInfo; -import android.app.NotificationManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; import android.content.pm.PackageManager; -import android.media.AudioAttributes; -import android.media.AudioFocusRequest; import android.media.AudioManager; -import android.os.Build; import androidx.annotation.NonNull; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import kotlin.Unit; + +import android.os.Bundle; import android.util.Log; -import android.view.Window; -import android.view.WindowManager; import com.facebook.react.bridge.JSApplicationIllegalArgumentException; import com.facebook.react.bridge.AssertionException; @@ -39,10 +36,10 @@ import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; -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.audioswitch.AudioDevice; +import com.twilio.audioswitch.AudioSwitch; import com.twilio.voice.AcceptOptions; import com.twilio.voice.Call; import com.twilio.voice.CallException; @@ -52,10 +49,15 @@ import com.twilio.voice.LogLevel; import com.twilio.voice.RegistrationException; import com.twilio.voice.RegistrationListener; +import com.twilio.voice.UnregistrationListener; import com.twilio.voice.Voice; +import java.util.HashSet; +import java.util.Locale; +import java.util.Set; import java.util.HashMap; import java.util.Map; +import java.util.List; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_CONNECT; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_DISCONNECT; @@ -66,6 +68,7 @@ import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CALL_INVITE_CANCELLED; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_IS_RECONNECTING; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_RECONNECT; +import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_AUDIO_DEVICES_UPDATED; public class TwilioVoiceModule extends ReactContextBaseJavaModule implements ActivityEventListener, LifecycleEventListener { @@ -73,40 +76,12 @@ public class TwilioVoiceModule extends ReactContextBaseJavaModule implements Act private static final int MIC_PERMISSION_REQUEST_CODE = 1; - private AudioManager audioManager; - private int originalAudioMode = AudioManager.MODE_NORMAL; - private boolean isReceiverRegistered = false; private VoiceBroadcastReceiver voiceBroadcastReceiver; // Empty HashMap, contains parameters for the Outbound call private HashMap twiMLParams = new HashMap<>(); - 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"; - 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"; - 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"; - public static final String INCOMING_NOTIFICATION_PREFIX = "Incoming_"; - public static final String MISSED_CALLS_GROUP = "MISSED_CALLS"; - public static final int MISSED_CALLS_NOTIFICATION_ID = 1; - public static final int HANGUP_NOTIFICATION_ID = 11; - public static final int CLEAR_MISSED_CALLS_NOTIFICATION_ID = 21; - - public static final String PREFERENCE_KEY = "com.hoxfon.react.TwilioVoice.PREFERENCE_FILE_KEY"; - - private NotificationManager notificationManager; private CallNotificationManager callNotificationManager; private ProximityManager proximityManager; @@ -118,21 +93,28 @@ public class TwilioVoiceModule extends ReactContextBaseJavaModule implements Act static Map callNotificationMap; private RegistrationListener registrationListener = registrationListener(); + private UnregistrationListener unregistrationListener = unregistrationListener(); private Call.Listener callListener = callListener(); private CallInvite activeCallInvite; private Call activeCall; - // this variable determines when to create missed calls notifications - private Boolean callAccepted = false; - - private AudioFocusRequest focusRequest; private HeadsetManager headsetManager; private EventManager eventManager; + private int existingCallInviteIntent; + + /* + * Audio device management + */ + private AudioSwitch audioSwitch; + private int savedVolumeControlStream; + AudioDevice selectedAudioDevice; + Map availableAudioDevices; public TwilioVoiceModule(ReactApplicationContext reactContext, - boolean shouldAskForMicPermission) { + boolean shouldAskForMicPermission) { super(reactContext); + if (BuildConfig.DEBUG) { Voice.setLogLevel(LogLevel.DEBUG); } else { @@ -146,8 +128,6 @@ public TwilioVoiceModule(ReactApplicationContext reactContext, proximityManager = new ProximityManager(reactContext, eventManager); headsetManager = new HeadsetManager(eventManager); - notificationManager = (android.app.NotificationManager) reactContext.getSystemService(Context.NOTIFICATION_SERVICE); - /* * Setup the broadcast receiver to be notified of GCM Token updates * or incoming call messages in this Activity. @@ -157,10 +137,8 @@ public TwilioVoiceModule(ReactApplicationContext reactContext, TwilioVoiceModule.callNotificationMap = new HashMap<>(); - /* - * Needed for setting/abandoning audio focus during a call - */ - audioManager = (AudioManager) reactContext.getSystemService(Context.AUDIO_SERVICE); + audioSwitch = new AudioSwitch(reactContext); + availableAudioDevices = new HashMap<>(); /* * Ensure the microphone permission is enabled @@ -172,11 +150,37 @@ public TwilioVoiceModule(ReactApplicationContext reactContext, @Override public void onHostResume() { + savedVolumeControlStream = getCurrentActivity().getVolumeControlStream(); /* * Enable changing the volume using the up/down keys during a conversation */ getCurrentActivity().setVolumeControlStream(AudioManager.STREAM_VOICE_CALL); registerReceiver(); + + Intent intent = getCurrentActivity().getIntent(); + if (intent == null || intent.getAction() == null) { + return; + } + int currentCallInviteIntent = intent.hashCode(); + String action = intent.getAction(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "onHostResume(). Action: " + action + ". Intent: " + intent.getExtras()); + } + + if (action.equals(Intent.ACTION_MAIN)) { + return; + } + + if (action.equals(Constants.ACTION_ACCEPT) && currentCallInviteIntent == existingCallInviteIntent) { + return; + } + + if (action.equals(Constants.ACTION_INCOMING_CALL_NOTIFICATION) && currentCallInviteIntent == existingCallInviteIntent) { + return; + } + + existingCallInviteIntent = currentCallInviteIntent; + handleStartActivityIntent(intent); } @Override @@ -189,7 +193,11 @@ public void onHostPause() { public void onHostDestroy() { disconnect(); callNotificationManager.removeHangupNotification(getReactApplicationContext()); - unsetAudioFocus(); + /* + * Tear down audio device management and restore previous volume stream + */ + audioSwitch.stop(); + getCurrentActivity().setVolumeControlStream(savedVolumeControlStream); } @Override @@ -197,34 +205,53 @@ public String getName() { return TAG; } + @Override public void onNewIntent(Intent intent) { // This is called only when the App is in the foreground if (BuildConfig.DEBUG) { - Log.d(TAG, "onNewIntent " + intent.toString()); + Log.d(TAG, "onNewIntent(). Intent: " + intent.toString()); } - handleIncomingCallIntent(intent); + handleStartActivityIntent(intent); } private RegistrationListener registrationListener() { return new RegistrationListener() { @Override - public void onRegistered(String accessToken, String fcmToken) { + public void onRegistered(@NonNull String accessToken, @NonNull String fcmToken) { if (BuildConfig.DEBUG) { - Log.d(TAG, "Successfully registered FCM"); + Log.d(TAG, "RegistrationListener().onRegistered(). FCM registered."); } eventManager.sendEvent(EVENT_DEVICE_READY, null); } @Override - public void onError(RegistrationException error, String accessToken, String fcmToken) { - Log.e(TAG, String.format("Registration Error: %d, %s", error.getErrorCode(), error.getMessage())); + public void onError(@NonNull RegistrationException error, + @NonNull String accessToken, + @NonNull String fcmToken) { + Log.e(TAG, String.format("RegistrationListener().onError(). Code: %d. %s", error.getErrorCode(), error.getMessage())); WritableMap params = Arguments.createMap(); - params.putString("err", error.getMessage()); + params.putString(Constants.ERROR, error.getMessage()); eventManager.sendEvent(EVENT_DEVICE_NOT_READY, params); } }; } + private UnregistrationListener unregistrationListener() { + return new UnregistrationListener() { + @Override + public void onUnregistered(String accessToken, String fcmToken) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Successfully unregistered FCM"); + } + } + + @Override + public void onError(RegistrationException error, String accessToken, String fcmToken) { + Log.e(TAG, String.format("Unregistration Error: %d, %s", error.getErrorCode(), error.getMessage())); + } + }; + } + private Call.Listener callListener() { return new Call.Listener() { /* @@ -241,46 +268,44 @@ private Call.Listener callListener() { * raised, irrespective of the value of answerOnBridge being set to true or false */ @Override - public void onRinging(Call call) { + public void onRinging(@NonNull 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()); + Log.d(TAG, "Call.Listener().onRinging(). Call state: " + call.getState() + ". Call: "+ call.toString()); } WritableMap params = Arguments.createMap(); if (call != null) { - params.putString("call_sid", call.getSid()); - params.putString("call_from", call.getFrom()); + params.putString(Constants.CALL_SID, call.getSid()); + params.putString(Constants.CALL_FROM, call.getFrom()); } eventManager.sendEvent(EVENT_CALL_STATE_RINGING, params); } @Override - public void onConnected(Call call) { + public void onConnected(@NonNull Call call) { if (BuildConfig.DEBUG) { - Log.d(TAG, "CALL CONNECTED callListener().onConnected call state = "+call.getState()); + Log.d(TAG, "Call.Listener().onConnected(). Call state: " + call.getState()); } - setAudioFocus(); + audioSwitch.activate(); proximityManager.startProximitySensor(); headsetManager.startWiredHeadsetEvent(getReactApplicationContext()); WritableMap params = Arguments.createMap(); - if (call != null) { - params.putString("call_sid", call.getSid()); - params.putString("call_state", call.getState().name()); - params.putString("call_from", call.getFrom()); - params.putString("call_to", call.getTo()); - String caller = "Show call details in the app"; - if (!toName.equals("")) { - caller = toName; - } else if (!toNumber.equals("")) { - caller = toNumber; - } - activeCall = call; - callNotificationManager.createHangupLocalNotification(getReactApplicationContext(), - call.getSid(), caller); + params.putString(Constants.CALL_SID, call.getSid()); + params.putString(Constants.CALL_STATE, call.getState().name()); + params.putString(Constants.CALL_FROM, call.getFrom()); + params.putString(Constants.CALL_TO, call.getTo()); + String caller = "Show call details in the app"; + if (!toName.equals("")) { + caller = toName; + } else if (!toNumber.equals("")) { + caller = toNumber; } + activeCall = call; + callNotificationManager.createHangupNotification(getReactApplicationContext(), + call.getSid(), caller); eventManager.sendEvent(EVENT_CONNECTION_DID_CONNECT, params); + activeCallInvite = null; } /** @@ -291,16 +316,13 @@ public void onConnected(Call call) { @Override public void onReconnecting(@NonNull Call call, @NonNull CallException callException) { if (BuildConfig.DEBUG) { - Log.d(TAG, "CALL RECONNECTING callListener().onReconnecting call state = "+call.getState()); + Log.d(TAG, "Call.Listener().onReconnecting(). Call state: " + call.getState()); } WritableMap params = Arguments.createMap(); - if (call != null) { - params.putString("call_sid", call.getSid()); - params.putString("call_from", call.getFrom()); - params.putString("call_to", call.getTo()); - } + params.putString(Constants.CALL_SID, call.getSid()); + params.putString(Constants.CALL_FROM, call.getFrom()); + params.putString(Constants.CALL_TO, call.getTo()); eventManager.sendEvent(EVENT_CONNECTION_IS_RECONNECTING, params); - } /** @@ -309,40 +331,35 @@ public void onReconnecting(@NonNull Call call, @NonNull CallException callExcept @Override public void onReconnected(@NonNull Call call) { if (BuildConfig.DEBUG) { - Log.d(TAG, "CALL RECONNECTED callListener().onReconnected call state = "+call.getState()); + Log.d(TAG, "Call.Listener().onReconnected(). Call state: " + call.getState()); } WritableMap params = Arguments.createMap(); - if (call != null) { - params.putString("call_sid", call.getSid()); - params.putString("call_from", call.getFrom()); - params.putString("call_to", call.getTo()); - } + params.putString(Constants.CALL_SID, call.getSid()); + params.putString(Constants.CALL_FROM, call.getFrom()); + params.putString(Constants.CALL_TO, call.getTo()); eventManager.sendEvent(EVENT_CONNECTION_DID_RECONNECT, params); } @Override - public void onDisconnected(Call call, CallException error) { + public void onDisconnected(@NonNull Call call, CallException error) { if (BuildConfig.DEBUG) { - Log.d(TAG, "CALL DISCONNECTED callListener().onDisconnected call state = "+call.getState()); + Log.d(TAG, "Call.Listener().onDisconnected(). Call state: " + call.getState()); } - unsetAudioFocus(); + audioSwitch.deactivate(); proximityManager.stopProximitySensor(); headsetManager.stopWiredHeadsetEvent(getReactApplicationContext()); - callAccepted = false; WritableMap params = Arguments.createMap(); String callSid = ""; - if (call != null) { - callSid = call.getSid(); - params.putString("call_sid", callSid); - params.putString("call_state", call.getState().name()); - params.putString("call_from", call.getFrom()); - params.putString("call_to", call.getTo()); - } + callSid = call.getSid(); + params.putString(Constants.CALL_SID, callSid); + params.putString(Constants.CALL_STATE, call.getState().name()); + params.putString(Constants.CALL_FROM, call.getFrom()); + params.putString(Constants.CALL_TO, call.getTo()); if (error != null) { Log.e(TAG, String.format("CallListener onDisconnected error: %d, %s", error.getErrorCode(), error.getMessage())); - params.putString("err", error.getMessage()); + params.putString(Constants.ERROR, error.getMessage()); } if (callSid != null && activeCall != null && activeCall.getSid() != null && activeCall.getSid().equals(callSid)) { activeCall = null; @@ -351,31 +368,28 @@ public void onDisconnected(Call call, CallException error) { callNotificationManager.removeHangupNotification(getReactApplicationContext()); toNumber = ""; toName = ""; + activeCallInvite = null; } @Override - public void onConnectFailure(Call call, CallException error) { + public void onConnectFailure(@NonNull Call call, CallException error) { if (BuildConfig.DEBUG) { - Log.d(TAG, "CALL FAILURE callListener().onConnectFailure call state = "+call.getState()); + Log.d(TAG, "Call.Listener().onConnectFailure(). Call state: " + call.getState()); } - unsetAudioFocus(); + audioSwitch.deactivate(); proximityManager.stopProximitySensor(); - callAccepted = false; - Log.e(TAG, String.format("CallListener onConnectFailure error: %d, %s", - error.getErrorCode(), error.getMessage())); + error.getErrorCode(), error.getMessage())); WritableMap params = Arguments.createMap(); - params.putString("err", error.getMessage()); + params.putString(Constants.ERROR, error.getMessage()); String callSid = ""; - if (call != null) { - callSid = call.getSid(); - params.putString("call_sid", callSid); - params.putString("call_state", call.getState().name()); - params.putString("call_from", call.getFrom()); - params.putString("call_to", call.getTo()); - } + callSid = call.getSid(); + params.putString(Constants.CALL_SID, callSid); + params.putString(Constants.CALL_STATE, call.getState().name()); + params.putString(Constants.CALL_FROM, call.getFrom()); + params.putString(Constants.CALL_TO, call.getTo()); if (callSid != null && activeCall != null && activeCall.getSid() != null && activeCall.getSid().equals(callSid)) { activeCall = null; } @@ -383,6 +397,35 @@ public void onConnectFailure(Call call, CallException error) { callNotificationManager.removeHangupNotification(getReactApplicationContext()); toNumber = ""; toName = ""; + activeCallInvite = null; + } + + /* + * currentWarnings: existing quality warnings that have not been cleared yet + * previousWarnings: last set of warnings prior to receiving this callback + * + * Example: + * - currentWarnings: { A, B } + * - previousWarnings: { B, C } + * + * Newly raised warnings = currentWarnings - intersection = { A } + * Newly cleared warnings = previousWarnings - intersection = { C } + */ + public void onCallQualityWarningsChanged(@NonNull Call call, + @NonNull Set currentWarnings, + @NonNull Set previousWarnings) { + if (previousWarnings.size() > 1) { + Set intersection = new HashSet<>(currentWarnings); + currentWarnings.removeAll(previousWarnings); + intersection.retainAll(previousWarnings); + previousWarnings.removeAll(intersection); + } + String message = String.format( + Locale.US, + "Newly raised warnings: " + currentWarnings + " Clear warnings " + previousWarnings); + Log.e(TAG, message); + + // TODO send event to JS } }; } @@ -391,57 +434,82 @@ public void onConnectFailure(Call call, CallException error) { * Register the Voice broadcast receiver */ private void registerReceiver() { + if (isReceiverRegistered) { + return; + } + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(Constants.ACTION_INCOMING_CALL); + intentFilter.addAction(Constants.ACTION_CANCEL_CALL); + LocalBroadcastManager.getInstance(getReactApplicationContext()).registerReceiver( + voiceBroadcastReceiver, intentFilter); + registerActionReceiver(); + isReceiverRegistered = true; + } + + private void unregisterReceiver() { 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); - registerActionReceiver(); - isReceiverRegistered = true; + return; } + LocalBroadcastManager.getInstance(getReactApplicationContext()).unregisterReceiver(voiceBroadcastReceiver); + isReceiverRegistered = false; + } + + private void removeMissedCalls() { + SharedPreferences sharedPref = getReactApplicationContext().getSharedPreferences( + Constants.PREFERENCE_KEY, Context.MODE_PRIVATE); + SharedPreferences.Editor sharedPrefEditor = sharedPref.edit(); + sharedPrefEditor.putInt(Constants.MISSED_CALLS_GROUP, 0); + sharedPrefEditor.commit(); } -// private void unregisterReceiver() { -// if (isReceiverRegistered) { -// LocalBroadcastManager.getInstance(getReactApplicationContext()).unregisterReceiver(voiceBroadcastReceiver); -// isReceiverRegistered = false; -// } -// } + private class VoiceBroadcastReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + String action = intent.getAction(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "VoiceBroadcastReceiver.onReceive() action: " + action + ". Intent extra: " + intent.getExtras()); + } + activeCallInvite = intent.getParcelableExtra(Constants.INCOMING_CALL_INVITE); + + switch (action) { + // when a callInvite is received in the foreground + case Constants.ACTION_INCOMING_CALL: + handleCallInviteNotification(); + break; + + case Constants.ACTION_CANCEL_CALL: + handleCancelCall(intent); + break; + + } + } + } private void registerActionReceiver() { IntentFilter intentFilter = new IntentFilter(); - intentFilter.addAction(ACTION_ANSWER_CALL); - intentFilter.addAction(ACTION_REJECT_CALL); - intentFilter.addAction(ACTION_HANGUP_CALL); - intentFilter.addAction(ACTION_CLEAR_MISSED_CALLS_COUNT); + intentFilter.addAction(Constants.ACTION_HANGUP_CALL); + intentFilter.addAction(Constants.ACTION_CLEAR_MISSED_CALLS_COUNT); getReactApplicationContext().registerReceiver(new BroadcastReceiver() { @Override public void onReceive(Context context, Intent intent) { String action = intent.getAction(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "BroadcastReceiver.onReceive() action: " + action); + } switch (action) { - case ACTION_ANSWER_CALL: - accept(); - break; - case ACTION_REJECT_CALL: - reject(); - break; - case ACTION_HANGUP_CALL: + case Constants.ACTION_HANGUP_CALL: disconnect(); break; - case ACTION_CLEAR_MISSED_CALLS_COUNT: - SharedPreferences sharedPref = context.getSharedPreferences(PREFERENCE_KEY, Context.MODE_PRIVATE); - SharedPreferences.Editor sharedPrefEditor = sharedPref.edit(); - sharedPrefEditor.putInt(MISSED_CALLS_GROUP, 0); - sharedPrefEditor.commit(); + case Constants.ACTION_CLEAR_MISSED_CALLS_COUNT: + removeMissedCalls(); + break; } - // Dismiss the notification when the user tap on the relative notification action - // eventually the notification will be cleared anyway - // but in this way there is no UI lag - notificationManager.cancel(intent.getIntExtra(INCOMING_CALL_NOTIFICATION_ID, 0)); } }, intentFilter); } @@ -456,100 +524,115 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { // Ignored, required to implement ActivityEventListener for RN 0.33 } - private void handleIncomingCallIntent(Intent intent) { + private void handleStartActivityIntent(Intent intent) { if (intent == null || intent.getAction() == null) { return; } String action = intent.getAction(); - if (action.equals(ACTION_INCOMING_CALL)) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "handleIncomingCallIntent"); - } - activeCallInvite = intent.getParcelableExtra(INCOMING_CALL_INVITE); - if (activeCallInvite != null) { - callAccepted = false; - SoundPoolManager.getInstance(getReactApplicationContext()).playRinging(); - - if (getReactApplicationContext().getCurrentActivity() != null) { - Window window = getReactApplicationContext().getCurrentActivity().getWindow(); - window.addFlags(WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON - | WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED - ); + if (BuildConfig.DEBUG) { + Log.d(TAG, "handleStartActivityIntent() action: " + action); + } + activeCallInvite = intent.getParcelableExtra(Constants.INCOMING_CALL_INVITE); + + switch (action) { + case Constants.ACTION_INCOMING_CALL_NOTIFICATION: + if (BuildConfig.DEBUG) { + Log.d(TAG, "ACTION_INCOMING_CALL_NOTIFICATION handleStartActivityIntent"); } - // send a JS event ONLY if the app's importance is FOREGROUND or SERVICE - // at startup the app would try to fetch the activeIncoming calls - int appImportance = callNotificationManager.getApplicationImportance(getReactApplicationContext()); - if (appImportance == RunningAppProcessInfo.IMPORTANCE_FOREGROUND || - appImportance == RunningAppProcessInfo.IMPORTANCE_SERVICE) { - - WritableMap params = Arguments.createMap(); - params.putString("call_sid", activeCallInvite.getCallSid()); - params.putString("call_from", activeCallInvite.getFrom()); - params.putString("call_to", activeCallInvite.getTo()); // TODO check if needed - eventManager.sendEvent(EVENT_DEVICE_DID_RECEIVE_INCOMING, params); + WritableMap params = Arguments.createMap(); + params.putString(Constants.CALL_SID, activeCallInvite.getCallSid()); + params.putString(Constants.CALL_FROM, activeCallInvite.getFrom()); + params.putString(Constants.CALL_TO, activeCallInvite.getTo()); + eventManager.sendEvent(EVENT_DEVICE_DID_RECEIVE_INCOMING, params); + break; + + case Constants.ACTION_MISSED_CALL: + if (BuildConfig.DEBUG) { + Log.d(TAG, "ACTION_MISSED_CALL handleStartActivityIntent"); } - } else { - // TODO evaluate what more is needed at this point? - Log.e(TAG, "ACTION_INCOMING_CALL but not active call"); - } - } else if (action.equals(ACTION_CANCEL_CALL_INVITE)) { - SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); - if (BuildConfig.DEBUG) { - Log.d(TAG, "activeCallInvite was cancelled by " + activeCallInvite.getFrom()); - } - if (!callAccepted) { + removeMissedCalls(); + break; + + case Constants.ACTION_CLEAR_MISSED_CALLS_COUNT: if (BuildConfig.DEBUG) { - Log.d(TAG, "creating a missed call"); + Log.d(TAG, "ACTION_CLEAR_MISSED_CALLS_COUNT handleStartActivityIntent"); } - 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); + removeMissedCalls(); + break; + + case Constants.ACTION_FCM_TOKEN: + registerForCallInvites(); + break; + + case Constants.ACTION_ACCEPT: + acceptFromIntent(intent); + break; + + case Constants.ACTION_OPEN_CALL_IN_PROGRESS: + // the notification already brings the activity to the top + if (activeCall == null) { + callNotificationManager.removeHangupNotification(getReactApplicationContext()); } - } - clearIncomingNotification(activeCallInvite.getCallSid()); - } else if (action.equals(ACTION_FCM_TOKEN)) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "handleIncomingCallIntent ACTION_FCM_TOKEN"); - } - registerForCallInvites(); + break; + + default: + Log.e(TAG, "received broadcast unhandled action " + action); + break; } } - private class VoiceBroadcastReceiver extends BroadcastReceiver { + private void handleCallInviteNotification() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "handleCallInviteNotification()"); + } + if (activeCallInvite == null) { + Log.e(TAG, "NO active call invite"); + return; + } + SoundPoolManager.getInstance(getReactApplicationContext()).playRinging(); - @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)) { - 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(); - sharedPrefEditor.remove(MISSED_CALLS_GROUP); - sharedPrefEditor.commit(); - } else { - Log.e(TAG, "received broadcast unhandled action " + action); + WritableMap params = Arguments.createMap(); + params.putString(Constants.CALL_SID, activeCallInvite.getCallSid()); + params.putString(Constants.CALL_FROM, activeCallInvite.getFrom()); + params.putString(Constants.CALL_TO, activeCallInvite.getTo()); + String verificationStatus = Constants.CALLER_VERIFICATION_UNKNOWN; + if (activeCallInvite.getCallerInfo().isVerified() != null) { + verificationStatus = activeCallInvite.getCallerInfo().isVerified() == true + ? Constants.CALLER_VERIFICATION_VERIFIED + : Constants.CALLER_VERIFICATION_UNVERIFIED + ; + } + params.putString(Constants.CALLER_VERIFICATION_STATUS, verificationStatus); + eventManager.sendEvent(EVENT_DEVICE_DID_RECEIVE_INCOMING, params); + } + + private void handleCancelCall(Intent intent) { + CancelledCallInvite cancelledCallInvite = intent.getParcelableExtra(Constants.CANCELLED_CALL_INVITE); + + ReactApplicationContext ctx = getReactApplicationContext(); + SoundPoolManager.getInstance(ctx).stopRinging(); + callNotificationManager.createMissedCallNotification( + getReactApplicationContext(), + cancelledCallInvite.getCallSid(), + cancelledCallInvite.getFrom() + ); + + WritableMap params = Arguments.createMap(); + + // TODO check whether the params should be passed anyway + int appImportance = callNotificationManager.getApplicationImportance(ctx); + if (appImportance <= RunningAppProcessInfo.IMPORTANCE_VISIBLE) { + params.putString(Constants.CALL_SID, cancelledCallInvite.getCallSid()); + params.putString(Constants.CALL_FROM, cancelledCallInvite.getFrom()); + params.putString(Constants.CALL_TO, cancelledCallInvite.getTo()); + String cancelledCallInviteErr = intent.getStringExtra(Constants.CANCELLED_CALL_INVITE_EXCEPTION); + // pass this to the event even though in v5.0.2 it is always "Call Cancelled" + if (cancelledCallInviteErr != null) { + params.putString(Constants.ERROR, cancelledCallInviteErr); } } + // TODO handle custom parameters + eventManager.sendEvent(EVENT_CALL_INVITE_CANCELLED, params); } @ReactMethod @@ -566,27 +649,13 @@ public void initWithAccessToken(final String accessToken, Promise promise) { TwilioVoiceModule.this.accessToken = accessToken; if (BuildConfig.DEBUG) { - Log.d(TAG, "initWithAccessToken"); + Log.d(TAG, "initWithAccessToken()"); } registerForCallInvites(); WritableMap params = Arguments.createMap(); params.putBoolean("initialized", true); promise.resolve(params); - } - - private void clearIncomingNotification(String callSid) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "clearIncomingNotification() callSid: "+ callSid); - } - // remove incoming call notification - String notificationKey = INCOMING_NOTIFICATION_PREFIX + callSid; - int notificationId = 0; - if (TwilioVoiceModule.callNotificationMap.containsKey(notificationKey)) { - notificationId = TwilioVoiceModule.callNotificationMap.get(notificationKey); - } - callNotificationManager.removeIncomingCallNotification(getReactApplicationContext(), null, notificationId); - TwilioVoiceModule.callNotificationMap.remove(notificationKey); - activeCallInvite = null; + startAudioSwitch(); } /* @@ -597,108 +666,127 @@ private void clearIncomingNotification(String callSid) { * */ private void registerForCallInvites() { - FirebaseInstanceId.getInstance().getInstanceId() - .addOnCompleteListener(new OnCompleteListener() { - @Override - public void onComplete(@NonNull Task task) { - if (!task.isSuccessful()) { - Log.w(TAG, "getInstanceId failed", task.getException()); - return; - } + String fcmToken = FirebaseInstanceId.getInstance().getToken(); + if (fcmToken == null) { + return; + } + if (BuildConfig.DEBUG) { + Log.i(TAG, "Registering with FCM"); + } + Voice.register(accessToken, Voice.RegistrationChannel.FCM, fcmToken, registrationListener); + } + + /* + * Unregister your android device with Twilio + * + */ - // 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 // + public void unregister(Promise promise) { + unregisterForCallInvites(); + promise.resolve(true); + } + + private void unregisterForCallInvites() { + FirebaseInstanceId.getInstance().getInstanceId() + .addOnCompleteListener(task -> { + if (!task.isSuccessful()) { + Log.w(TAG, "FCM unregistration failed", task.getException()); + return; + } + // Get new Instance ID token + String fcmToken = task.getResult().getToken(); + if (fcmToken != null) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "Unregistering with FCM"); } + Voice.unregister(accessToken, Voice.RegistrationChannel.FCM, fcmToken, unregistrationListener); } }); } + public void acceptFromIntent(Intent intent) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "acceptFromIntent()"); + } + activeCallInvite = intent.getParcelableExtra(Constants.INCOMING_CALL_INVITE); + if (activeCallInvite == null) { + eventManager.sendEvent(EVENT_CALL_INVITE_CANCELLED, null); + return; + } + + SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); + + AcceptOptions acceptOptions = new AcceptOptions.Builder() + .enableDscp(true) + .build(); + activeCallInvite.accept(getReactApplicationContext(), acceptOptions, callListener); + } + @ReactMethod public void accept() { - callAccepted = true; SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); - if (activeCallInvite != null) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "accept()"); - } - AcceptOptions acceptOptions = new AcceptOptions.Builder() - .enableDscp(true) - .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); + if (activeCallInvite == null) { + eventManager.sendEvent(EVENT_CALL_INVITE_CANCELLED, null); + return; + } + if (BuildConfig.DEBUG) { + Log.d(TAG, "accept()"); } + + Intent intent = new Intent(getReactApplicationContext(), IncomingCallNotificationService.class); + intent.setAction(Constants.ACTION_JS_ANSWER); + + getReactApplicationContext().startService(intent); + + AcceptOptions acceptOptions = new AcceptOptions.Builder() + .enableDscp(true) + .build(); + activeCallInvite.accept(getReactApplicationContext(), acceptOptions, callListener); } @ReactMethod public void reject() { - callAccepted = false; SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); WritableMap params = Arguments.createMap(); 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", "DISCONNECTED"); - // TODO check if DISCONNECTED should be REJECTED - // params.putString("call_state", "REJECTED"); + params.putString(Constants.CALL_SID, activeCallInvite.getCallSid()); + params.putString(Constants.CALL_FROM, activeCallInvite.getFrom()); + params.putString(Constants.CALL_TO, activeCallInvite.getTo()); activeCallInvite.reject(getReactApplicationContext()); - clearIncomingNotification(activeCallInvite.getCallSid()); } - eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); + + Intent intent = new Intent(getReactApplicationContext(), IncomingCallNotificationService.class); + intent.setAction(Constants.ACTION_JS_REJECT); + + getReactApplicationContext().startService(intent); + + eventManager.sendEvent(EVENT_CALL_INVITE_CANCELLED, params); + activeCallInvite = null; } @ReactMethod public void ignore() { - callAccepted = false; SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); - WritableMap params = Arguments.createMap(); - 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", "BUSY"); - clearIncomingNotification(activeCallInvite.getCallSid()); - } - eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); } @ReactMethod public void connect(ReadableMap params) { if (BuildConfig.DEBUG) { - Log.d(TAG, "connect params: "+params); + Log.d(TAG, "connect(). Params: "+params); } WritableMap errParams = Arguments.createMap(); if (accessToken == null) { - errParams.putString("err", "Invalid access token"); + errParams.putString(Constants.ERROR, "Invalid access token"); eventManager.sendEvent(EVENT_DEVICE_NOT_READY, errParams); return; } if (params == null) { - errParams.putString("err", "Invalid parameters"); + errParams.putString(Constants.ERROR, "Invalid parameters"); eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, errParams); return; } else if (!params.hasKey("To")) { - errParams.putString("err", "Invalid To parameter"); + errParams.putString(Constants.ERROR, "Invalid To parameter"); eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, errParams); return; } @@ -728,15 +816,15 @@ public void connect(ReadableMap params) { twiMLParams.put(key, params.getString(key)); break; default: - Log.d(TAG, "Could not convert with key: " + key + "."); + Log.e(TAG, "Could not convert key: " + key + ". ReadableType: "+ readableType.toString()); break; } } ConnectOptions connectOptions = new ConnectOptions.Builder(accessToken) - .enableDscp(true) - .params(twiMLParams) - .build(); + .enableDscp(true) + .params(twiMLParams) + .build(); activeCall = Voice.connect(getReactApplicationContext(), connectOptions, callListener); } @@ -765,46 +853,39 @@ public void sendDigits(String digits) { @ReactMethod public void getActiveCall(Promise promise) { - if (activeCall != null) { - if (BuildConfig.DEBUG) { - 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", toNum); - params.putString("call_state", activeCall.getState().name()); - promise.resolve(params); + if (activeCall == null) { + promise.resolve(null); return; } - promise.resolve(null); + if (BuildConfig.DEBUG) { + Log.d(TAG, "getActiveCall(). Active call state: " + activeCall.getState()); + } + WritableMap params = Arguments.createMap(); + String toNum = activeCall.getTo(); + if (toNum == null) { + toNum = toNumber; + } + params.putString(Constants.CALL_SID, activeCall.getSid()); + params.putString(Constants.CALL_FROM, activeCall.getFrom()); + params.putString(Constants.CALL_TO, toNum); + params.putString(Constants.CALL_STATE, activeCall.getState().name()); + promise.resolve(params); } @ReactMethod public void getCallInvite(Promise promise) { - if (activeCallInvite != null) { - if (BuildConfig.DEBUG) { - 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()); - promise.resolve(params); + if (activeCallInvite == null) { + promise.resolve(null); return; } - promise.resolve(null); - } - - @ReactMethod - public void setSpeakerPhone(Boolean value) { - // TODO check whether it is necessary to call setAudioFocus again -// setAudioFocus(); - audioManager.setSpeakerphoneOn(value); + if (BuildConfig.DEBUG) { + Log.d(TAG, "getCallInvite(). Call invite: " + activeCallInvite); + } + WritableMap params = Arguments.createMap(); + params.putString(Constants.CALL_SID, activeCallInvite.getCallSid()); + params.putString(Constants.CALL_FROM, activeCallInvite.getFrom()); + params.putString(Constants.CALL_TO, activeCallInvite.getTo()); + promise.resolve(params); } @ReactMethod @@ -814,59 +895,44 @@ public void setOnHold(Boolean value) { } } - private void setAudioFocus() { - if (audioManager == null) { - audioManager.setMode(originalAudioMode); - audioManager.abandonAudioFocus(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 { - int focusRequestResult = audioManager.requestAudioFocus(new AudioManager.OnAudioFocusChangeListener() { - @Override - public void onAudioFocusChange(int focusChange) {} - }, - AudioManager.STREAM_VOICE_CALL, - AudioManager.AUDIOFOCUS_GAIN_TRANSIENT); + @ReactMethod + public void getAudioDevices(Promise promise) { + List availableAudioDevices = audioSwitch.getAvailableAudioDevices(); + + WritableMap devices = Arguments.createMap(); + for (AudioDevice a : availableAudioDevices) { + devices.putBoolean(a.getName(), selectedAudioDevice.getName().equals(a.getName())); } - /* - * 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); + promise.resolve(devices); } - private void unsetAudioFocus() { - if (audioManager == null) { - audioManager.setMode(originalAudioMode); - audioManager.abandonAudioFocus(null); + @ReactMethod + public void getSelectedAudioDevice(Promise promise) { + WritableMap device = Arguments.createMap(); + device.putString(Constants.SELECTED_AUDIO_DEVICE, selectedAudioDevice.getName()); + promise.resolve(device); + } + + @ReactMethod + public void selectAudioDevice(String name) { + AudioDevice selected = availableAudioDevices.get(name); + if (selected == null) { return; } - audioManager.setMode(originalAudioMode); - if (Build.VERSION.SDK_INT >= 26) { - if (focusRequest != null) { - audioManager.abandonAudioFocusRequest(focusRequest); + audioSwitch.selectDevice(selected); + } + + private void startAudioSwitch() { + audioSwitch.start((devices, device) -> { + selectedAudioDevice = device; + WritableMap params = Arguments.createMap(); + for (AudioDevice a : devices) { + params.putBoolean(a.getName(), device.getName().equals(a.getName())); + availableAudioDevices.put(a.getName(), a); } - } else { - audioManager.abandonAudioFocus(null); - } + eventManager.sendEvent(EVENT_AUDIO_DEVICES_UPDATED, params); + return Unit.INSTANCE; + }); } private boolean checkPermissionForMicrophone() { @@ -879,11 +945,35 @@ private void requestPermissionForMicrophone() { return; } if (ActivityCompat.shouldShowRequestPermissionRationale(getCurrentActivity(), Manifest.permission.RECORD_AUDIO)) { -// Snackbar.make(coordinatorLayout, -// "Microphone permissions needed. Please allow in your application settings.", -// SNACKBAR_DURATION).show(); + // TODO } else { ActivityCompat.requestPermissions(getCurrentActivity(), new String[]{Manifest.permission.RECORD_AUDIO}, MIC_PERMISSION_REQUEST_CODE); } } + + public static Bundle getActivityLaunchOption(Intent intent) { + Bundle initialProperties = new Bundle(); + if (intent == null || intent.getAction() == null) { + return initialProperties; + } + + Bundle callBundle = new Bundle(); + switch (intent.getAction()) { + case Constants.ACTION_INCOMING_CALL_NOTIFICATION: + callBundle.putString(Constants.CALL_SID, intent.getStringExtra(Constants.CALL_SID)); + callBundle.putString(Constants.CALL_FROM, intent.getStringExtra(Constants.CALL_FROM)); + callBundle.putString(Constants.CALL_TO, intent.getStringExtra(Constants.CALL_TO)); + initialProperties.putBundle(Constants.CALL_INVITE_KEY, callBundle); + break; + + case Constants.ACTION_ACCEPT: + callBundle.putString(Constants.CALL_SID, intent.getStringExtra(Constants.CALL_SID)); + callBundle.putString(Constants.CALL_FROM, intent.getStringExtra(Constants.CALL_FROM)); + callBundle.putString(Constants.CALL_TO, intent.getStringExtra(Constants.CALL_TO)); + callBundle.putString(Constants.CALL_STATE, Constants.CALL_STATE_CONNECTED); + initialProperties.putBundle(Constants.CALL_KEY, callBundle); + break; + } + return initialProperties; + } } 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 520b77f1..15dd4395 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,6 +4,8 @@ import android.content.Intent; import android.os.Handler; import android.os.Looper; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import android.util.Log; @@ -14,6 +16,9 @@ import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; +import com.hoxfon.react.RNTwilioVoice.Constants; +import com.hoxfon.react.RNTwilioVoice.IncomingCallNotificationService; +import com.twilio.voice.CallException; import com.hoxfon.react.RNTwilioVoice.BuildConfig; import com.hoxfon.react.RNTwilioVoice.CallNotificationManager; import com.twilio.voice.CallInvite; @@ -25,30 +30,18 @@ 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_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; public class VoiceFirebaseMessagingService extends FirebaseMessagingService { - private CallNotificationManager callNotificationManager; - @Override public void onCreate() { super.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); + super.onNewToken(token); + Intent intent = new Intent(Constants.ACTION_FCM_TOKEN); LocalBroadcastManager.getInstance(this).sendBroadcast(intent); } @@ -71,76 +64,53 @@ public void onMessageReceived(RemoteMessage remoteMessage) { Random randomNumberGenerator = new Random(System.currentTimeMillis()); final int notificationId = randomNumberGenerator.nextInt(); - boolean valid = Voice.handleMessage(data, new MessageListener() { + boolean valid = Voice.handleMessage(this, data, new MessageListener() { @Override public void onCallInvite(final CallInvite callInvite) { - // We need to run this on the main thread, as the React code assumes that is true. // Namely, DevServerHelper constructs a Handler() without a Looper, which triggers: // "Can't create handler inside thread that has not called Looper.prepare()" Handler handler = new Handler(Looper.getMainLooper()); handler.post(new Runnable() { public void run() { + CallNotificationManager callNotificationManager = new CallNotificationManager(); // Construct and load our normal React JS code bundle ReactInstanceManager mReactInstanceManager = ((ReactApplication) getApplication()).getReactNativeHost().getReactInstanceManager(); ReactContext context = mReactInstanceManager.getCurrentReactContext(); - // If it's constructed, send a notification + + // initialise appImportance to the highest possible importance in case context is null + int appImportance = ActivityManager.RunningAppProcessInfo.IMPORTANCE_GONE; + if (context != null) { - int appImportance = callNotificationManager.getApplicationImportance((ReactApplicationContext)context); + appImportance = callNotificationManager.getApplicationImportance((ReactApplicationContext)context); + } + if (BuildConfig.DEBUG) { + Log.d(TAG, "context: " + context + ". appImportance = " + appImportance); + } + + // when the app is not started or in the background + if (appImportance > ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE) { if (BuildConfig.DEBUG) { - Log.d(TAG, "CONTEXT present appImportance = " + appImportance); - } - Intent launchIntent = callNotificationManager.getLaunchIntent( - (ReactApplicationContext)context, - notificationId, - callInvite, - false, - appImportance - ); - // app is not in foreground - if (appImportance != ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND) { - context.startActivity(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() { - public void onReactContextInitialized(ReactContext context) { - int appImportance = callNotificationManager.getApplicationImportance((ReactApplicationContext)context); - if (BuildConfig.DEBUG) { - Log.d(TAG, "CONTEXT not present appImportance = " + appImportance); - } - Intent launchIntent = callNotificationManager.getLaunchIntent((ReactApplicationContext)context, notificationId, callInvite, true, appImportance); - context.startActivity(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()) { - // Construct it in the background - mReactInstanceManager.createReactContextInBackground(); + Log.d(TAG, "Background"); } + handleInvite(callInvite, notificationId); + return; } + + Intent intent = new Intent(Constants.ACTION_INCOMING_CALL); + intent.putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(Constants.INCOMING_CALL_INVITE, callInvite); + LocalBroadcastManager.getInstance(context).sendBroadcast(intent); } }); } @Override - public void onCancelledCallInvite(final CancelledCallInvite cancelledCallInvite) { - Handler handler = new Handler(Looper.getMainLooper()); - handler.post(new Runnable() { - public void run() { - VoiceFirebaseMessagingService.this.sendCancelledCallInviteToActivity(cancelledCallInvite); - } - }); + public void onCancelledCallInvite(@NonNull CancelledCallInvite cancelledCallInvite, @Nullable CallException callException) { + // The call is prematurely disconnected by the caller. + // The callee does not accept or reject the call within 30 seconds. + // The Voice SDK is unable to establish a connection to Twilio. + handleCancelledCallInvite(cancelledCallInvite, callException); } }); @@ -155,13 +125,22 @@ public void run() { } } - /* - * Send the CancelledCallInvite to the TwilioVoiceModule - */ - 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); + private void handleInvite(CallInvite callInvite, int notificationId) { + Intent intent = new Intent(this, IncomingCallNotificationService.class); + intent.setAction(Constants.ACTION_INCOMING_CALL); + intent.putExtra(Constants.INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(Constants.INCOMING_CALL_INVITE, callInvite); + + startService(intent); + } + + private void handleCancelledCallInvite(CancelledCallInvite cancelledCallInvite, CallException callException) { + Intent intent = new Intent(this, IncomingCallNotificationService.class); + intent.setAction(Constants.ACTION_CANCEL_CALL); + intent.putExtra(Constants.CANCELLED_CALL_INVITE, cancelledCallInvite); + if (callException != null) { + intent.putExtra(Constants.CANCELLED_CALL_INVITE_EXCEPTION, callException.getMessage()); + } + startService(intent); } } diff --git a/android/src/main/res/values/style.xml b/android/src/main/res/values/style.xml new file mode 100644 index 00000000..fc100b88 --- /dev/null +++ b/android/src/main/res/values/style.xml @@ -0,0 +1,5 @@ + + + #d6312e + #258c42 + \ No newline at end of file diff --git a/android/src/main/res/values/values.xml b/android/src/main/res/values/values.xml new file mode 100644 index 00000000..e89d98e7 --- /dev/null +++ b/android/src/main/res/values/values.xml @@ -0,0 +1,13 @@ + + + Answer + Decline + Hang up + Call in progress + Incoming call + is calling + Missed call + last call from: + called + missed calls + diff --git a/index.js b/index.js index b84b91bf..e472949b 100644 --- a/index.js +++ b/index.js @@ -22,6 +22,7 @@ const _eventHandlers = { callStateRinging: new Map(), callInviteCancelled: new Map(), callRejected: new Map(), + audioDevicesUpdated: new Map(), } const Twilio = { @@ -70,7 +71,12 @@ const Twilio = { TwilioVoice.ignore() }, setMuted: TwilioVoice.setMuted, - setSpeakerPhone: TwilioVoice.setSpeakerPhone, + setSpeakerPhone(value) { + if (Platform.OS === ANDROID) { + return + } + return TwilioVoice.setSpeakerPhone(value) + }, sendDigits: TwilioVoice.sendDigits, hold: TwilioVoice.setOnHold, requestPermissions(senderId) { @@ -86,9 +92,32 @@ const Twilio = { } }, unregister() { + TwilioVoice.unregister() + }, + // getAudioDevices returns all audio devices connected + // { + // "Speakerphone": false, + // "Earnpiece": true, // true indicates the selected audio device + // } + getAudioDevices() { if (Platform.OS === IOS) { - TwilioVoice.unregister() + return } + TwilioVoice.getAudioDevices() + }, + // getSelectedAudioDevice returns the selected audio device + getSelectedAudioDevice() { + if (Platform.OS === IOS) { + return + } + TwilioVoice.getSelectedAudioDevice() + }, + // selectAudioDevice selects the passed audio device for the current active call + selectAudioDevice(name: string) { + if (Platform.OS === IOS) { + return + } + TwilioVoice.selectAudioDevice(name) }, addEventListener(type, handler) { if (!_eventHandlers.hasOwnProperty(type)) { @@ -106,7 +135,7 @@ const Twilio = { } _eventHandlers[type].get(handler).remove() _eventHandlers[type].delete(handler) - } + }, } export default Twilio diff --git a/ios/RNTwilioVoice/RNTwilioVoice.m b/ios/RNTwilioVoice/RNTwilioVoice.m index df4c3810..1c8a96b9 100644 --- a/ios/RNTwilioVoice/RNTwilioVoice.m +++ b/ios/RNTwilioVoice/RNTwilioVoice.m @@ -13,7 +13,6 @@ @interface RNTwilioVoice ()