diff --git a/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidDeleteBlobWork.java b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidDeleteBlobWork.java index 7283bf149..3b5c85967 100644 --- a/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidDeleteBlobWork.java +++ b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidDeleteBlobWork.java @@ -1,96 +1,97 @@ package app.comm.android.commservices; import android.content.Context; import android.util.Log; import androidx.work.Data; import androidx.work.ListenableWorker.Result; import androidx.work.Worker; import androidx.work.WorkerParameters; import java.io.IOException; import java.util.function.Consumer; import okhttp3.MediaType; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import org.json.JSONException; import org.json.JSONObject; public class CommAndroidDeleteBlobWork extends Worker { private static final int MAX_RETRY_ATTEMPTS = 10; public CommAndroidDeleteBlobWork(Context context, WorkerParameters params) { super(context, params); } @Override public Result doWork() { String blobHash = - getInputData().getString(CommAndroidBlobClient.BLOB_HASH_KEY); + getInputData().getString(CommAndroidServicesClient.BLOB_HASH_KEY); String blobHolder = - getInputData().getString(CommAndroidBlobClient.BLOB_HOLDER_KEY); + getInputData().getString(CommAndroidServicesClient.BLOB_HOLDER_KEY); String jsonBody; try { jsonBody = new JSONObject() - .put(CommAndroidBlobClient.BLOB_HASH_KEY, blobHash) - .put(CommAndroidBlobClient.BLOB_HOLDER_KEY, blobHolder) + .put(CommAndroidServicesClient.BLOB_HASH_KEY, blobHash) + .put(CommAndroidServicesClient.BLOB_HOLDER_KEY, blobHolder) .toString(); } catch (JSONException e) { // This should never happen since the code // throwing is just simple JSON creation. // If it happens there is no way to retry // so we fail immediately. Log.w( "COMM", "Failed to create JSON from blob hash and holder provided.", e); return Result.failure(); } String authToken; try { - authToken = CommAndroidBlobClient.getAuthToken(); + authToken = CommAndroidServicesClient.getAuthToken(); } catch (JSONException e) { // In this case however it may happen that // auth metadata got corrupted but will be // fixed soon by event emitter. Therefore // we should retry in this case. return Result.retry(); } RequestBody requestBody = RequestBody.create(MediaType.parse("application/json"), jsonBody); - Request request = new Request.Builder() - .delete(requestBody) - .url(CommAndroidBlobClient.BLOB_SERVICE_URL + "/blob") - .header("Authorization", authToken) - .build(); + Request request = + new Request.Builder() + .delete(requestBody) + .url(CommAndroidServicesClient.BLOB_SERVICE_URL + "/blob") + .header("Authorization", authToken) + .build(); try { Response response = - CommAndroidBlobClient.httpClient.newCall(request).execute(); + CommAndroidServicesClient.httpClient.newCall(request).execute(); if (response.isSuccessful()) { return Result.success(); } Log.w( "COMM", "Failed to execute blob deletion request. HTTP code:" + response.code() + " status: " + response.message()); return retryOrFail(); } catch (IOException e) { Log.w( "COMM", "IO exception occurred while issuing blob deletion request.", e); return retryOrFail(); } } private Result retryOrFail() { if (getRunAttemptCount() > MAX_RETRY_ATTEMPTS) { return Result.failure(); } return Result.retry(); } } diff --git a/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidServicesClient.java similarity index 64% rename from native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java rename to native/android/app/src/main/java/app/comm/android/commservices/CommAndroidServicesClient.java index 482daa39a..d178231a2 100644 --- a/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidBlobClient.java +++ b/native/android/app/src/main/java/app/comm/android/commservices/CommAndroidServicesClient.java @@ -1,107 +1,166 @@ package app.comm.android.commservices; import android.content.Context; import androidx.work.Constraints; import androidx.work.Data; import androidx.work.NetworkType; import androidx.work.OneTimeWorkRequest; import androidx.work.WorkManager; import androidx.work.WorkRequest; import app.comm.android.BuildConfig; import app.comm.android.fbjni.CommSecureStore; import java.io.IOException; import java.lang.OutOfMemoryError; import java.util.Base64; import java.util.concurrent.TimeUnit; import okhttp3.Call; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.RequestBody; import okhttp3.Response; import org.json.JSONException; import org.json.JSONObject; -public class CommAndroidBlobClient { +public class CommAndroidServicesClient { // The FirebaseMessagingService docs state that message // processing should complete within at most 20 seconds // window. Therefore we limit http time call to 15 seconds // https://firebase.google.com/docs/cloud-messaging/android/receive#handling_messages private static final int NOTIF_PROCESSING_TIME_LIMIT_SECONDS = 15; // OkHttp docs advise to share OkHttpClient instances // https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/#okhttpclients-should-be-shared public static final OkHttpClient httpClient = new OkHttpClient.Builder() .callTimeout(NOTIF_PROCESSING_TIME_LIMIT_SECONDS, TimeUnit.SECONDS) .build(); public static final String BLOB_SERVICE_URL = BuildConfig.DEBUG ? "https://blob.staging.commtechnologies.org" : "https://blob.commtechnologies.org"; + public static final String IDENTITY_SERVICE_URL = BuildConfig.DEBUG + ? "https://identity.staging.commtechnologies.org:51004" + : "https://identity.commtechnologies.org:51004"; public static final String BLOB_HASH_KEY = "blob_hash"; public static final String BLOB_HOLDER_KEY = "holder"; public byte[] getBlobSync(String blobHash) throws IOException, JSONException { String authToken = getAuthToken(); Request request = new Request.Builder() .get() .url(BLOB_SERVICE_URL + "/blob/" + blobHash) .header("Authorization", authToken) .build(); Response response = httpClient.newCall(request).execute(); if (!response.isSuccessful()) { throw new RuntimeException( "Failed to download blob from blob service. Response error code: " + response); } return response.body().bytes(); } + public JSONObject getNotifsInboundKeysForDeviceSync(String deviceID) + throws IOException, JSONException { + String authToken = getAuthToken(); + String base64URLEncodedDeviceID = + deviceID.replaceAll("\\+", "-").replaceAll("\\/", "_"); + + Request request = + new Request.Builder() + .get() + .url( + IDENTITY_SERVICE_URL + + "/device_inbound_keys?device_id=" + base64URLEncodedDeviceID) + .header("Authorization", authToken) + .build(); + Response response = httpClient.newCall(request).execute(); + if (!response.isSuccessful()) { + throw new RuntimeException( + "Failed to fetch inbound keys for device: " + deviceID + + " from identity service. Response error code: " + response); + } + + String serializedResponse = response.body().string(); + JSONObject responseObject = new JSONObject(serializedResponse); + + JSONObject identityKeyInfo = + responseObject.optJSONObject("identityKeyInfo"); + if (identityKeyInfo == null) { + throw new RuntimeException( + "identityKeyInfo missing in identity service response"); + } + + String keyPayload = identityKeyInfo.optString("keyPayload"); + if (keyPayload == null) { + throw new RuntimeException( + "keyPayload missing in identity service response"); + } + + JSONObject identityKeys = new JSONObject(keyPayload); + JSONObject notificationIdentityKeys = + identityKeys.optJSONObject("notificationIdentityPublicKeys"); + if (notificationIdentityKeys == null) { + throw new RuntimeException( + "notificationIdentityKeys missing in identity service response"); + } + + String curve25519 = notificationIdentityKeys.optString("curve25519"); + String ed25519 = notificationIdentityKeys.optString("ed25519"); + + if (curve25519 == null || ed25519 == null) { + throw new RuntimeException( + "ed25519 or curve25519 missing in identity service response"); + } + + return notificationIdentityKeys; + } + public void scheduleDeferredBlobDeletion( String blobHash, String blobHolder, Context context) { Constraints constraints = new Constraints.Builder() .setRequiredNetworkType(NetworkType.CONNECTED) .build(); WorkRequest deleteBlobWorkRequest = new OneTimeWorkRequest.Builder(CommAndroidDeleteBlobWork.class) .setConstraints(constraints) .setInitialDelay( NOTIF_PROCESSING_TIME_LIMIT_SECONDS, TimeUnit.SECONDS) .setInputData(new Data.Builder() .putString(BLOB_HASH_KEY, blobHash) .putString(BLOB_HOLDER_KEY, blobHolder) .build()) .build(); WorkManager.getInstance(context).enqueue(deleteBlobWorkRequest); } public static String getAuthToken() throws JSONException { // Authentication data are retrieved on every request // since they might change while CommNotificationsHandler // thread is running so we should not rely on caching // them in memory. String userID = CommSecureStore.get("userID"); String accessToken = CommSecureStore.get("accessToken"); String deviceID = CommSecureStore.get("deviceID"); if (userID == null || accessToken == null || deviceID == null) { throw new RuntimeException( "Unable to query blob service due to missing CSAT."); } String authObjectJsonBody = new JSONObject() .put("userID", userID) .put("accessToken", accessToken) .put("deviceID", deviceID) .toString(); String encodedAuthObjectJsonBody = Base64.getEncoder().encodeToString(authObjectJsonBody.getBytes()); return "Bearer " + encodedAuthObjectJsonBody; } } 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 2f3688522..af6b94bd0 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,658 +1,658 @@ 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.aescrypto.AESCryptoModuleCompat; -import app.comm.android.commservices.CommAndroidBlobClient; +import app.comm.android.commservices.CommAndroidServicesClient; import app.comm.android.fbjni.CommMMKV; 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.StaffUtils; import app.comm.android.fbjni.ThreadOperations; import com.google.firebase.messaging.FirebaseMessagingService; import com.google.firebase.messaging.RemoteMessage; import java.io.File; import java.io.IOException; import java.lang.OutOfMemoryError; import java.lang.StringBuilder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.Map; 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 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 BLOB_HASH_KEY = "blobHash"; private static final String BLOB_HOLDER_KEY = "blobHolder"; private static final String AES_ENCRYPTION_KEY_LABEL = "encryptionKey"; private static final String GROUP_NOTIF_IDS_KEY = "groupNotifIDs"; private static final String COLLAPSE_ID_KEY = "collapseKey"; private static final String KEYSERVER_ID_KEY = "keyserverID"; private static final String CHANNEL_ID = "default"; private static final long[] VIBRATION_SPEC = {500, 500}; private static final Map NOTIF_PRIORITY_VERBOSE = Map.of(0, "UNKNOWN", 1, "HIGH", 2, "NORMAL"); // Those and future MMKV-related constants should match // similar constants in NotificationService.mm private static final String MMKV_KEY_SEPARATOR = "."; private static final String MMKV_KEYSERVER_PREFIX = "KEYSERVER"; private static final String MMKV_UNREAD_COUNT_SUFFIX = "UNREAD_COUNT"; private Bitmap displayableNotificationLargeIcon; private NotificationManager notificationManager; private LocalBroadcastManager localBroadcastManager; - private CommAndroidBlobClient blobClient; + private CommAndroidServicesClient servicesClient; private AESCryptoModuleCompat aesCryptoModule; 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); - blobClient = new CommAndroidBlobClient(); + servicesClient = new CommAndroidServicesClient(); aesCryptoModule = new AESCryptoModuleCompat(); } @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) { handleAlteredNotificationPriority(message); if (StaffUtils.isStaffRelease() && message.getData().get(KEYSERVER_ID_KEY) == null) { displayErrorMessageNotification( "Received notification without keyserver ID.", "Missing keyserver ID.", null); return; } String senderKeyserverID = message.getData().get(KEYSERVER_ID_KEY); if (message.getData().get(ENCRYPTED_PAYLOAD_KEY) != null) { try { message = this.olmDecryptRemoteMessage(message, senderKeyserverID); } 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; } } if (StaffUtils.isStaffRelease() && "1".equals(message.getData().get(ENCRYPTION_FAILED_KEY))) { displayErrorMessageNotification( "Notification encryption failed on the keyserver. Please investigate", "Unencrypted notification", null); } if ("1".equals(message.getData().get(ENCRYPTION_FAILED_KEY))) { Log.w("COMM", "Received erroneously unencrypted notification."); } 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); } try { handleUnreadCountUpdate(message); } catch (Exception e) { Log.w("COMM", "Unread count update failure.", e); } String badgeOnly = message.getData().get(BADGE_ONLY_KEY); if ("1".equals(badgeOnly)) { return; } if (message.getData().get(MESSAGE_INFOS_KEY) != null) { handleMessageInfosPersistence(message); } if (message.getData().get(BLOB_HASH_KEY) != null && message.getData().get(AES_ENCRYPTION_KEY_LABEL) != null && message.getData().get(BLOB_HOLDER_KEY) != null) { handleLargeNotification(message); } Intent intent = new Intent(MESSAGE_EVENT); intent.putExtra( "message", serializeMessageDataForIntentAttachment(message)); localBroadcastManager.sendBroadcast(intent); if (this.isAppInForeground()) { return; } this.displayNotification(message); } private void handleAlteredNotificationPriority(RemoteMessage message) { if (!StaffUtils.isStaffRelease()) { return; } int originalPriority = message.getOriginalPriority(); int priority = message.getPriority(); String priorityName = NOTIF_PRIORITY_VERBOSE.get(priority); String originalPriorityName = NOTIF_PRIORITY_VERBOSE.get(originalPriority); if (priorityName == null || originalPriorityName == null) { // Technically this will never happen as // it would violate FCM documentation return; } if (priority != originalPriority) { displayErrorMessageNotification( "System changed notification priority from " + priorityName + " to " + originalPriorityName, "Notification priority altered.", null); } } 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()) { 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 && StaffUtils.isStaffRelease()) { groupSummaryPresent = true; removeNotificationFromGroupSummary(threadID, rescindID, notification); } else if (isGroupMember && isGroupSummary) { groupSummaryPresent = true; } else if (isGroupMember) { threadGroupPresent = true; } else if (isGroupSummary && StaffUtils.isStaffRelease()) { checkForUnmatchedRescind(threadID, rescindID, notification); } } if (groupSummaryPresent && !threadGroupPresent) { notificationManager.cancel(threadID, threadID.hashCode()); } } private void handleUnreadCountUpdate(RemoteMessage message) { String badge = message.getData().get(BADGE_KEY); if (badge == null) { return; } if (message.getData().get(KEYSERVER_ID_KEY) == null) { throw new RuntimeException("Received badge update without keyserver ID."); } String senderKeyserverID = message.getData().get(KEYSERVER_ID_KEY); String senderKeyserverUnreadCountKey = String.join( MMKV_KEY_SEPARATOR, MMKV_KEYSERVER_PREFIX, senderKeyserverID, MMKV_UNREAD_COUNT_SUFFIX); int senderKeyserverUnreadCount; try { senderKeyserverUnreadCount = Integer.parseInt(badge); } catch (NumberFormatException e) { Log.w("COMM", "Invalid badge count", e); return; } CommMMKV.setInt(senderKeyserverUnreadCountKey, senderKeyserverUnreadCount); int totalUnreadCount = 0; String[] allKeys = CommMMKV.getAllKeys(); for (String key : allKeys) { if (!key.startsWith(MMKV_KEYSERVER_PREFIX) || !key.endsWith(MMKV_UNREAD_COUNT_SUFFIX)) { continue; } Integer unreadCount = CommMMKV.getInt(key, -1); if (unreadCount == null) { continue; } totalUnreadCount += unreadCount; } if (totalUnreadCount > 0) { ShortcutBadger.applyCount(this, totalUnreadCount); } else { ShortcutBadger.removeCount(this); } } private void handleMessageInfosPersistence(RemoteMessage message) { 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"); } } private void handleLargeNotification(RemoteMessage message) { String blobHash = message.getData().get(BLOB_HASH_KEY); String blobHolder = message.getData().get(BLOB_HOLDER_KEY); try { - byte[] largePayload = blobClient.getBlobSync(blobHash); + byte[] largePayload = servicesClient.getBlobSync(blobHash); message = aesDecryptRemoteMessage(message, largePayload); handleMessageInfosPersistence(message); } catch (Exception e) { Log.w("COMM", "Failure when handling large notification.", e); } - blobClient.scheduleDeferredBlobDeletion( + servicesClient.scheduleDeferredBlobDeletion( blobHash, blobHolder, this.getApplicationContext()); } private void addToThreadGroupAndDisplay( String notificationID, NotificationCompat.Builder notificationBuilder, String threadID) { 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) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); if (StaffUtils.isStaffRelease()) { ArrayList groupNotifIDs = recordNotificationInGroupSummary(threadID, notificationID); String notificationSummaryBody = "Notif IDs: " + String.join(System.lineSeparator(), groupNotifIDs); Bundle data = new Bundle(); data.putStringArrayList(GROUP_NOTIF_IDS_KEY, groupNotifIDs); groupSummaryNotificationBuilder .setContentTitle("Summary for thread id " + threadID) .setExtras(data) .setStyle(new NotificationCompat.BigTextStyle().bigText( notificationSummaryBody)) .setAutoCancel(false); } else { groupSummaryNotificationBuilder.setAutoCancel(true); } 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 collapseKey = message.getData().get(COLLAPSE_ID_KEY); String notificationID = id; if (collapseKey != null) { notificationID = collapseKey; } 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); 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); if (title != null) { notificationBuilder.setContentTitle(title); } if (threadID != null) { notificationBuilder.setContentIntent( this.createStartMainActivityAction(id, threadID)); } if (!this.notificationGroupingSupported() || threadID == null) { notificationManager.notify( notificationID, notificationID.hashCode(), notificationBuilder.build()); return; } this.addToThreadGroupAndDisplay( notificationID, notificationBuilder, threadID); } private PendingIntent createStartMainActivityAction(String notificationID, String threadID) { Intent intent = new Intent(this.getApplicationContext(), MainActivity.class); intent.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP); intent.putExtra("threadID", threadID); return PendingIntent.getActivity( this.getApplicationContext(), notificationID.hashCode(), intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE); } private RemoteMessage updateRemoteMessageWithDecryptedPayload( RemoteMessage message, String decryptedSerializedPayload) throws JSONException, IllegalStateException { JSONObject decryptedPayload = new JSONObject(decryptedSerializedPayload); ((Iterable)() -> decryptedPayload.keys()) .forEach(payloadFieldName -> { if (decryptedPayload.optJSONArray(payloadFieldName) != null || decryptedPayload.optJSONObject(payloadFieldName) != null) { throw new IllegalStateException( "Notification payload JSON is not {[string]: string} type."); } String payloadFieldValue = decryptedPayload.optString(payloadFieldName); message.getData().put(payloadFieldName, payloadFieldValue); }); return message; } private RemoteMessage olmDecryptRemoteMessage(RemoteMessage message, String senderKeyserverID) throws JSONException, IllegalStateException { String encryptedSerializedPayload = message.getData().get(ENCRYPTED_PAYLOAD_KEY); String decryptedSerializedPayload = NotificationsCryptoModule.decrypt( senderKeyserverID, encryptedSerializedPayload, NotificationsCryptoModule.olmEncryptedTypeMessage()); return updateRemoteMessageWithDecryptedPayload( message, decryptedSerializedPayload); } private RemoteMessage aesDecryptRemoteMessage(RemoteMessage message, byte[] blob) throws JSONException, IllegalStateException { String aesEncryptionKey = message.getData().get(AES_ENCRYPTION_KEY_LABEL); // On the keyserver AES key is generated as raw bytes // so to send it in JSON it is encoded to Base64 string. byte[] aesEncryptionKeyBytes = Base64.getDecoder().decode(aesEncryptionKey); // On the keyserver notification is a string so it is // first encoded into UTF8 bytes. Therefore bytes // obtained from blob decryption are correct UTF8 bytes. String decryptedSerializedPayload = new String( aesCryptoModule.decrypt(aesEncryptionKeyBytes, blob), StandardCharsets.UTF_8); return updateRemoteMessageWithDecryptedPayload( message, decryptedSerializedPayload); } private Bundle serializeMessageDataForIntentAttachment(RemoteMessage message) { Bundle bundle = new Bundle(); message.getData().forEach(bundle::putString); return bundle; } private void displayErrorMessageNotification( String errorMessage, String errorTitle, String largeErrorData) { NotificationCompat.Builder errorNotificationBuilder = new NotificationCompat.Builder(this.getApplicationContext()) .setDefaults(Notification.DEFAULT_ALL) .setChannelId(CHANNEL_ID) .setSmallIcon(R.drawable.notif_icon) .setLargeIcon(displayableNotificationLargeIcon); if (errorMessage != null) { errorNotificationBuilder.setContentText(errorMessage); } if (errorTitle != null) { errorNotificationBuilder.setContentTitle(errorTitle); } if (largeErrorData != null) { errorNotificationBuilder.setStyle( new NotificationCompat.BigTextStyle().bigText(largeErrorData)); } notificationManager.notify( errorMessage, errorMessage.hashCode(), errorNotificationBuilder.build()); } private boolean isGroupSummary(StatusBarNotification notification, String threadID) { boolean isAnySummary = (notification.getNotification().flags & Notification.FLAG_GROUP_SUMMARY) != 0; if (threadID == null) { return isAnySummary; } return isAnySummary && threadID.equals(notification.getNotification().getGroup()); } private ArrayList recordNotificationInGroupSummary(String threadID, String notificationID) { ArrayList groupNotifIDs = Arrays.stream(notificationManager.getActiveNotifications()) .filter(notif -> isGroupSummary(notif, threadID)) .findFirst() .map( notif -> notif.getNotification().extras.getStringArrayList( GROUP_NOTIF_IDS_KEY)) .orElse(new ArrayList<>()); groupNotifIDs.add(notificationID); return groupNotifIDs; } private void removeNotificationFromGroupSummary( String threadID, String notificationID, StatusBarNotification groupSummaryNotification) { ArrayList groupNotifIDs = groupSummaryNotification.getNotification().extras.getStringArrayList( GROUP_NOTIF_IDS_KEY); if (groupNotifIDs == null) { displayErrorMessageNotification( "Empty summary notif for thread ID " + threadID, "Empty Summary Notif", "Summary notification for thread ID " + threadID + " had empty body when rescinding " + notificationID); } boolean notificationRemoved = groupNotifIDs.removeIf(notifID -> notifID.equals(notificationID)); if (!notificationRemoved) { displayErrorMessageNotification( "Notif with ID " + notificationID + " not in " + threadID, "Unrecorded Notif", "Rescinded notification with id " + notificationID + " not found in group summary for thread id " + threadID); return; } String notificationSummaryBody = "Notif IDs: " + String.join(System.lineSeparator(), groupNotifIDs); Bundle data = new Bundle(); data.putStringArrayList(GROUP_NOTIF_IDS_KEY, groupNotifIDs); NotificationCompat.Builder groupSummaryNotificationBuilder = new NotificationCompat.Builder(this.getApplicationContext()) .setChannelId(CHANNEL_ID) .setSmallIcon(R.drawable.notif_icon) .setContentIntent( this.createStartMainActivityAction(threadID, threadID)) .setContentTitle("Summary for thread id " + threadID) .setExtras(data) .setStyle(new NotificationCompat.BigTextStyle().bigText( notificationSummaryBody)) .setGroup(threadID) .setGroupSummary(true) .setAutoCancel(false) .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_CHILDREN); notificationManager.notify( threadID, threadID.hashCode(), groupSummaryNotificationBuilder.build()); } private void checkForUnmatchedRescind( String threadID, String notificationID, StatusBarNotification anySummaryNotification) { ArrayList anyGroupNotifIDs = anySummaryNotification.getNotification().extras.getStringArrayList( GROUP_NOTIF_IDS_KEY); if (anyGroupNotifIDs == null) { return; } String groupID = anySummaryNotification.getNotification().getGroup(); for (String notifID : anyGroupNotifIDs) { if (!notificationID.equals(notifID)) { continue; } displayErrorMessageNotification( "Summary for thread id " + groupID + "has " + notifID, "Rescind Mismatch", "Summary notif for thread id " + groupID + " contains notif id " + notifID + " which was received in rescind with thread id " + threadID); } } }