diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationListener.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationListener.java index 514e5aca14a0..8149ca118d58 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationListener.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationListener.java @@ -26,6 +26,10 @@ public void onNotificationPosted(@NonNull @NotNull NotificationInfo notification @Override public boolean onNotificationOpened(@NonNull @NotNull NotificationInfo notificationInfo) { + // The notification is also dismissed when it's tapped so handle that as well + PushMessage message = notificationInfo.getMessage(); + provider.onDismissNotification(message); + return parent.onNotificationOpened(notificationInfo); } diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java index d7dff0ffcf0f..0ba77f809b19 100644 --- a/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/CustomNotificationProvider.java @@ -49,6 +49,9 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import com.expensify.chat.customairshipextender.NotificationCache.NotificationData; +import com.expensify.chat.customairshipextender.NotificationCache.NotificationMessage; + public class CustomNotificationProvider extends ReactNotificationProvider { // Resize icons to 100 dp x 100 dp private static final int MAX_ICON_SIZE_DPS = 100; @@ -71,7 +74,6 @@ public class CustomNotificationProvider extends ReactNotificationProvider { private static final String ONYX_DATA_KEY = "onyxData"; private final ExecutorService executorService = Executors.newCachedThreadPool(); - public final HashMap cache = new HashMap<>(); public CustomNotificationProvider(@NonNull Context context, @NonNull AirshipConfigOptions configOptions) { super(context, configOptions); @@ -168,8 +170,8 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil } // Retrieve and check for cached notifications - NotificationCache notificationCache = findOrCreateNotificationCache(reportID); - boolean hasExistingNotification = notificationCache.messages.size() >= 1; + NotificationData notificationData = NotificationCache.getNotificationData(reportID); + boolean hasExistingNotification = notificationData.messages.size() >= 1; try { JsonMap reportMap = payload.get(ONYX_DATA_KEY).getList().get(1).getMap().get("value").getMap(); @@ -183,8 +185,8 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil String conversationName = payload.get("roomName") == null ? "" : payload.get("roomName").getString(""); // Retrieve or create the Person object who sent the latest report comment - Person person = notificationCache.people.get(accountID); - Bitmap personIcon = notificationCache.bitmapIcons.get(accountID); + Person person = notificationData.getPerson(accountID); + Bitmap personIcon = notificationData.getIcon(accountID); if (personIcon == null) { personIcon = fetchIcon(context, avatar); @@ -200,13 +202,12 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil .setName(name) .build(); - notificationCache.people.put(accountID, person); - notificationCache.bitmapIcons.put(accountID, personIcon); + notificationData.putPerson(accountID, name, personIcon); } // Despite not using conversation style for the initial notification from each chat, we need to cache it to enable conversation style for future notifications long createdTimeInMillis = getMessageTimeInMillis(messageData.get("created").getString("")); - notificationCache.messages.add(new NotificationCache.Message(person, message, createdTimeInMillis)); + notificationData.messages.add(new NotificationMessage(accountID, message, createdTimeInMillis)); // Conversational styling should be applied to groups chats, rooms, and any 1:1 chats with more than one notification (ensuring the large profile image is always shown) @@ -217,16 +218,16 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil .setConversationTitle(conversationName); // Add all conversation messages to the notification, including the last one we just received. - for (NotificationCache.Message cachedMessage : notificationCache.messages) { - messagingStyle.addMessage(cachedMessage.text, cachedMessage.time, cachedMessage.person); + for (NotificationMessage cachedMessage : notificationData.messages) { + messagingStyle.addMessage(cachedMessage.text, cachedMessage.time, notificationData.getPerson(cachedMessage.accountID)); } builder.setStyle(messagingStyle); } // Clear the previous notification associated to this conversation so it looks like we are // replacing them with this new one we just built. - if (notificationCache.prevNotificationID != -1) { - NotificationManagerCompat.from(context).cancel(notificationCache.prevNotificationID); + if (notificationData.prevNotificationID != -1) { + NotificationManagerCompat.from(context).cancel(notificationData.prevNotificationID); } } catch (Exception e) { @@ -235,7 +236,9 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil // Store the new notification ID so we can replace the notification if this conversation // receives more messages - notificationCache.prevNotificationID = notificationID; + notificationData.prevNotificationID = notificationID; + + NotificationCache.setNotificationData(reportID, notificationData); } /** @@ -254,25 +257,6 @@ private long getMessageTimeInMillis(String createdTime) { return Calendar.getInstance().getTimeInMillis(); } - /** - * Check if we are showing a notification related to a reportID. - * If not, create a new NotificationCache so we can build a conversation notification - * as the messages come. - * - * @param reportID Report ID. - * @return Notification Cache. - */ - private NotificationCache findOrCreateNotificationCache(long reportID) { - NotificationCache notificationCache = cache.get(reportID); - - if (notificationCache == null) { - notificationCache = new NotificationCache(); - cache.put(reportID, notificationCache); - } - - return notificationCache; - } - /** * Remove the notification data from the cache when the user dismisses the notification. * @@ -287,7 +271,7 @@ public void onDismissNotification(PushMessage message) { return; } - cache.remove(reportID); + NotificationCache.setNotificationData(reportID, null); } catch (Exception e) { Log.e(TAG, "Failed to delete conversation cache. SendID=" + message.getSendId(), e); } @@ -328,24 +312,4 @@ private Bitmap fetchIcon(@NonNull Context context, String urlString) { return null; } - - private static class NotificationCache { - public Map people = new HashMap<>(); - public ArrayList messages = new ArrayList<>(); - - public Map bitmapIcons = new HashMap<>(); - public int prevNotificationID = -1; - - public static class Message { - public Person person; - public String text; - public long time; - - Message(Person person, String text, long time) { - this.person = person; - this.text = text; - this.time = time; - } - } - } } diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/NotificationCache.java b/android/app/src/main/java/com/expensify/chat/customairshipextender/NotificationCache.java new file mode 100644 index 000000000000..7ddc17d37b4d --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/NotificationCache.java @@ -0,0 +1,193 @@ +package com.expensify.chat.customairshipextender; + +import android.content.Context; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.util.Base64; + +import androidx.core.app.Person; +import androidx.core.graphics.drawable.IconCompat; + +import com.expensify.chat.MainApplication; +import com.urbanairship.UAirship; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.io.Serializable; +import java.util.ArrayList; +import java.util.HashMap; + +public class NotificationCache { + + private static final String CACHE_FILE_NAME = "notification-cache"; + private static HashMap cache = null; + + /* + * Get NotificationData for an existing notification or create a new instance + * if it doesn't exist + */ + public static NotificationData getNotificationData(long reportID) { + if (cache == null) { + cache = readFromInternalStorage(); + } + + NotificationData notificationData = cache.get(Long.toString(reportID)); + + if (notificationData == null) { + notificationData = new NotificationData(); + setNotificationData(reportID, notificationData); + } + + return notificationData; + } + + /* + * Set and persist NotificationData in the cache + */ + public static void setNotificationData(long reportID, NotificationData data) { + if (cache == null) { + cache = readFromInternalStorage(); + } + + cache.put(Long.toString(reportID), data); + writeToInternalStorage(); + } + + private static void writeToInternalStorage() { + Context context = UAirship.getApplicationContext(); + + FileOutputStream fos = null; + ObjectOutputStream oos = null; + try { + File outputFile = new File(context.getFilesDir(), CACHE_FILE_NAME); + fos = new FileOutputStream(outputFile); + oos = new ObjectOutputStream(fos); + oos.writeObject(cache); + } catch (IOException e) { + e.printStackTrace(); + } finally { + try { + if (oos != null) { + oos.close(); + } + if (fos != null) { + fos.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + } + + private static HashMap readFromInternalStorage() { + HashMap result; + Context context = UAirship.getApplicationContext(); + + FileInputStream fis = null; + ObjectInputStream ois = null; + try { + File fileCache = new File(context.getFilesDir(), CACHE_FILE_NAME); + fis = new FileInputStream(fileCache); + ois = new ObjectInputStream(fis); + result = (HashMap) ois.readObject(); + } catch (IOException | ClassNotFoundException e) { + e.printStackTrace(); + result = new HashMap<>(); + } finally { + try { + if (ois != null) { + ois.close(); + } + if (fis != null) { + fis.close(); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + + return result; + } + + /** + * A class for caching data for notifications. We use this to track active notifications so we + * can thread related notifications together + */ + public static class NotificationData implements Serializable { + private final HashMap names = new HashMap<>(); + + // A map of accountID => base64 encoded Bitmap + // In order to make Bitmaps serializable, we encode them as base64 strings + private final HashMap icons = new HashMap<>(); + public ArrayList messages = new ArrayList<>(); + + public int prevNotificationID = -1; + + public NotificationData() {} + + public Bitmap getIcon(String accountID) { + return decodeToBitmap(icons.get(accountID)); + } + + public void putIcon(String accountID, Bitmap bitmap) { + icons.put(accountID, encodeToBase64(bitmap)); + } + + public Person getPerson(String accountID) { + if (!names.containsKey(accountID) || !icons.containsKey(accountID)) { + return null; + } + + String name = names.get(accountID); + Bitmap icon = getIcon(accountID); + + return new Person.Builder() + .setIcon(IconCompat.createWithBitmap(icon)) + .setKey(accountID) + .setName(name) + .build(); + } + + public void putPerson(String accountID, String name, Bitmap icon) { + names.put(accountID, name); + putIcon(accountID, icon); + } + + public static String encodeToBase64(Bitmap bitmap) { + if (bitmap == null) { + return ""; + } + + ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream(); + bitmap.compress(Bitmap.CompressFormat.PNG, 100, byteArrayOutputStream); + byte[] byteArray = byteArrayOutputStream.toByteArray(); + return Base64.encodeToString(byteArray, Base64.DEFAULT); + } + + public static Bitmap decodeToBitmap(String base64String) { + if (base64String == null) { + return null; + } + + byte[] decodedBytes = Base64.decode(base64String, Base64.DEFAULT); + return BitmapFactory.decodeByteArray(decodedBytes, 0, decodedBytes.length); + } + } + + public static class NotificationMessage implements Serializable { + public String accountID; + public String text; + public long time; + + NotificationMessage(String accountID, String text, long time) { + this.accountID = accountID; + this.text = text; + this.time = time; + } + } +}