Page MenuHomePhabricator

No OneTemporary

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

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)

Event Timeline