Page Menu
Home
Phabricator
Search
Configure Global Search
Log In
Files
F3525880
No One
Temporary
Actions
View File
Edit File
Delete File
View Transforms
Subscribe
Mute Notifications
Award Token
Flag For Later
Size
51 KB
Referenced Files
None
Subscribers
None
View Options
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<Promise>
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<String, Object> getConstants() {
final Map<String, Object> 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<String>)() -> 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<?AndroidMessage>,
+ +getInitialNotificationThreadID: () => Promise<?string>,
+createChannel: (
channelID: string,
name: string,
importance: number,
description: ?string,
) => void,
+getConstants: () => CommAndroidNotificationsConstants,
+setBadge: (count: number) => void,
+removeAllDeliveredNotifications: () => void,
+hasPermission: () => Promise<boolean>,
+getToken: () => Promise<string>,
+requestNotificationsPermission: () => Promise<boolean>,
+canRequestNotificationsPermissionFromUser: () => Promise<boolean>,
...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<?string>,
// withRootContext
+rootContext: ?RootContextType,
};
type State = {
+inAppNotifProps: ?{
+customComponent: React.Node,
+blurType: ?('xlight' | 'dark'),
+onPress: () => void,
},
};
class PushHandler extends React.PureComponent<Props, State> {
state: State = {
inAppNotifProps: null,
};
currentState: ?string = getCurrentLifecycleState();
appStarted = 0;
androidNotificationsEventSubscriptions: Array<EventSubscription> = [];
androidNotificationsPermissionPromise: ?Promise<boolean> = undefined;
initialAndroidNotifHandled = false;
openThreadOnceReceived: Set<string> = new Set();
lifecycleSubscription: ?EventSubscription;
iosNotificationEventSubscriptions: Array<EventSubscription> = [];
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<CoreIOSNotificationDataWithRequestIdentifier>,
) {
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<RawMessageInfo> =
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: (
<InAppNotif
title={title}
message={message}
activeTheme={this.props.activeTheme}
/>
),
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 (
<InAppNotification
{...this.state.inAppNotifProps}
hideStatusBar={false}
/>
);
}
}
const ConnectedPushHandler: React.ComponentType<BaseProps> =
React.memo<BaseProps>(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 (
<PushHandler
{...props}
activeThread={activeThread}
unreadCount={boundUnreadCount}
deviceToken={deviceToken}
threadInfos={threadInfos}
notifPermissionAlertInfo={notifPermissionAlertInfo}
connection={connection}
updatesCurrentAsOf={updatesCurrentAsOf}
activeTheme={activeTheme}
loggedIn={loggedIn}
navigateToThread={navigateToThread}
dispatch={dispatch}
dispatchActionPromise={dispatchActionPromise}
setDeviceToken={boundSetDeviceToken}
rootContext={rootContext}
/>
);
});
export default ConnectedPushHandler;
File Metadata
Details
Attached
Mime Type
text/x-diff
Expires
Wed, Dec 25, 6:40 PM (18 h, 18 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
2700794
Default Alt Text
(51 KB)
Attached To
Mode
rCOMM Comm
Attached
Detach File
Event Timeline
Log In to Comment