diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java index 7e659794c..91b027f78 100644 --- a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotifications.java @@ -1,215 +1,213 @@ package app.comm.android.notifications; import android.Manifest; import android.app.Activity; import android.app.NotificationChannel; import android.app.NotificationManager; import android.content.Context; import android.os.Bundle; import android.service.notification.StatusBarNotification; import androidx.core.app.ActivityCompat; import androidx.core.app.NotificationManagerCompat; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.WritableMap; import com.google.firebase.messaging.FirebaseMessaging; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import me.leolin.shortcutbadger.ShortcutBadger; public class CommAndroidNotifications extends ReactContextBaseJavaModule { private static AtomicReference notificationsPermissionRequestPromise = new AtomicReference(null); private NotificationManager notificationManager; // Application can request multiple permission, but all // will be resolved via `onRequestPermissionsResult` callback // in MainActivity. Therefore we use custom request codes // to differentiate between different permission requests public static final int COMM_ANDROID_NOTIFICATIONS_REQUEST_CODE = 11111; CommAndroidNotifications(ReactApplicationContext reactContext) { super(reactContext); Context context = reactContext.getApplicationContext(); this.notificationManager = (NotificationManager)context.getSystemService( Context.NOTIFICATION_SERVICE); } @Override public String getName() { return "CommAndroidNotifications"; } @ReactMethod public void removeAllActiveNotificationsForThread(String threadID) { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.M) { // Method used below appeared in API level 23. Details: // https://developer.android.com/reference/android/app/NotificationManager#getActiveNotifications() // https://developer.android.com/reference/android/os/Build.VERSION_CODES#M return; } for (StatusBarNotification notification : notificationManager.getActiveNotifications()) { Bundle data = notification.getNotification().extras; if (data == null) { continue; } String notificationThreadID = data.getString("threadID"); - if (notificationThreadID != null && - notificationThreadID.equals(threadID)) { + String notificationGroup = notification.getNotification().getGroup(); + if (threadID.equals(notificationThreadID) || + threadID.equals(notificationGroup)) { notificationManager.cancel(notification.getTag(), notification.getId()); } } } @ReactMethod - public void getInitialNotification(Promise promise) { - Bundle initialNotification = - getCurrentActivity().getIntent().getParcelableExtra("message"); - if (initialNotification == null) { + public void getInitialNotificationThreadID(Promise promise) { + String initialNotificationThreadID = + getCurrentActivity().getIntent().getStringExtra("threadID"); + if (initialNotificationThreadID == null) { promise.resolve(null); return; } - WritableMap jsReadableNotification = - CommAndroidNotificationParser.parseRemoteMessageToJSMessage( - initialNotification); - promise.resolve(jsReadableNotification); + promise.resolve(initialNotificationThreadID); } @ReactMethod public void createChannel( String channelID, String name, int importance, String description) { if (android.os.Build.VERSION.SDK_INT < android.os.Build.VERSION_CODES.O) { // Method used below appeared in API level 26. Details: // https://developer.android.com/develop/ui/views/notifications/channels#CreateChannel return; } NotificationChannel channel = new NotificationChannel(channelID, name, importance); if (description != null) { channel.setDescription(description); } notificationManager.createNotificationChannel(channel); } @ReactMethod public void setBadge(int count) { if (count == 0) { ShortcutBadger.removeCount(this.getReactApplicationContext()); } else { ShortcutBadger.applyCount(this.getReactApplicationContext(), count); } } @ReactMethod public void removeAllDeliveredNotifications() { notificationManager.cancelAll(); } @ReactMethod public void hasPermission(Promise promise) { promise.resolve(hasPermissionInternal()); } @ReactMethod public void getToken(Promise promise) { FirebaseMessaging.getInstance().getToken().addOnCompleteListener(task -> { if (task.isSuccessful()) { promise.resolve(task.getResult()); } else { promise.reject(task.getException()); } }); } @ReactMethod public void canRequestNotificationsPermissionFromUser(Promise promise) { promise.resolve(canRequestNotificationsPermissionInternal()); } @ReactMethod public void requestNotificationsPermission(Promise promise) { if (!canRequestNotificationsPermissionInternal()) { // If this method is erroneously called on older Android we should // resolve to the value from notifications settings. promise.resolve(hasPermissionInternal()); return; } if (ActivityCompat.shouldShowRequestPermissionRationale( getCurrentActivity(), Manifest.permission.POST_NOTIFICATIONS)) { // Since Android 13 user has to deny notifications permission // twice to remove possibility to show the prompt again. Details: // https://developer.android.com/training/permissions/requesting#handle-denial // On iOS user has to deny notifications permission once to disable the // prompt. We use the method above to mimic iOS behavior on Android. // Details: // https://developer.android.com/topic/performance/vitals/permissions#explain promise.resolve(hasPermissionInternal()); return; } if (!notificationsPermissionRequestPromise.compareAndSet(null, promise)) { promise.reject( "Programmer error: permission request already in progress."); return; } ActivityCompat.requestPermissions( getCurrentActivity(), new String[] {Manifest.permission.POST_NOTIFICATIONS}, CommAndroidNotifications.COMM_ANDROID_NOTIFICATIONS_REQUEST_CODE); } @Override public Map getConstants() { final Map constants = new HashMap<>(); constants.put( "NOTIFICATIONS_IMPORTANCE_HIGH", NotificationManager.IMPORTANCE_HIGH); constants.put( "COMM_ANDROID_NOTIFICATIONS_TOKEN", CommAndroidNotificationsEventEmitter.COMM_ANDROID_NOTIFICATIONS_TOKEN); constants.put( "COMM_ANDROID_NOTIFICATIONS_MESSAGE", CommAndroidNotificationsEventEmitter .COMM_ANDROID_NOTIFICATIONS_MESSAGE); constants.put( "COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED", CommAndroidNotificationsEventEmitter .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED); return constants; } public static void resolveNotificationsPermissionRequestPromise( Activity mainActivity, boolean isPermissionGranted) { if (notificationsPermissionRequestPromise.get() == null) { return; } notificationsPermissionRequestPromise.getAndSet(null).resolve( isPermissionGranted); } private boolean hasPermissionInternal() { return NotificationManagerCompat.from(getReactApplicationContext()) .areNotificationsEnabled(); } private boolean canRequestNotificationsPermissionInternal() { // Application has to explicitly request notifications permission from // user since Android 13. Older versions grant them by default. Details: // https://developer.android.com/develop/ui/views/notifications/notification-permission return android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU; } } diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsEventEmitter.java b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsEventEmitter.java index 270bd27c4..33b03b4ad 100644 --- a/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsEventEmitter.java +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommAndroidNotificationsEventEmitter.java @@ -1,129 +1,124 @@ package app.comm.android.notifications; import android.app.Activity; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.os.Bundle; import android.util.Log; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import com.facebook.react.bridge.ActivityEventListener; import com.facebook.react.bridge.NativeModule; import com.facebook.react.bridge.ReactApplicationContext; import com.facebook.react.bridge.ReactContext; import com.facebook.react.bridge.ReactContextBaseJavaModule; import com.facebook.react.bridge.ReactMethod; import com.facebook.react.bridge.WritableMap; import com.facebook.react.modules.core.DeviceEventManagerModule; public class CommAndroidNotificationsEventEmitter extends ReactContextBaseJavaModule { private static final String TAG = "CommAndroidNotifications"; private volatile int listenersCount = 0; public static final String COMM_ANDROID_NOTIFICATIONS_TOKEN = "commAndroidNotificationsToken"; public static final String COMM_ANDROID_NOTIFICATIONS_MESSAGE = "commAndroidNotificationsMessage"; public static final String COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED = "commAndroidNotificationsNotificationOpened"; CommAndroidNotificationsEventEmitter(ReactApplicationContext reactContext) { super(reactContext); reactContext.addActivityEventListener( new CommAndroidNotificationsActivityEventListener()); LocalBroadcastManager localBroadcastManager = LocalBroadcastManager.getInstance(reactContext); localBroadcastManager.registerReceiver( new CommAndroidNotificationsTokenReceiver(), new IntentFilter(CommNotificationsHandler.TOKEN_EVENT)); localBroadcastManager.registerReceiver( new CommAndroidNotificationsMessageReceiver(), new IntentFilter(CommNotificationsHandler.MESSAGE_EVENT)); } @Override public String getName() { return "CommAndroidNotificationsEventEmitter"; } @ReactMethod public void addListener(String eventName) { this.listenersCount += 1; // This is for the edge case that the app was started by tapping // on notification in notification center. We want to open the app // and navigate to relevant thread. We need to parse notification // so that JS can get its threadID. sendInitialNotificationFromIntentToJS(getCurrentActivity().getIntent()); } @ReactMethod public void removeListeners(Integer count) { this.listenersCount -= count; } private void sendEventToJS(String eventName, Object body) { if (this.listenersCount == 0) { return; } getReactApplicationContext() .getJSModule(DeviceEventManagerModule.RCTDeviceEventEmitter.class) .emit(eventName, body); } private void sendInitialNotificationFromIntentToJS(Intent intent) { - Bundle initialNotification = intent.getParcelableExtra("message"); - if (initialNotification == null) { + String initialNotificationThreadID = intent.getStringExtra("threadID"); + if (initialNotificationThreadID == null) { return; } - WritableMap jsReadableNotification = - CommAndroidNotificationParser.parseRemoteMessageToJSMessage( - initialNotification); - if (jsReadableNotification != null) { - sendEventToJS( - COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, - jsReadableNotification); - } + sendEventToJS( + COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, + initialNotificationThreadID); } private class CommAndroidNotificationsTokenReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { String token = intent.getStringExtra("token"); sendEventToJS(COMM_ANDROID_NOTIFICATIONS_TOKEN, token); } } private class CommAndroidNotificationsMessageReceiver extends BroadcastReceiver { @Override public void onReceive(Context context, Intent intent) { Bundle message = intent.getParcelableExtra("message"); WritableMap jsMessage = CommAndroidNotificationParser.parseRemoteMessageToJSMessage(message); if (jsMessage != null) { sendEventToJS(COMM_ANDROID_NOTIFICATIONS_MESSAGE, jsMessage); } } } private class CommAndroidNotificationsActivityEventListener implements ActivityEventListener { @Override public void onNewIntent(Intent intent) { sendInitialNotificationFromIntentToJS(intent); } @Override public void onActivityResult( Activity activity, int requestCode, int resultCode, Intent data) { // Required by ActivityEventListener, but not needed for this project } } } diff --git a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java index bc5a03384..83816e629 100644 --- a/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java +++ b/native/android/app/src/main/java/app/comm/android/notifications/CommNotificationsHandler.java @@ -1,267 +1,322 @@ package app.comm.android.notifications; import android.app.Notification; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.os.Bundle; import android.service.notification.StatusBarNotification; import android.util.JsonReader; import android.util.Log; import androidx.core.app.NotificationCompat; import androidx.lifecycle.Lifecycle; import androidx.lifecycle.ProcessLifecycleOwner; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import app.comm.android.ExpoUtils; import app.comm.android.MainActivity; import app.comm.android.R; import app.comm.android.fbjni.CommSecureStore; import app.comm.android.fbjni.GlobalDBSingleton; import app.comm.android.fbjni.MessageOperationsUtilities; import app.comm.android.fbjni.NetworkModule; import app.comm.android.fbjni.NotificationsCryptoModule; import app.comm.android.fbjni.ThreadOperations; import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; import java.io.File; import me.leolin.shortcutbadger.ShortcutBadger; import org.json.JSONException; import org.json.JSONObject; public class CommNotificationsHandler extends FirebaseMessagingService { private static final String BADGE_KEY = "badge"; private static final String BADGE_ONLY_KEY = "badgeOnly"; private static final String BACKGROUND_NOTIF_TYPE_KEY = "backgroundNotifType"; private static final String SET_UNREAD_STATUS_KEY = "setUnreadStatus"; private static final String NOTIF_ID_KEY = "id"; private static final String ENCRYPTED_PAYLOAD_KEY = "encryptedPayload"; private static final String ENCRYPTION_FAILED_KEY = "encryptionFailed"; private static final String CHANNEL_ID = "default"; private static final long[] VIBRATION_SPEC = {500, 500}; private Bitmap displayableNotificationLargeIcon; private NotificationManager notificationManager; private LocalBroadcastManager localBroadcastManager; public static final String RESCIND_KEY = "rescind"; public static final String RESCIND_ID_KEY = "rescindID"; public static final String TITLE_KEY = "title"; public static final String PREFIX_KEY = "prefix"; public static final String BODY_KEY = "body"; public static final String MESSAGE_INFOS_KEY = "messageInfos"; public static final String THREAD_ID_KEY = "threadID"; public static final String TOKEN_EVENT = "TOKEN_EVENT"; public static final String MESSAGE_EVENT = "MESSAGE_EVENT"; @Override public void onCreate() { super.onCreate(); CommSecureStore.getInstance().initialize( ExpoUtils.createExpoSecureStoreSupplier(this.getApplicationContext())); notificationManager = (NotificationManager)this.getSystemService( Context.NOTIFICATION_SERVICE); localBroadcastManager = LocalBroadcastManager.getInstance(this); displayableNotificationLargeIcon = BitmapFactory.decodeResource( this.getApplicationContext().getResources(), R.mipmap.ic_launcher); } @Override public void onNewToken(String token) { Intent intent = new Intent(TOKEN_EVENT); intent.putExtra("token", token); localBroadcastManager.sendBroadcast(intent); } @Override public void onMessageReceived(RemoteMessage message) { String rescind = message.getData().get(RESCIND_KEY); if ("true".equals(rescind) && android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { handleNotificationRescind(message); } if (message.getData().get(ENCRYPTED_PAYLOAD_KEY) != null) { try { message = this.decryptRemoteMessage(message); } catch (JSONException e) { Log.w("COMM", "Malformed notification JSON payload.", e); return; } catch (IllegalStateException e) { Log.w("COMM", "Android notification type violation.", e); return; } catch (Exception e) { Log.w("COMM", "Notification decryption failure.", e); return; } } else if ("1".equals(message.getData().get(ENCRYPTION_FAILED_KEY))) { Log.w( "COMM", "Received unencrypted notification for client with existing olm session for notifications"); } String badge = message.getData().get(BADGE_KEY); if (badge != null) { try { int badgeCount = Integer.parseInt(badge); if (badgeCount > 0) { ShortcutBadger.applyCount(this, badgeCount); } else { ShortcutBadger.removeCount(this); } } catch (NumberFormatException e) { Log.w("COMM", "Invalid badge count", e); } } String badgeOnly = message.getData().get(BADGE_ONLY_KEY); if ("1".equals(badgeOnly)) { return; } String backgroundNotifType = message.getData().get(BACKGROUND_NOTIF_TYPE_KEY); String rawMessageInfosString = message.getData().get(MESSAGE_INFOS_KEY); File sqliteFile = this.getApplicationContext().getDatabasePath("comm.sqlite"); if (rawMessageInfosString != null && sqliteFile.exists()) { GlobalDBSingleton.scheduleOrRun(() -> { MessageOperationsUtilities.storeMessageInfos( sqliteFile.getPath(), rawMessageInfosString); }); } else if (rawMessageInfosString != null) { Log.w("COMM", "Database not existing yet. Skipping notification"); } Intent intent = new Intent(MESSAGE_EVENT); intent.putExtra( "message", serializeMessageDataForIntentAttachment(message)); localBroadcastManager.sendBroadcast(intent); if (this.isAppInForeground()) { return; } this.displayNotification(message); } private boolean isAppInForeground() { return ProcessLifecycleOwner.get().getLifecycle().getCurrentState() == Lifecycle.State.RESUMED; } + private boolean notificationGroupingSupported() { + // Comm doesn't support notification grouping for clients running + // Android versions older than 23 + return android.os.Build.VERSION.SDK_INT > android.os.Build.VERSION_CODES.M; + } + private void handleNotificationRescind(RemoteMessage message) { String setUnreadStatus = message.getData().get(SET_UNREAD_STATUS_KEY); + String threadID = message.getData().get(THREAD_ID_KEY); if ("true".equals(setUnreadStatus)) { File sqliteFile = this.getApplicationContext().getDatabasePath("comm.sqlite"); if (sqliteFile.exists()) { - String threadID = message.getData().get(THREAD_ID_KEY); GlobalDBSingleton.scheduleOrRun(() -> { ThreadOperations.updateSQLiteUnreadStatus( sqliteFile.getPath(), threadID, false); }); } else { Log.w( "COMM", "Database not existing yet. Skipping thread status update."); } } String rescindID = message.getData().get(RESCIND_ID_KEY); + boolean groupSummaryPresent = false; + boolean threadGroupPresent = false; + for (StatusBarNotification notification : notificationManager.getActiveNotifications()) { String tag = notification.getTag(); + boolean isGroupMember = + threadID.equals(notification.getNotification().getGroup()); + boolean isGroupSummary = + (notification.getNotification().flags & + Notification.FLAG_GROUP_SUMMARY) == Notification.FLAG_GROUP_SUMMARY; if (tag != null && tag.equals(rescindID)) { notificationManager.cancel(notification.getTag(), notification.getId()); + } else if (isGroupMember && isGroupSummary) { + groupSummaryPresent = true; + } else if (isGroupMember) { + threadGroupPresent = true; } } + + if (groupSummaryPresent && !threadGroupPresent) { + notificationManager.cancel(threadID, threadID.hashCode()); + } + } + + private void addToThreadGroupAndDisplay( + String notificationID, + NotificationCompat.Builder notificationBuilder, + String threadID) { + + notificationBuilder = + notificationBuilder.setGroup(threadID).setGroupAlertBehavior( + NotificationCompat.GROUP_ALERT_CHILDREN); + + NotificationCompat.Builder groupSummaryNotificationBuilder = + new NotificationCompat.Builder(this.getApplicationContext()) + .setChannelId(CHANNEL_ID) + .setSmallIcon(R.drawable.notif_icon) + .setContentIntent( + this.createStartMainActivityAction(threadID, threadID)) + .setGroup(threadID) + .setGroupSummary(true) + .setAutoCancel(true) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); + + notificationManager.notify( + notificationID, notificationID.hashCode(), notificationBuilder.build()); + notificationManager.notify( + threadID, threadID.hashCode(), groupSummaryNotificationBuilder.build()); } private void displayNotification(RemoteMessage message) { if (message.getData().get(RESCIND_KEY) != null) { // don't attempt to display rescinds return; } String id = message.getData().get(NOTIF_ID_KEY); String title = message.getData().get(TITLE_KEY); String prefix = message.getData().get(PREFIX_KEY); String body = message.getData().get(BODY_KEY); String threadID = message.getData().get(THREAD_ID_KEY); if (prefix != null) { body = prefix + " " + body; } Bundle data = new Bundle(); data.putString(THREAD_ID_KEY, threadID); - PendingIntent startMainActivityAction = - this.createStartMainActivityAction(message); - NotificationCompat.Builder notificationBuilder = new NotificationCompat.Builder(this.getApplicationContext()) .setDefaults(Notification.DEFAULT_ALL) .setContentText(body) .setExtras(data) .setChannelId(CHANNEL_ID) .setVibrate(VIBRATION_SPEC) .setSmallIcon(R.drawable.notif_icon) .setLargeIcon(displayableNotificationLargeIcon) - .setAutoCancel(true) - .setContentIntent(startMainActivityAction); + .setAutoCancel(true); if (title != null) { notificationBuilder = notificationBuilder.setContentTitle(title); } - notificationManager.notify(id, id.hashCode(), notificationBuilder.build()); + + if (threadID != null) { + notificationBuilder = notificationBuilder.setContentIntent( + this.createStartMainActivityAction(id, threadID)); + } + + if (!this.notificationGroupingSupported() || threadID == null) { + notificationManager.notify( + id, id.hashCode(), notificationBuilder.build()); + return; + } + this.addToThreadGroupAndDisplay(id, notificationBuilder, threadID); } - private PendingIntent createStartMainActivityAction(RemoteMessage message) { + private PendingIntent + createStartMainActivityAction(String notificationID, String threadID) { Intent intent = new Intent(this.getApplicationContext(), MainActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); - intent.putExtra( - "message", serializeMessageDataForIntentAttachment(message)); + intent.putExtra("threadID", threadID); return PendingIntent.getActivity( this.getApplicationContext(), - message.getData().get(NOTIF_ID_KEY).hashCode(), + notificationID.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); } private RemoteMessage decryptRemoteMessage(RemoteMessage message) throws JSONException, IllegalStateException { String encryptedSerializedPayload = message.getData().get(ENCRYPTED_PAYLOAD_KEY); String decryptedSerializedPayload = NotificationsCryptoModule.decrypt( encryptedSerializedPayload, NotificationsCryptoModule.olmEncryptedTypeMessage(), "CommNotificationsHandler"); JSONObject decryptedPayload = new JSONObject(decryptedSerializedPayload); ((Iterable)() -> decryptedPayload.keys()) .forEach(payloadFieldName -> { if (decryptedPayload.optJSONArray(payloadFieldName) != null || decryptedPayload.optJSONObject(payloadFieldName) != null) { throw new IllegalStateException( "Notification payload JSON is not {[string]: string} type."); } String payloadFieldValue = decryptedPayload.optString(payloadFieldName); message.getData().put(payloadFieldName, payloadFieldValue); }); return message; } private Bundle serializeMessageDataForIntentAttachment(RemoteMessage message) { Bundle bundle = new Bundle(); message.getData().forEach(bundle::putString); return bundle; } } diff --git a/native/push/android.js b/native/push/android.js index d5f330445..942e7e1e6 100644 --- a/native/push/android.js +++ b/native/push/android.js @@ -1,79 +1,79 @@ // @flow import { NativeModules, NativeEventEmitter } from 'react-native'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; type CommAndroidNotificationsConstants = { +NOTIFICATIONS_IMPORTANCE_HIGH: number, +COMM_ANDROID_NOTIFICATIONS_TOKEN: 'commAndroidNotificationsToken', +COMM_ANDROID_NOTIFICATIONS_MESSAGE: 'commAndroidNotificationsMessage', +COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED: 'commAndroidNotificationsNotificationOpened', }; type CommAndroidNotificationsModuleType = { +removeAllActiveNotificationsForThread: (threadID: string) => void, - +getInitialNotification: () => Promise, + +getInitialNotificationThreadID: () => Promise, +createChannel: ( channelID: string, name: string, importance: number, description: ?string, ) => void, +getConstants: () => CommAndroidNotificationsConstants, +setBadge: (count: number) => void, +removeAllDeliveredNotifications: () => void, +hasPermission: () => Promise, +getToken: () => Promise, +requestNotificationsPermission: () => Promise, +canRequestNotificationsPermissionFromUser: () => Promise, ...CommAndroidNotificationsConstants, }; export type AndroidMessage = { +body: string, +title: string, +threadID: string, +prefix?: string, +messageInfos: ?string, }; const { CommAndroidNotificationsEventEmitter } = NativeModules; const CommAndroidNotifications: CommAndroidNotificationsModuleType = NativeModules.CommAndroidNotifications; const androidNotificationChannelID = 'default'; function handleAndroidMessage( message: AndroidMessage, updatesCurrentAsOf: number, handleIfActive?: ( threadID: string, texts: { body: string, title: ?string }, ) => boolean, ) { const { title, prefix, threadID } = message; let { body } = message; ({ body } = mergePrefixIntoBody({ body, title, prefix })); if (handleIfActive) { const texts = { title, body }; const isActive = handleIfActive(threadID, texts); if (isActive) { return; } } } function getCommAndroidNotificationsEventEmitter(): NativeEventEmitter<{ commAndroidNotificationsToken: [string], commAndroidNotificationsMessage: [AndroidMessage], - commAndroidNotificationsNotificationOpened: [AndroidMessage], + commAndroidNotificationsNotificationOpened: [string], }> { return new NativeEventEmitter(CommAndroidNotificationsEventEmitter); } export { androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, CommAndroidNotifications, }; diff --git a/native/push/push-handler.react.js b/native/push/push-handler.react.js index 6c5602293..ab0cf4782 100644 --- a/native/push/push-handler.react.js +++ b/native/push/push-handler.react.js @@ -1,655 +1,654 @@ // @flow import * as Haptics from 'expo-haptics'; import * as React from 'react'; import { Platform, Alert, LogBox } from 'react-native'; import { Notification as InAppNotification } from 'react-native-in-app-message'; import { useDispatch } from 'react-redux'; import { setDeviceTokenActionTypes, setDeviceToken, } from 'lib/actions/device-actions.js'; import { saveMessagesActionType } from 'lib/actions/message-actions.js'; import { unreadCount, threadInfoSelector, } from 'lib/selectors/thread-selectors.js'; import { isLoggedIn } from 'lib/selectors/user-selectors.js'; import { mergePrefixIntoBody } from 'lib/shared/notif-utils.js'; import type { RawMessageInfo } from 'lib/types/message-types.js'; import type { Dispatch } from 'lib/types/redux-types.js'; import { type ConnectionInfo } from 'lib/types/socket-types.js'; import { type ThreadInfo } from 'lib/types/thread-types.js'; import { useServerCall, useDispatchActionPromise, type DispatchActionPromise, } from 'lib/utils/action-utils.js'; import { type NotifPermissionAlertInfo, recordNotifPermissionAlertActionType, shouldSkipPushPermissionAlert, } from 'lib/utils/push-alerts.js'; import sleep from 'lib/utils/sleep.js'; import { androidNotificationChannelID, handleAndroidMessage, getCommAndroidNotificationsEventEmitter, type AndroidMessage, CommAndroidNotifications, } from './android.js'; import { CommIOSNotification, type CoreIOSNotificationData, type CoreIOSNotificationDataWithRequestIdentifier, } from './comm-ios-notification.js'; import InAppNotif from './in-app-notif.react.js'; import { requestIOSPushPermissions, iosPushPermissionResponseReceived, CommIOSNotifications, getCommIOSNotificationsEventEmitter, } from './ios.js'; import { type MessageListParams, useNavigateToThread, } from '../chat/message-list-types.js'; import { addLifecycleListener, getCurrentLifecycleState, } from '../lifecycle/lifecycle.js'; import { replaceWithThreadActionType } from '../navigation/action-types.js'; import { activeMessageListSelector } from '../navigation/nav-selectors.js'; import { NavContext } from '../navigation/navigation-context.js'; import type { RootNavigationProp } from '../navigation/root-navigator.react.js'; import { useSelector } from '../redux/redux-utils.js'; import { RootContext, type RootContextType } from '../root-context.js'; import type { EventSubscription } from '../types/react-native.js'; import { type GlobalTheme } from '../types/themes.js'; LogBox.ignoreLogs([ // react-native-in-app-message 'ForceTouchGestureHandler is not available', ]); type BaseProps = { +navigation: RootNavigationProp<'App'>, }; type Props = { ...BaseProps, // Navigation state +activeThread: ?string, // Redux state +unreadCount: number, +deviceToken: ?string, +threadInfos: { +[id: string]: ThreadInfo }, +notifPermissionAlertInfo: NotifPermissionAlertInfo, +connection: ConnectionInfo, +updatesCurrentAsOf: number, +activeTheme: ?GlobalTheme, +loggedIn: boolean, +navigateToThread: (params: MessageListParams) => void, // Redux dispatch functions +dispatch: Dispatch, +dispatchActionPromise: DispatchActionPromise, // async functions that hit server APIs +setDeviceToken: (deviceToken: ?string) => Promise, // withRootContext +rootContext: ?RootContextType, }; type State = { +inAppNotifProps: ?{ +customComponent: React.Node, +blurType: ?('xlight' | 'dark'), +onPress: () => void, }, }; class PushHandler extends React.PureComponent { state: State = { inAppNotifProps: null, }; currentState: ?string = getCurrentLifecycleState(); appStarted = 0; androidNotificationsEventSubscriptions: Array = []; androidNotificationsPermissionPromise: ?Promise = undefined; initialAndroidNotifHandled = false; openThreadOnceReceived: Set = new Set(); lifecycleSubscription: ?EventSubscription; iosNotificationEventSubscriptions: Array = []; componentDidMount() { this.appStarted = Date.now(); this.lifecycleSubscription = addLifecycleListener( this.handleAppStateChange, ); this.onForeground(); if (Platform.OS === 'ios') { const commIOSNotificationsEventEmitter = getCommIOSNotificationsEventEmitter(); this.iosNotificationEventSubscriptions.push( commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTERED_EVENT, registration => this.registerPushPermissions(registration?.deviceToken), ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .REMOTE_NOTIFICATIONS_REGISTRATION_FAILED_EVENT, this.failedToRegisterPushPermissionsIOS, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_FOREGROUND_EVENT, this.iosForegroundNotificationReceived, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants().NOTIFICATION_OPENED_EVENT, this.iosNotificationOpened, ), commIOSNotificationsEventEmitter.addListener( CommIOSNotifications.getConstants() .NOTIFICATION_RECEIVED_BACKGROUND_EVENT, backgroundData => this.saveMessageInfos(backgroundData?.messageInfos), ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.createChannel( androidNotificationChannelID, 'Default', CommAndroidNotifications.getConstants().NOTIFICATIONS_IMPORTANCE_HIGH, 'Comm notifications channel', ); const commAndroidNotificationsEventEmitter = getCommAndroidNotificationsEventEmitter(); this.androidNotificationsEventSubscriptions.push( commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_TOKEN, this.handleAndroidDeviceToken, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_MESSAGE, this.androidMessageReceived, ), commAndroidNotificationsEventEmitter.addListener( CommAndroidNotifications.getConstants() .COMM_ANDROID_NOTIFICATIONS_NOTIFICATION_OPENED, this.androidNotificationOpened, ), ); } if (this.props.connection.status === 'connected') { this.updateBadgeCount(); } } componentWillUnmount() { if (this.lifecycleSubscription) { this.lifecycleSubscription.remove(); } if (Platform.OS === 'ios') { for (const iosNotificationEventSubscription of this .iosNotificationEventSubscriptions) { iosNotificationEventSubscription.remove(); } } else if (Platform.OS === 'android') { for (const androidNotificationsEventSubscription of this .androidNotificationsEventSubscriptions) { androidNotificationsEventSubscription.remove(); } this.androidNotificationsEventSubscriptions = []; } } handleAppStateChange = (nextState: ?string) => { if (!nextState || nextState === 'unknown') { return; } const lastState = this.currentState; this.currentState = nextState; if (lastState === 'background' && nextState === 'active') { this.onForeground(); this.clearNotifsOfThread(); } }; onForeground() { if (this.props.loggedIn) { this.ensurePushNotifsEnabled(); } else if (this.props.deviceToken) { // We do this in case there was a crash, so we can clear deviceToken from // any other cookies it might be set for this.setDeviceToken(this.props.deviceToken); } } componentDidUpdate(prevProps: Props, prevState: State) { if (this.props.activeThread !== prevProps.activeThread) { this.clearNotifsOfThread(); } if ( this.props.connection.status === 'connected' && (prevProps.connection.status !== 'connected' || this.props.unreadCount !== prevProps.unreadCount) ) { this.updateBadgeCount(); } for (const threadID of this.openThreadOnceReceived) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, false); this.openThreadOnceReceived.clear(); break; } } if ( (this.props.loggedIn && !prevProps.loggedIn) || (!this.props.deviceToken && prevProps.deviceToken) ) { this.ensurePushNotifsEnabled(); } if (!this.props.loggedIn && prevProps.loggedIn) { this.clearAllNotifs(); } if ( this.state.inAppNotifProps && this.state.inAppNotifProps !== prevState.inAppNotifProps ) { Haptics.notificationAsync(); InAppNotification.show(); } } updateBadgeCount() { const curUnreadCount = this.props.unreadCount; if (Platform.OS === 'ios') { CommIOSNotifications.setBadgesCount(curUnreadCount); } else if (Platform.OS === 'android') { CommAndroidNotifications.setBadge(curUnreadCount); } } clearAllNotifs() { if (Platform.OS === 'ios') { CommIOSNotifications.removeAllDeliveredNotifications(); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllDeliveredNotifications(); } } clearNotifsOfThread() { const { activeThread } = this.props; if (!activeThread) { return; } if (Platform.OS === 'ios') { CommIOSNotifications.getDeliveredNotifications(notifications => PushHandler.clearDeliveredIOSNotificationsForThread( activeThread, notifications, ), ); } else if (Platform.OS === 'android') { CommAndroidNotifications.removeAllActiveNotificationsForThread( activeThread, ); } } static clearDeliveredIOSNotificationsForThread( threadID: string, notifications: $ReadOnlyArray, ) { const identifiersToClear = []; for (const notification of notifications) { if (notification.threadID === threadID) { identifiersToClear.push(notification.identifier); } } if (identifiersToClear) { CommIOSNotifications.removeDeliveredNotifications(identifiersToClear); } } async ensurePushNotifsEnabled() { if (!this.props.loggedIn) { return; } if (Platform.OS === 'ios') { const missingDeviceToken = this.props.deviceToken === null || this.props.deviceToken === undefined; await requestIOSPushPermissions(missingDeviceToken); } else if (Platform.OS === 'android') { await this.ensureAndroidPushNotifsEnabled(); } } async ensureAndroidPushNotifsEnabled() { const permissionPromisesResult = await Promise.all([ CommAndroidNotifications.hasPermission(), CommAndroidNotifications.canRequestNotificationsPermissionFromUser(), ]); let [hasPermission] = permissionPromisesResult; const [, canRequestPermission] = permissionPromisesResult; if (!hasPermission && canRequestPermission) { const permissionResponse = await (async () => { // We issue a call to sleep to match iOS behavior where prompt // doesn't appear immediately but after logged-out modal disappears await sleep(10); await this.requestAndroidNotificationsPermission(); })(); hasPermission = permissionResponse; } if (!hasPermission) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); return; } try { const fcmToken = await CommAndroidNotifications.getToken(); await this.handleAndroidDeviceToken(fcmToken); } catch (e) { this.failedToRegisterPushPermissionsAndroid(!canRequestPermission); } } requestAndroidNotificationsPermission = () => { if (!this.androidNotificationsPermissionPromise) { this.androidNotificationsPermissionPromise = (async () => { const notifPermission = await CommAndroidNotifications.requestNotificationsPermission(); this.androidNotificationsPermissionPromise = undefined; return notifPermission; })(); } return this.androidNotificationsPermissionPromise; }; handleAndroidDeviceToken = async (deviceToken: string) => { this.registerPushPermissions(deviceToken); await this.handleInitialAndroidNotification(); }; async handleInitialAndroidNotification() { if (this.initialAndroidNotifHandled) { return; } this.initialAndroidNotifHandled = true; - const initialNotif = - await CommAndroidNotifications.getInitialNotification(); - if (initialNotif) { - await this.androidNotificationOpened(initialNotif); + const initialNotifThreadID = + await CommAndroidNotifications.getInitialNotificationThreadID(); + if (initialNotifThreadID) { + await this.androidNotificationOpened(initialNotifThreadID); } } registerPushPermissions = (deviceToken: ?string) => { const deviceType = Platform.OS; if (deviceType !== 'android' && deviceType !== 'ios') { return; } if (deviceType === 'ios') { iosPushPermissionResponseReceived(); } if (deviceToken !== this.props.deviceToken) { this.setDeviceToken(deviceToken); } }; setDeviceToken(deviceToken: ?string) { this.props.dispatchActionPromise( setDeviceTokenActionTypes, this.props.setDeviceToken(deviceToken), ); } failedToRegisterPushPermissionsIOS = () => { this.setDeviceToken(null); if (!this.props.loggedIn) { return; } iosPushPermissionResponseReceived(); }; failedToRegisterPushPermissionsAndroid = ( shouldShowAlertOnAndroid: boolean, ) => { this.setDeviceToken(null); if (!this.props.loggedIn) { return; } if (shouldShowAlertOnAndroid) { this.showNotifAlertOnAndroid(); } }; showNotifAlertOnAndroid() { const alertInfo = this.props.notifPermissionAlertInfo; if (shouldSkipPushPermissionAlert(alertInfo)) { return; } this.props.dispatch({ type: recordNotifPermissionAlertActionType, payload: { time: Date.now() }, }); Alert.alert( 'Unable to initialize notifs!', 'Please check your network connection, make sure Google Play ' + 'services are installed and enabled, and confirm that your Google ' + 'Play credentials are valid in the Google Play Store.', undefined, { cancelable: true }, ); } navigateToThread(threadInfo: ThreadInfo, clearChatRoutes: boolean) { if (clearChatRoutes) { this.props.navigation.dispatch({ type: replaceWithThreadActionType, payload: { threadInfo }, }); } else { this.props.navigateToThread({ threadInfo }); } } onPressNotificationForThread(threadID: string, clearChatRoutes: boolean) { const threadInfo = this.props.threadInfos[threadID]; if (threadInfo) { this.navigateToThread(threadInfo, clearChatRoutes); } else { this.openThreadOnceReceived.add(threadID); } } saveMessageInfos(messageInfosString: ?string) { if (!messageInfosString) { return; } const rawMessageInfos: $ReadOnlyArray = JSON.parse(messageInfosString); const { updatesCurrentAsOf } = this.props; this.props.dispatch({ type: saveMessagesActionType, payload: { rawMessageInfos, updatesCurrentAsOf }, }); } iosForegroundNotificationReceived = ( rawNotification: CoreIOSNotificationData, ) => { const notification = new CommIOSNotification(rawNotification); if (Date.now() < this.appStarted + 1500) { // On iOS, when the app is opened from a notif press, for some reason this // callback gets triggered before iosNotificationOpened. In fact this // callback shouldn't be triggered at all. To avoid weirdness we are // ignoring any foreground notification received within the first second // of the app being started, since they are most likely to be erroneous. notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NO_DATA, ); return; } const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); let title = notification.getData().title; let body = notification.getData().body; if (title && body) { ({ title, body } = mergePrefixIntoBody({ title, body })); } else { body = notification.getMessage(); } if (body) { this.showInAppNotification(threadID, body, title); } else { console.log( 'Non-rescind foreground notification without alert received!', ); } notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; onPushNotifBootsApp() { if ( this.props.rootContext && this.props.rootContext.detectUnsupervisedBackground ) { this.props.rootContext.detectUnsupervisedBackground(false); } } iosNotificationOpened = (rawNotification: CoreIOSNotificationData) => { const notification = new CommIOSNotification(rawNotification); this.onPushNotifBootsApp(); const threadID = notification.getData().threadID; const messageInfos = notification.getData().messageInfos; this.saveMessageInfos(messageInfos); this.onPressNotificationForThread(threadID, true); notification.finish( CommIOSNotifications.getConstants().FETCH_RESULT_NEW_DATA, ); }; showInAppNotification(threadID: string, message: string, title?: ?string) { if (threadID === this.props.activeThread) { return; } this.setState({ inAppNotifProps: { customComponent: ( ), blurType: this.props.activeTheme === 'dark' ? 'xlight' : 'dark', onPress: () => { InAppNotification.hide(); this.onPressNotificationForThread(threadID, false); }, }, }); } - androidNotificationOpened = async (notificationOpen: AndroidMessage) => { + androidNotificationOpened = async (threadID: string) => { this.onPushNotifBootsApp(); - const { threadID } = notificationOpen; this.onPressNotificationForThread(threadID, true); }; androidMessageReceived = async (message: AndroidMessage) => { this.onPushNotifBootsApp(); const { messageInfos } = message; this.saveMessageInfos(messageInfos); handleAndroidMessage( message, this.props.updatesCurrentAsOf, this.handleAndroidNotificationIfActive, ); }; handleAndroidNotificationIfActive = ( threadID: string, texts: { body: string, title: ?string }, ) => { if (this.currentState !== 'active') { return false; } this.showInAppNotification(threadID, texts.body, texts.title); return true; }; render() { return ( ); } } const ConnectedPushHandler: React.ComponentType = React.memo(function ConnectedPushHandler(props: BaseProps) { const navContext = React.useContext(NavContext); const activeThread = activeMessageListSelector(navContext); const boundUnreadCount = useSelector(unreadCount); const deviceToken = useSelector(state => state.deviceToken); const threadInfos = useSelector(threadInfoSelector); const notifPermissionAlertInfo = useSelector( state => state.notifPermissionAlertInfo, ); const connection = useSelector(state => state.connection); const updatesCurrentAsOf = useSelector(state => state.updatesCurrentAsOf); const activeTheme = useSelector(state => state.globalThemeInfo.activeTheme); const loggedIn = useSelector(isLoggedIn); const navigateToThread = useNavigateToThread(); const dispatch = useDispatch(); const dispatchActionPromise = useDispatchActionPromise(); const boundSetDeviceToken = useServerCall(setDeviceToken); const rootContext = React.useContext(RootContext); return ( ); }); export default ConnectedPushHandler;