Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Persist notification cache to internal storage #23825

Merged
merged 32 commits into from
Aug 7, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
0d201fe
rename and move notification cache to its own file
arosiclair Jul 18, 2023
f0d1811
move notification cache hashmap over
arosiclair Jul 18, 2023
5891d85
implement Parcelable for Message
arosiclair Jul 19, 2023
9e3452d
convert hashmaps to bundles
arosiclair Jul 19, 2023
43893e2
fix bundling Person objects by converting icon to bundle
arosiclair Jul 19, 2023
05cb476
safety check
arosiclair Jul 19, 2023
56a1dc1
rename and lift NotificationMessage out of NotificationData to fix st…
arosiclair Jul 20, 2023
3bf3bf5
fix parcelable creator for NotificationMessage
arosiclair Jul 20, 2023
e643107
implement parcelable for NotificationData
arosiclair Jul 20, 2023
5ef1f63
convert cache to Bundle
arosiclair Jul 20, 2023
43dfbc6
add helper for writing cache to file
arosiclair Jul 20, 2023
949fb1c
encode bitmaps so we can marshal the final parcel to bytes
arosiclair Jul 24, 2023
6fa2aac
de-dupe person reference
arosiclair Jul 28, 2023
907fb2e
use Serializable instead
arosiclair Jul 28, 2023
5170132
switch back to Serializable
arosiclair Jul 28, 2023
17b882d
switch back to hashmaps in NotificationData
arosiclair Jul 28, 2023
46687de
switch cache back to HashMap
arosiclair Jul 28, 2023
e03a51c
write the cache to internal storage
arosiclair Jul 28, 2023
dd8f47c
encode bitmap to string so it's serializable
arosiclair Jul 28, 2023
1e06cf0
persist just the person name to make it serializable
arosiclair Jul 28, 2023
3704944
read cache from storage when it's null
arosiclair Jul 28, 2023
6189be7
save a static reference for application context
arosiclair Jul 28, 2023
38f55c0
restore the cache on set as well
arosiclair Jul 28, 2023
93fa41e
handle the notification getting dismissed on open
arosiclair Jul 28, 2023
32f2027
use constant for cache file name
arosiclair Jul 28, 2023
887b1bc
add comment explaining encoded bitmaps
arosiclair Jul 28, 2023
e7188b8
remove unused data class
arosiclair Jul 28, 2023
6df512d
linter suggestions
arosiclair Jul 28, 2023
f119998
Revert "save a static reference for application context"
arosiclair Aug 1, 2023
d772757
use UAirship helper for getting application context
arosiclair Aug 1, 2023
b396b5e
add doc for NotificationData class
arosiclair Aug 2, 2023
6b3ff25
safely close file streams
arosiclair Aug 2, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
arosiclair marked this conversation as resolved.
Show resolved Hide resolved

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;
Julesssss marked this conversation as resolved.
Show resolved Hide resolved
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);
arosiclair marked this conversation as resolved.
Show resolved Hide resolved
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) {
arosiclair marked this conversation as resolved.
Show resolved Hide resolved
if (cache == null) {
cache = readFromInternalStorage();
}

cache.put(Long.toString(reportID), data);
writeToInternalStorage();
arosiclair marked this conversation as resolved.
Show resolved Hide resolved
}

private static void writeToInternalStorage() {
arosiclair marked this conversation as resolved.
Show resolved Hide resolved
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<>();
Comment on lines +99 to +100
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the case where casting the object to result fails, can we assume it is malformed and wipe it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reading the file could fail in a few ways, but in any case, the file will get overwritten once we're done processing the notification and update the cache

} 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 {
arosiclair marked this conversation as resolved.
Show resolved Hide resolved
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;
}
}
}
Loading