Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
0c70673
feat: js payload parser for gzipped json strings
adhorodyski Jan 13, 2025
5850f72
chore: update logs
adhorodyski Jan 13, 2025
6a7db39
feat: init android implementation
adhorodyski Jan 20, 2025
87815ee
fix: adjust the js implementation to work with RN, add tests for all …
adhorodyski Jan 23, 2025
b14244d
Merge branch 'Expensify:main' into feat/compressed-push-notifications
adhorodyski Jan 23, 2025
34ee570
fix: android implementation
adhorodyski Jan 23, 2025
3cc57fa
Merge branch 'Expensify:main' into feat/compressed-push-notifications
adhorodyski Jan 27, 2025
959478f
feat: initial implementation for ios
adhorodyski Jan 28, 2025
7f6d9b0
Merge branch 'Expensify:main' into feat/compressed-push-notifications
adhorodyski Jan 28, 2025
de47c99
Merge branch 'Expensify:main' into feat/compressed-push-notifications
adhorodyski Jan 30, 2025
c8511be
chore: Podfile.lock update
VickyStash Jan 31, 2025
7c0fde2
chore: update unit test to use more realistic data
VickyStash Jan 31, 2025
9a61c8d
fix: android implementation improvements
VickyStash Jan 31, 2025
c009ee4
Merge branch 'refs/heads/main' into feat/compressed-push-notifications
VickyStash Feb 4, 2025
a7593e3
Merge branch 'refs/heads/main' into feat/compressed-push-notifications
VickyStash Feb 5, 2025
1e50439
chore: minor code updates
VickyStash Feb 6, 2025
bad3241
re-run tests
VickyStash Feb 6, 2025
f8922cf
Merge branch 'refs/heads/main' into feat/compressed-push-notifications
VickyStash Feb 17, 2025
799b149
Minor improvement
VickyStash Feb 19, 2025
fcab066
re-run checks
VickyStash Feb 19, 2025
510df80
Merge branch 'refs/heads/main' into feat/compressed-push-notifications
VickyStash Feb 21, 2025
563c180
Merge branch 'refs/heads/main' into feat/compressed-push-notifications
VickyStash Feb 21, 2025
1fc6c25
package-lock.json updates
VickyStash Feb 21, 2025
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 @@ -38,6 +38,7 @@

import com.expensify.chat.R;
import com.expensify.chat.shortcutManagerModule.ShortcutManagerUtils;
import com.expensify.chat.customairshipextender.PayloadHandler;
import com.urbanairship.AirshipConfigOptions;
import com.urbanairship.json.JsonMap;
import com.urbanairship.json.JsonValue;
Expand Down Expand Up @@ -119,18 +120,31 @@ protected NotificationCompat.Builder onExtendBuilder(@NonNull Context context, @
}

// Attempt to parse data and apply custom notification styling
if (message.containsKey(PAYLOAD_KEY)) {
try {
JsonMap payload = JsonValue.parseString(message.getExtra(PAYLOAD_KEY)).optMap();
if (payload.containsKey(ONYX_DATA_KEY)) {
Objects.requireNonNull(payload.get(ONYX_DATA_KEY)).isNull();
Log.d(TAG, "payload contains onxyData");
String alert = message.getExtra(PushMessage.EXTRA_ALERT);
applyMessageStyle(context, builder, payload, arguments.getNotificationId(), alert);
}
} catch (Exception e) {
Log.e(TAG, "Failed to parse conversation, falling back to default notification style. SendID=" + message.getSendId(), e);
if (!message.containsKey(PAYLOAD_KEY)) {
return builder;
}

try {
String rawPayload = message.getExtra(PAYLOAD_KEY);
if (rawPayload == null) {
Log.d(TAG, "Failed to parse payload - payload is empty. SendID=" + message.getSendId());
return builder;
}

PayloadHandler handler = new PayloadHandler();
String processedPayload = handler.processPayload(rawPayload);
JsonMap payload = JsonValue.parseString(processedPayload).optMap();
if (!payload.containsKey(ONYX_DATA_KEY)) {
Log.d(TAG, "Failed to process payload - no onyx data. SendID=" + message.getSendId());
return builder;
}

Objects.requireNonNull(payload.get(ONYX_DATA_KEY)).isNull();
Log.d(TAG, "payload contains onxyData");
String alert = message.getExtra(PushMessage.EXTRA_ALERT);
applyMessageStyle(context, builder, payload, arguments.getNotificationId(), alert);
} catch (Exception e) {
Log.e(TAG, "Failed to parse conversation, falling back to default notification style. SendID=" + message.getSendId(), e);
}

return builder;
Expand Down Expand Up @@ -207,7 +221,7 @@ private void applyMessageStyle(@NonNull Context context, NotificationCompat.Buil
String name = messageData.get("person").getList().get(0).getMap().get("text").getString();
String avatar = messageData.get("avatar").getString();
String accountID = Integer.toString(messageData.get("actorAccountID").getInt(-1));

// Use the formatted alert message from the backend. Otherwise fallback on the message in the Onyx data.
String message = alert != null ? alert : messageData.get("message").getList().get(0).getMap().get("text").getString();
String roomName = payload.get("roomName") == null ? "" : payload.get("roomName").getString("");
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package com.expensify.chat.customairshipextender

import android.util.Base64
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.util.zip.GZIPInputStream

class PayloadHandler {
private val BUFFER_SIZE = 4096

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

  • Where does this BUFFER_SIZE value come from? Is it the 4KB notification limit constraint?
  • What if the input stream size exceeds 4KB? I know we're trying to not let that happen in the BE but just "what if".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Yeah, in this case 4KB is a notification payload size limit.

What if the input stream size exceeds 4KB?

Hm, I'm not sure if it's possible since it's the limitation on the Apple Push Notification service (APNs) / Android's Firebase Cloud Messaging (FCM) level

private val GZIP_MAGIC = byteArrayOf(0x1f.toByte(), 0x8b.toByte())

fun processPayload(payloadString: String): String =
runCatching {
val decoded = Base64.decode(payloadString, Base64.DEFAULT)
if (!isGzipped(decoded)) error("Input not gzipped")
return decompressGzip(decoded)
}.getOrDefault(payloadString)

private fun isGzipped(decoded: ByteArray) =
decoded.size >= 2 && decoded[0] == GZIP_MAGIC[0] && decoded[1] == GZIP_MAGIC[1]

private fun decompressGzip(compressed: ByteArray): String {
ByteArrayInputStream(compressed).use { bis ->
GZIPInputStream(bis).use { gis ->
ByteArrayOutputStream().use { output ->
val buffer = ByteArray(BUFFER_SIZE)
var len: Int
while (gis.read(buffer).also { len = it } > 0) {
output.write(buffer, 0, len)
}

return output.toString("UTF-8")
}
}
}
}
}
36 changes: 33 additions & 3 deletions ios/NotificationServiceExtension/NotificationService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import AirshipServiceExtension
import os.log
import Intents
import AppLogs
import Gzip

class NotificationService: UANotificationServiceExtension {

Expand Down Expand Up @@ -93,11 +94,40 @@ class NotificationService: UANotificationServiceExtension {
}
}

func parsePayload(notificationContent: UNMutableNotificationContent) throws -> NotificationData {
guard let payload = notificationContent.userInfo["payload"] as? NSDictionary else {
throw ExpError.runtimeError("payload missing")
private func processPayload(rawPayload: Any) throws -> NSDictionary {
// Handle valid objects first
if let dictPayload = rawPayload as? NSDictionary {
return dictPayload
}

guard let stringPayload = rawPayload as? String else {
throw ExpError.runtimeError("Failed to read payload as string")
}

guard let decoded = Data(base64Encoded: stringPayload) else {
throw ExpError.runtimeError("Failed to decode payload string")
}

guard decoded.isGzipped else {
throw ExpError.runtimeError("Decoded string not gzipped")
}

let decompressedData = try decoded.gunzipped()

guard let jsonDict = try JSONSerialization.jsonObject(with: decompressedData) as? NSDictionary else {
throw ExpError.runtimeError("Failed to parse JSON into dictionary")
}

return jsonDict
}

func parsePayload(notificationContent: UNMutableNotificationContent) throws -> NotificationData {
guard let rawPayload = notificationContent.userInfo["payload"] else {
throw ExpError.runtimeError("payload missing")
}

let payload = try processPayload(rawPayload: rawPayload)

guard let reportID = payload["reportID"] as? Int64 else {
throw ExpError.runtimeError("payload.reportID missing")
}
Expand Down
3 changes: 2 additions & 1 deletion ios/Podfile
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,8 @@ end

target 'NotificationServiceExtension' do
pod 'AirshipServiceExtension'
pod 'GzipSwift'
pod 'AppLogs', :path => '../node_modules/react-native-app-logs/AppLogsPod'
end

pod 'FullStory', :http => 'https://ios-releases.fullstory.com/fullstory-1.52.0-xcframework.tar.gz'
pod 'FullStory', :http => 'https://ios-releases.fullstory.com/fullstory-1.52.0-xcframework.tar.gz'
6 changes: 5 additions & 1 deletion ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,7 @@ PODS:
- AppAuth/Core (~> 1.7)
- GTMSessionFetcher/Core (< 4.0, >= 3.3)
- GTMSessionFetcher/Core (3.5.0)
- GzipSwift (5.1.1)
- hermes-engine (0.76.3):
- hermes-engine/Pre-built (= 0.76.3)
- hermes-engine/Pre-built (0.76.3)
Expand Down Expand Up @@ -2877,6 +2878,7 @@ DEPENDENCIES:
- "FullStory (from `{:http=>\"https://ios-releases.fullstory.com/fullstory-1.52.0-xcframework.tar.gz\"}`)"
- "fullstory_react-native (from `../node_modules/@fullstory/react-native`)"
- glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`)
- GzipSwift
- hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`)
- lottie-react-native (from `../node_modules/lottie-react-native`)
- "onfido-react-native-sdk (from `../node_modules/@onfido/react-native-sdk`)"
Expand Down Expand Up @@ -3008,6 +3010,7 @@ SPEC REPOS:
- GoogleUtilities
- GTMAppAuth
- GTMSessionFetcher
- GzipSwift
- libavif
- libdav1d
- libwebp
Expand Down Expand Up @@ -3321,6 +3324,7 @@ SPEC CHECKSUMS:
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa
hermes-engine: 0555a84ea495e8e3b4bde71b597cd87fbb382888
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
Expand Down Expand Up @@ -3449,6 +3453,6 @@ SPEC CHECKSUMS:
VisionCamera: c95a8ad535f527562be1fb05fb2fd324578e769c
Yoga: 3deb2471faa9916c8a82dda2a22d3fba2620ad37

PODFILE CHECKSUM: 6fc95cc1e80a55665a376c27ca23105e1eda8c64
PODFILE CHECKSUM: a5d166a2e749f6a67badc7a7c90ac596a94c2099

COCOAPODS: 1.15.2
14 changes: 14 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
"@react-ng/bounds-observer": "^0.2.1",
"@rnmapbox/maps": "10.1.33",
"@shopify/flash-list": "1.7.1",
"@types/pako": "^2.0.3",
"@ua/react-native-airship": "19.2.1",
"awesome-phonenumber": "^5.4.0",
"babel-polyfill": "^6.26.0",
Expand All @@ -136,6 +137,7 @@
"lottie-react-native": "6.5.1",
"mapbox-gl": "^2.15.0",
"onfido-sdk-ui": "14.42.0",
"pako": "^2.1.0",
"process": "^0.11.10",
"pusher-js": "8.3.0",
"react": "18.3.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,43 @@
import type {JsonObject, JsonValue} from '@ua/react-native-airship';
import pako from 'pako';
import Log from '@libs/Log';
import type {PushNotificationData} from './NotificationType';

const GZIP_MAGIC_NUMBER = '\x1f\x8b';

/**
* Parse the payload of a push notification. On Android, some notification payloads are sent as a JSON string rather than an object
*/
export default function parsePushNotificationPayload(payload: JsonValue | undefined): PushNotificationData | undefined {
let data = payload;
if (typeof payload === 'string') {
try {
data = JSON.parse(payload) as JsonObject;
} catch {
Log.hmmm(`[PushNotification] Failed to parse the payload`, payload);
data = undefined;
if (payload === undefined) {
return undefined;
}

// No need to parse if it's already an object
if (typeof payload !== 'string') {
return payload as PushNotificationData;
}

// Gzipped JSON String
try {
const binaryStringPayload = atob(payload);
if (!binaryStringPayload.startsWith(GZIP_MAGIC_NUMBER)) {
throw Error();
}
const compressed = Uint8Array.from(binaryStringPayload, (x) => x.charCodeAt(0));
const decompressed = pako.inflate(compressed, {to: 'string'});
const jsonObject = JSON.parse(decompressed) as JsonObject;
return jsonObject as PushNotificationData;
} catch {
Log.hmmm('[PushNotification] Failed to parse the payload as a Gzipped JSON string', payload);
}

// JSON String
try {
return JSON.parse(payload) as JsonObject as PushNotificationData;
} catch {
Log.hmmm(`[PushNotification] Failed to parse the payload as a JSON string`, payload);
}

return data ? (data as PushNotificationData) : undefined;
return undefined;
}
56 changes: 56 additions & 0 deletions tests/unit/parsePushNotificationPayloadTest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import parsePushNotificationPayload from '@libs/Notification/PushNotification/parsePushNotificationPayload';

const payloadWithOnyxData = {
type: 'reportComment',
title: 'Test Payload',
onyxData: [
{
key: 'reportActions_2170976176751360',
onyxMethod: 'merge',
value: {
// eslint-disable-next-line @typescript-eslint/naming-convention
'2463291366241014308': {
actionName: 'ADDCOMMENT',
reportID: '2170976176751360',
reportActionID: '2463291366241014308',
message: [
{
type: 'COMMENT',
html: 'Hello world!',
text: 'Hello world!',
},
],
},
},
},
],
lastUpdateID: 4024059044,
previousUpdateID: 4024059043,
reportID: 2170976176751360,
reportActionID: '2463291366241014308',
roomName: '',
app: 'new',
};

// gzip compressed json string
Comment thread
VickyStash marked this conversation as resolved.
const compressedPayloadWithOnyxData =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Can we perform compression of the payloadWithOnyxData above instead of hard-coding this value?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The app doesn't do gzip compression. I'm not sure we should add this functionality just for the mocked data.

'H4sIAAAAAAAAA5WQT0/DMAzFv0rJeYe0zVrKbVqR4NDBYZzQhCxqbRVJEyXen6rqd6fJmBjQC7f4/ew8P/eMOoPsLmIWjba01EphS2wWMWpIBrJGR9EzdFJD7YFuu1MJBCN77dkHdt/ji3dqdOvekjjnRZ7FeZbP4zTjl7EKaadr36/QbtHLB5B7b9OzRGRpUoztWSJiHouU3wYdwqcrUGGbRVkun6rqfrX202fbx9KTKdPrtb66Jlxmfh/nYIvnTJebXBntSEkvPaCUOjpqK+ubcCU80R992AzDsBmpBEcvpgbCYC54Ivi84EKMzFg8NHrvpnj6M9rvZP8JZrVWl9v5GozxzxaPbPgE0MCh7v4BAAA=';

describe('parsePushNotificationPayload', () => {
it("returns 'undefined' for missing payload", () => {
expect(parsePushNotificationPayload(undefined)).toBeUndefined();
});

it('returns untouched input when provided with raw json', () => {
expect(parsePushNotificationPayload(payloadWithOnyxData)).toStrictEqual(payloadWithOnyxData);
});

it('returns correct json when provided with stringified json', () => {
const stringifiedPayload = JSON.stringify(payloadWithOnyxData);
expect(parsePushNotificationPayload(stringifiedPayload)).toStrictEqual(payloadWithOnyxData);
});

it('returns correct json when provided with gzip compressed json string', () => {
expect(parsePushNotificationPayload(compressedPayloadWithOnyxData)).toStrictEqual(payloadWithOnyxData);
});
});