From 458b0d491b0efa179c9ba91fe3b270e817c4f52c Mon Sep 17 00:00:00 2001 From: Fabrizio Moscon Date: Sat, 14 Nov 2020 18:29:40 +0000 Subject: [PATCH] feat: Android Twilio SDK 5.0.2 - notification for incoming call when the app is in the background --- README.md | 2 +- android/build.gradle | 3 +- android/gradle.properties | 2 +- .../CallNotificationManager.java | 309 ++++-------- .../hoxfon/react/RNTwilioVoice/Constants.java | 26 + .../IncomingCallNotificationService.java | 230 +++++++++ .../RNTwilioVoice/TwilioVoiceModule.java | 445 +++++++++--------- .../fcm/VoiceFirebaseMessagingService.java | 119 ++--- android/src/main/res/values/values.xml | 9 + 9 files changed, 624 insertions(+), 521 deletions(-) create mode 100644 android/src/main/java/com/hoxfon/react/RNTwilioVoice/Constants.java create mode 100644 android/src/main/java/com/hoxfon/react/RNTwilioVoice/IncomingCallNotificationService.java create mode 100644 android/src/main/res/values/values.xml diff --git a/README.md b/README.md index 8500b4a0..9409b925 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ This is a React-Native wrapper for [Twilio Programmable Voice SDK](https://www.t ## Twilio Programmable Voice SDK -- Android 4.5.0 (bundled within the module) +- Android 5.0.0 (bundled within the module) - iOS 5.1.0 (specified by the app's own podfile) ## Breaking changes in v4.0.0 diff --git a/android/build.gradle b/android/build.gradle index fe752fa4..8be3bc3c 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -55,9 +55,10 @@ 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.twilio:voice-android:5.0.2' implementation "com.android.support:appcompat-v7:$supportLibVersion" implementation 'com.facebook.react:react-native:+' implementation 'com.google.firebase:firebase-messaging:17.6.+' + implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' 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/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/CallNotificationManager.java index af4e9175..af272c82 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,24 @@ 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 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; - +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_HANGUP_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_INCOMING_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_MISSED_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.INCOMING_CALL_NOTIFICATION_ID; +import static com.hoxfon.react.RNTwilioVoice.Constants.CALL_SID_KEY; +import static com.hoxfon.react.RNTwilioVoice.Constants.MISSED_CALLS_GROUP; +import static com.hoxfon.react.RNTwilioVoice.Constants.MISSED_CALLS_NOTIFICATION_ID; +import static com.hoxfon.react.RNTwilioVoice.Constants.HANGUP_NOTIFICATION_ID; +import static com.hoxfon.react.RNTwilioVoice.Constants.PREFERENCE_KEY; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_CLEAR_MISSED_CALLS_COUNT; +import static com.hoxfon.react.RNTwilioVoice.Constants.CLEAR_MISSED_CALLS_NOTIFICATION_ID; public class CallNotificationManager { @@ -72,7 +60,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,144 +72,40 @@ 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) - { - if (BuildConfig.DEBUG) { - Log.d(TAG, "createIncomingCallNotification intent "+launchIntent.getFlags()); - } - 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) { + public void createMissedCallNotification(ReactApplicationContext context, String callSid, String callFrom) { SharedPreferences sharedPref = context.getSharedPreferences(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) .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_UPDATE_CURRENT + ); + + PendingIntent clearMissedCallsCountPendingIntent = PendingIntent.getBroadcast( + context, + 0, + new Intent(ACTION_CLEAR_MISSED_CALLS_COUNT) + .putExtra(INCOMING_CALL_NOTIFICATION_ID, 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.putString(CALL_SID_KEY, callSid); /* * Create the notification shown in the notification drawer */ + String title = context.getString(R.string.call_missed); NotificationCompat.Builder notification = new NotificationCompat.Builder(context, VOICE_CHANNEL) .setGroup(MISSED_CALLS_GROUP) @@ -230,8 +114,8 @@ public void createMissedCallNotification(ReactApplicationContext context, CallIn .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 + " called") .setAutoCancel(true) .setShowWhen(true) .setExtras(extras) @@ -242,11 +126,11 @@ public void createMissedCallNotification(ReactApplicationContext context, CallIn missedCalls++; if (missedCalls == 1) { inboxStyle = new NotificationCompat.InboxStyle(); - inboxStyle.setBigContentTitle("Missed call"); + inboxStyle.setBigContentTitle(title); } else { inboxStyle.setBigContentTitle(String.valueOf(missedCalls) + " missed calls"); } - inboxStyle.addLine("from: " +callInvite.getFrom()); + inboxStyle.addLine("last call from: " +callFrom); sharedPrefEditor.putInt(MISSED_CALLS_GROUP, missedCalls); sharedPrefEditor.commit(); @@ -265,89 +149,74 @@ public void createMissedCallNotification(ReactApplicationContext context, CallIn notificationManager.notify(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(ACTION_INCOMING_CALL) + .putExtra(INCOMING_CALL_NOTIFICATION_ID, 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), + intent, 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(ACTION_HANGUP_CALL) + .putExtra(INCOMING_CALL_NOTIFICATION_ID, HANGUP_NOTIFICATION_ID), + 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); - - 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); - 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; - } + Notification notification; 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 Notification.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); - } - } + 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(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) { 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..a50041c3 --- /dev/null +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/Constants.java @@ -0,0 +1,26 @@ +package com.hoxfon.react.RNTwilioVoice; + +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_ERROR = "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_NOTIFICATION = "ACTION_INCOMING_CALL_NOTIFICATION"; + public static final String ACTION_INCOMING_CALL = "ACTION_INCOMING_CALL"; + 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"; +} 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..bb1ff051 --- /dev/null +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/IncomingCallNotificationService.java @@ -0,0 +1,230 @@ +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.graphics.Color; +import android.os.Build; +import android.os.Bundle; +import android.os.IBinder; +import android.util.Log; + +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.Constants.ACTION_ACCEPT; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_CANCEL_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_INCOMING_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_INCOMING_CALL_NOTIFICATION; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_REJECT; +import static com.hoxfon.react.RNTwilioVoice.Constants.CALL_SID_KEY; +import static com.hoxfon.react.RNTwilioVoice.Constants.INCOMING_CALL_INVITE; +import static com.hoxfon.react.RNTwilioVoice.Constants.INCOMING_CALL_NOTIFICATION_ID; +import static com.hoxfon.react.RNTwilioVoice.TwilioVoiceModule.TAG; + +public class IncomingCallNotificationService extends Service { + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + String action = intent.getAction(); + + CallInvite callInvite = intent.getParcelableExtra(INCOMING_CALL_INVITE); + int notificationId = intent.getIntExtra(INCOMING_CALL_NOTIFICATION_ID, 0); + + switch (action) { + case ACTION_INCOMING_CALL: + handleIncomingCall(callInvite, notificationId); + break; + case ACTION_ACCEPT: + accept(callInvite, notificationId); + break; + case ACTION_REJECT: + reject(callInvite); + break; + case ACTION_CANCEL_CALL: + handleCancelledCall(intent); + break; + default: + break; + } + return START_NOT_STICKY; + } + + @Override + public IBinder onBind(Intent intent) { + return null; + } + + private Notification createNotification(CallInvite callInvite, int notificationId, int channelImportance) { + Intent intent = new Intent(this, getMainActivityClass(this)); + intent.setAction(ACTION_INCOMING_CALL_NOTIFICATION); + intent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(INCOMING_CALL_INVITE, callInvite); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + 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(CALL_SID_KEY, callInvite.getCallSid()); + + String contextText = callInvite.getFrom() + " is calling."; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // TODO make text configurable from app resources + return buildNotification(contextText, + pendingIntent, + extras, + callInvite, + notificationId, + createChannel(channelImportance)); + } else { + return new NotificationCompat.Builder(this) + .setSmallIcon(R.drawable.ic_call_white_24dp) + .setContentTitle(getString(R.string.call_incoming)) + .setContentText(contextText) + .setAutoCancel(true) + .setExtras(extras) + .setContentIntent(pendingIntent) + .setGroup("test_app_notification") + .setColor(Color.rgb(214, 10, 37)) + .build(); + } + } + + /** + * 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) { + Intent rejectIntent = new Intent(getApplicationContext(), IncomingCallNotificationService.class); + rejectIntent.setAction(ACTION_REJECT); + rejectIntent.putExtra(INCOMING_CALL_INVITE, callInvite); + rejectIntent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); + PendingIntent piRejectIntent = PendingIntent.getService(getApplicationContext(), 0, rejectIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + Intent acceptIntent = new Intent(getApplicationContext(), IncomingCallNotificationService.class); + acceptIntent.setAction(ACTION_ACCEPT); + acceptIntent.putExtra(INCOMING_CALL_INVITE, callInvite); + acceptIntent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); + PendingIntent piAcceptIntent = PendingIntent.getService(getApplicationContext(), 0, acceptIntent, PendingIntent.FLAG_UPDATE_CURRENT); + + Notification.Builder builder = + new Notification.Builder(getApplicationContext(), channelId) + .setSmallIcon(R.drawable.ic_call_white_24dp) + .setContentTitle(getString(R.string.call_incoming)) + .setContentText(text) + .setCategory(Notification.CATEGORY_CALL) + .setExtras(extras) + .setAutoCancel(true) + .addAction(android.R.drawable.ic_menu_delete, getString(R.string.decline), piRejectIntent) + .addAction(android.R.drawable.ic_menu_call, getString(R.string.answer), piAcceptIntent) + .setFullScreenIntent(pendingIntent, true); + + 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 +// callInviteChannel.setSound(); + 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(INCOMING_CALL_INVITE, callInvite); + activeCallIntent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); + activeCallIntent.setAction(ACTION_ACCEPT); + this.startActivity(activeCallIntent); + } + + private void reject(CallInvite callInvite) { + endForeground(); + callInvite.reject(getApplicationContext()); + } + + private void handleCancelledCall(Intent intent) { + 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); + } + + private void setCallInProgressNotification(CallInvite callInvite, int notificationId) { + int importance = NotificationManager.IMPORTANCE_LOW; + if (!isAppVisible()) { + Log.i(TAG, "setCallInProgressNotification - 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 (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && !isAppVisible()) { + return; + } + Intent intent = new Intent(this, getMainActivityClass(this)); + intent.setAction(ACTION_INCOMING_CALL); + intent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(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/TwilioVoiceModule.java b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java index fb68f49d..f2521e46 100644 --- a/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java +++ b/android/src/main/java/com/hoxfon/react/RNTwilioVoice/TwilioVoiceModule.java @@ -39,10 +39,7 @@ 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.voice.AcceptOptions; import com.twilio.voice.Call; import com.twilio.voice.CallException; @@ -57,13 +54,25 @@ import java.util.HashMap; import java.util.Map; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_ACCEPT; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_CANCEL_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_CLEAR_MISSED_CALLS_COUNT; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_FCM_TOKEN; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_HANGUP_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_INCOMING_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_MISSED_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.CANCELLED_CALL_INVITE; +import static com.hoxfon.react.RNTwilioVoice.Constants.CANCELLED_CALL_INVITE_ERROR; +import static com.hoxfon.react.RNTwilioVoice.Constants.INCOMING_CALL_INVITE; +import static com.hoxfon.react.RNTwilioVoice.Constants.MISSED_CALLS_GROUP; +import static com.hoxfon.react.RNTwilioVoice.Constants.PREFERENCE_KEY; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_CONNECT; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_DISCONNECT; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_DEVICE_DID_RECEIVE_INCOMING; +import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CALL_INVITE_CANCELLED; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_DEVICE_NOT_READY; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_DEVICE_READY; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CALL_STATE_RINGING; -import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CALL_INVITE_CANCELLED; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_IS_RECONNECTING; import static com.hoxfon.react.RNTwilioVoice.EventManager.EVENT_CONNECTION_DID_RECONNECT; @@ -74,7 +83,7 @@ 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 int savedAudioMode = AudioManager.MODE_NORMAL; private boolean isReceiverRegistered = false; private VoiceBroadcastReceiver voiceBroadcastReceiver; @@ -82,30 +91,6 @@ public class TwilioVoiceModule extends ReactContextBaseJavaModule implements Act // 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; @@ -123,12 +108,10 @@ public class TwilioVoiceModule extends ReactContextBaseJavaModule implements Act 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 callInviteIntent; public TwilioVoiceModule(ReactApplicationContext reactContext, boolean shouldAskForMicPermission) { @@ -177,6 +160,20 @@ public void onHostResume() { */ getCurrentActivity().setVolumeControlStream(AudioManager.STREAM_VOICE_CALL); registerReceiver(); + + Intent intent = getCurrentActivity().getIntent(); + if (intent == null) { + return; + } + int currentCallInviteIntent = intent.hashCode(); + String action = intent.getAction(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "Module creation "+action+". Intent "+ intent.getExtras()); + } + if (action.equals(ACTION_ACCEPT) && callInviteIntent != currentCallInviteIntent) { + callInviteIntent = currentCallInviteIntent; + handleIncomingCallIntent(intent); + } } @Override @@ -193,10 +190,6 @@ public void onHostDestroy() { } @Override - public String getName() { - return TAG; - } - public void onNewIntent(Intent intent) { // This is called only when the App is in the foreground if (BuildConfig.DEBUG) { @@ -205,6 +198,11 @@ public void onNewIntent(Intent intent) { handleIncomingCallIntent(intent); } + @Override + public String getName() { + return TAG; + } + private RegistrationListener registrationListener() { return new RegistrationListener() { @Override @@ -277,7 +275,7 @@ public void onConnected(Call call) { caller = toNumber; } activeCall = call; - callNotificationManager.createHangupLocalNotification(getReactApplicationContext(), + callNotificationManager.createHangupNotification(getReactApplicationContext(), call.getSid(), caller); } eventManager.sendEvent(EVENT_CONNECTION_DID_CONNECT, params); @@ -328,7 +326,6 @@ public void onDisconnected(Call call, CallException error) { unsetAudioFocus(); proximityManager.stopProximitySensor(); headsetManager.stopWiredHeadsetEvent(getReactApplicationContext()); - callAccepted = false; WritableMap params = Arguments.createMap(); String callSid = ""; @@ -360,8 +357,6 @@ public void onConnectFailure(Call call, CallException error) { } unsetAudioFocus(); proximityManager.stopProximitySensor(); - callAccepted = false; - Log.e(TAG, String.format("CallListener onConnectFailure error: %d, %s", error.getErrorCode(), error.getMessage())); @@ -391,30 +386,48 @@ public void onConnectFailure(Call call, CallException error) { * Register the Voice broadcast receiver */ private void registerReceiver() { + if (isReceiverRegistered) { + return; + } + IntentFilter intentFilter = new IntentFilter(); + intentFilter.addAction(ACTION_INCOMING_CALL); + intentFilter.addAction(ACTION_CANCEL_CALL); + intentFilter.addAction(ACTION_FCM_TOKEN); + intentFilter.addAction(ACTION_MISSED_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 unregisterReceiver() { -// if (isReceiverRegistered) { -// LocalBroadcastManager.getInstance(getReactApplicationContext()).unregisterReceiver(voiceBroadcastReceiver); -// isReceiverRegistered = false; -// } -// } + private class VoiceBroadcastReceiver extends BroadcastReceiver { + + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "VoiceBroadcastReceiver.onReceive "+action+". Intent "+ intent.getExtras()); + } + if (action.equals(ACTION_INCOMING_CALL) || action.equals(ACTION_CANCEL_CALL)) { + /* + * Handle the incoming or cancelled call invite + */ + handleIncomingCallIntent(intent); + } + } + } 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); @@ -423,12 +436,6 @@ private void registerActionReceiver() { public void onReceive(Context context, Intent intent) { String action = intent.getAction(); switch (action) { - case ACTION_ANSWER_CALL: - accept(); - break; - case ACTION_REJECT_CALL: - reject(); - break; case ACTION_HANGUP_CALL: disconnect(); break; @@ -438,10 +445,6 @@ public void onReceive(Context context, Intent intent) { sharedPrefEditor.putInt(MISSED_CALLS_GROUP, 0); sharedPrefEditor.commit(); } - // 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); } @@ -457,94 +460,103 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { } private void handleIncomingCallIntent(Intent intent) { - if (intent.getAction().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 - ); - } - // 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); - } - } else { - // TODO evaluate what more is needed at this point? - Log.e(TAG, "ACTION_INCOMING_CALL but not active call"); - } - } else if (intent.getAction().equals(ACTION_CANCEL_CALL_INVITE)) { - SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); - if (BuildConfig.DEBUG) { - Log.d(TAG, "activeCallInvite was cancelled by " + activeCallInvite.getFrom()); - } - if (!callAccepted) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "creating a missed call"); - } - callNotificationManager.createMissedCallNotification(getReactApplicationContext(), activeCallInvite); - int appImportance = callNotificationManager.getApplicationImportance(getReactApplicationContext()); - if (appImportance != RunningAppProcessInfo.IMPORTANCE_BACKGROUND) { - WritableMap params = Arguments.createMap(); - params.putString("call_sid", activeCallInvite.getCallSid()); - params.putString("call_from", activeCallInvite.getFrom()); - params.putString("call_to", activeCallInvite.getTo()); - params.putString("call_state", Call.State.DISCONNECTED.toString()); - eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); - } - } - clearIncomingNotification(activeCallInvite.getCallSid()); - } else if (intent.getAction().equals(ACTION_FCM_TOKEN)) { - if (BuildConfig.DEBUG) { - Log.d(TAG, "handleIncomingCallIntent ACTION_FCM_TOKEN"); - } - registerForCallInvites(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "handleIncomingCallIntent"); } - } + if (intent == null || intent.getAction() == null) { + return; + } + String action = intent.getAction(); + activeCallInvite = intent.getParcelableExtra(INCOMING_CALL_INVITE); - private class VoiceBroadcastReceiver extends BroadcastReceiver { + switch (action) { + case ACTION_INCOMING_CALL: + handleIncomingCall(); + break; - @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)) { + case ACTION_CANCEL_CALL: + handleCancel(intent); + break; + + case 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 { + break; + + case ACTION_FCM_TOKEN: + if (BuildConfig.DEBUG) { + Log.d(TAG, "handleIncomingCallIntent ACTION_FCM_TOKEN"); + } + registerForCallInvites(); + break; + + case ACTION_ACCEPT: + acceptFromIntent(intent); + break; + + default: Log.e(TAG, "received broadcast unhandled action " + action); + break; + } + } + + private void handleIncomingCall() { + if (BuildConfig.DEBUG) { + Log.d(TAG, "handleIncomingCall"); + } + if (activeCallInvite == null) { + // TODO evaluate what more is needed at this point? + Log.e(TAG, "ACTION_INCOMING_CALL but not active call"); + return; + } + 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 + ); + } + // send a JS event ONLY if the app is VISIBLE + // at startup the app would try to fetch the activeIncoming calls + int appImportance = callNotificationManager.getApplicationImportance(getReactApplicationContext()); + if (appImportance <= RunningAppProcessInfo.IMPORTANCE_VISIBLE) { + WritableMap params = Arguments.createMap(); + params.putString("call_sid", activeCallInvite.getCallSid()); + params.putString("call_from", activeCallInvite.getFrom()); + params.putString("call_to", activeCallInvite.getTo()); + eventManager.sendEvent(EVENT_DEVICE_DID_RECEIVE_INCOMING, params); + } + } + + private void handleCancel(Intent intent) { + CancelledCallInvite cancelledCallInvite = intent.getParcelableExtra(CANCELLED_CALL_INVITE); + + SoundPoolManager.getInstance(getReactApplicationContext()).stopRinging(); + if (BuildConfig.DEBUG) { + Log.d(TAG, "creating a missed call"); + } + callNotificationManager.createMissedCallNotification( + getReactApplicationContext(), + cancelledCallInvite.getCallSid(), + cancelledCallInvite.getFrom() + ); + // if the app is VISIBLE, send a call invite cancelled event + int appImportance = callNotificationManager.getApplicationImportance(getReactApplicationContext()); + if (appImportance <= RunningAppProcessInfo.IMPORTANCE_VISIBLE) { + WritableMap params = Arguments.createMap(); + params.putString("call_sid", cancelledCallInvite.getCallSid()); + params.putString("call_from", cancelledCallInvite.getFrom()); + params.putString("call_to", cancelledCallInvite.getTo()); + String cancelledCallInviteErr = intent.getStringExtra(CANCELLED_CALL_INVITE_ERROR); + // pass this to the event even though in v5.0.2 it seems to always be "Call Cancelled" + if (cancelledCallInviteErr != null) { + params.putString("err", cancelledCallInviteErr); } + // TODO handle customParamters + eventManager.sendEvent(EVENT_CALL_INVITE_CANCELLED, params); } } @@ -570,21 +582,6 @@ public void initWithAccessToken(final String accessToken, Promise promise) { 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; - } - /* * Register your FCM token with Twilio to receive incoming call invites * @@ -593,89 +590,66 @@ 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; - } - - // 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); - } - } - }); + final 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); + } + + public void acceptFromIntent(Intent intent) { + if (BuildConfig.DEBUG) { + Log.d(TAG, "acceptFromIntent()"); + } + activeCallInvite = intent.getParcelableExtra(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()"); } + 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"); activeCallInvite.reject(getReactApplicationContext()); - clearIncomingNotification(activeCallInvite.getCallSid()); } - eventManager.sendEvent(EVENT_CONNECTION_DID_DISCONNECT, params); + eventManager.sendEvent(EVENT_CALL_INVITE_CANCELLED, params); } @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 @@ -729,10 +703,25 @@ public void connect(ReadableMap params) { } } +// Set iceServers = new HashSet<>(); +// 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(); ConnectOptions connectOptions = new ConnectOptions.Builder(accessToken) - .enableDscp(true) - .params(twiMLParams) - .build(); + .enableDscp(true) + .params(twiMLParams) + .build(); activeCall = Voice.connect(getReactApplicationContext(), connectOptions, callListener); } @@ -812,18 +801,18 @@ public void setOnHold(Boolean value) { private void setAudioFocus() { if (audioManager == null) { - audioManager.setMode(originalAudioMode); + audioManager.setMode(savedAudioMode); audioManager.abandonAudioFocus(null); return; } - originalAudioMode = audioManager.getMode(); + savedAudioMode = 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) + focusRequest = new AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT) .setAudioAttributes(playbackAttributes) .setAcceptsDelayedFocusGain(true) .setOnAudioFocusChangeListener(new AudioManager.OnAudioFocusChangeListener() { @@ -851,11 +840,11 @@ public void onAudioFocusChange(int focusChange) {} private void unsetAudioFocus() { if (audioManager == null) { - audioManager.setMode(originalAudioMode); + audioManager.setMode(savedAudioMode); audioManager.abandonAudioFocus(null); return; } - audioManager.setMode(originalAudioMode); + audioManager.setMode(savedAudioMode); if (Build.VERSION.SDK_INT >= 26) { if (focusRequest != null) { audioManager.abandonAudioFocusRequest(focusRequest); 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..9be8f547 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,8 @@ import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; +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; @@ -24,23 +28,20 @@ import java.util.Map; import java.util.Random; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_CANCEL_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_FCM_TOKEN; +import static com.hoxfon.react.RNTwilioVoice.Constants.ACTION_INCOMING_CALL; +import static com.hoxfon.react.RNTwilioVoice.Constants.INCOMING_CALL_INVITE; +import static com.hoxfon.react.RNTwilioVoice.Constants.CANCELLED_CALL_INVITE; +import static com.hoxfon.react.RNTwilioVoice.Constants.CANCELLED_CALL_INVITE_ERROR; +import static com.hoxfon.react.RNTwilioVoice.Constants.INCOMING_CALL_NOTIFICATION_ID; 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 @@ -71,76 +72,44 @@ 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 - if (context != null) { - int appImportance = callNotificationManager.getApplicationImportance((ReactApplicationContext)context); - 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(); - } + + // if the app is closed or not visible, create a heads-up notification + int appImportance = callNotificationManager.getApplicationImportance((ReactApplicationContext)context); + if (BuildConfig.DEBUG) { + Log.d(TAG, "CONTEXT present appImportance = " + appImportance); + } + if (context == null || appImportance > ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE) { + handleInvite(callInvite, notificationId); + return; } + + 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); } }); } @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 +124,23 @@ 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); + private void handleInvite(CallInvite callInvite, int notificationId) { + Intent intent = new Intent(this, IncomingCallNotificationService.class); + intent.setAction(ACTION_INCOMING_CALL); + intent.putExtra(INCOMING_CALL_NOTIFICATION_ID, notificationId); + intent.putExtra(INCOMING_CALL_INVITE, callInvite); + + startService(intent); + } + + private void handleCancelledCallInvite(CancelledCallInvite cancelledCallInvite, CallException callException) { + Log.e(TAG, "handleCancelledCallInvite exception: " + callException.getMessage()); + Intent intent = new Intent(this, IncomingCallNotificationService.class); + intent.setAction(ACTION_CANCEL_CALL); intent.putExtra(CANCELLED_CALL_INVITE, cancelledCallInvite); - LocalBroadcastManager.getInstance(this).sendBroadcast(intent); + if (callException != null) { + intent.putExtra(CANCELLED_CALL_INVITE_ERROR, callException.getMessage()); + } + startService(intent); } } diff --git a/android/src/main/res/values/values.xml b/android/src/main/res/values/values.xml new file mode 100644 index 00000000..1788f8b2 --- /dev/null +++ b/android/src/main/res/values/values.xml @@ -0,0 +1,9 @@ + + + Answer + Decline + Hang up + Call in progress + Incoming call + Missed call +