Skip to content

Commit

Permalink
Merge pull request #23825 from Expensify/arosiclair-persist-notificat…
Browse files Browse the repository at this point in the history
…ion-cache

Persist notification cache to internal storage
  • Loading branch information
Julesssss authored Aug 7, 2023
2 parents 5775b4f + 6b3ff25 commit 81317d2
Show file tree
Hide file tree
Showing 3 changed files with 214 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<Long, NotificationCache> cache = new HashMap<>();

public CustomNotificationProvider(@NonNull Context context, @NonNull AirshipConfigOptions configOptions) {
super(context, configOptions);
Expand Down Expand Up @@ -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();
Expand All @@ -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);
Expand All @@ -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)
Expand All @@ -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) {
Expand All @@ -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);
}

/**
Expand All @@ -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.
*
Expand All @@ -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);
}
Expand Down Expand Up @@ -328,24 +312,4 @@ private Bitmap fetchIcon(@NonNull Context context, String urlString) {

return null;
}

private static class NotificationCache {
public Map<String, Person> people = new HashMap<>();
public ArrayList<Message> messages = new ArrayList<>();

public Map<String, Bitmap> 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;
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String, NotificationData> 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<String, NotificationData> readFromInternalStorage() {
HashMap<String, NotificationData> 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<String, NotificationData>) 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<String, String> 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<String, String> icons = new HashMap<>();
public ArrayList<NotificationMessage> 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;
}
}
}

0 comments on commit 81317d2

Please sign in to comment.