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 b950921a0cd5..fc2ba9db3624 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 @@ -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; @@ -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; @@ -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(""); diff --git a/android/app/src/main/java/com/expensify/chat/customairshipextender/PayloadHandler.kt b/android/app/src/main/java/com/expensify/chat/customairshipextender/PayloadHandler.kt new file mode 100644 index 000000000000..e9ade0eff5e5 --- /dev/null +++ b/android/app/src/main/java/com/expensify/chat/customairshipextender/PayloadHandler.kt @@ -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 + 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") + } + } + } + } +} diff --git a/ios/NotificationServiceExtension/NotificationService.swift b/ios/NotificationServiceExtension/NotificationService.swift index b588c6be1d0f..db999e0ec631 100644 --- a/ios/NotificationServiceExtension/NotificationService.swift +++ b/ios/NotificationServiceExtension/NotificationService.swift @@ -9,6 +9,7 @@ import AirshipServiceExtension import os.log import Intents import AppLogs +import Gzip class NotificationService: UANotificationServiceExtension { @@ -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") } diff --git a/ios/Podfile b/ios/Podfile index b74584b839b4..245a52257af8 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -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' \ No newline at end of file +pod 'FullStory', :http => 'https://ios-releases.fullstory.com/fullstory-1.52.0-xcframework.tar.gz' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 1b9153c3d861..39e33d3ec3d4 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -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) @@ -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`)" @@ -3008,6 +3010,7 @@ SPEC REPOS: - GoogleUtilities - GTMAppAuth - GTMSessionFetcher + - GzipSwift - libavif - libdav1d - libwebp @@ -3321,6 +3324,7 @@ SPEC CHECKSUMS: GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 GTMAppAuth: f69bd07d68cd3b766125f7e072c45d7340dea0de GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6 + GzipSwift: 893f3e48e597a1a4f62fafcb6514220fcf8287fa hermes-engine: 0555a84ea495e8e3b4bde71b597cd87fbb382888 libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7 libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f @@ -3449,6 +3453,6 @@ SPEC CHECKSUMS: VisionCamera: c95a8ad535f527562be1fb05fb2fd324578e769c Yoga: 3deb2471faa9916c8a82dda2a22d3fba2620ad37 -PODFILE CHECKSUM: 6fc95cc1e80a55665a376c27ca23105e1eda8c64 +PODFILE CHECKSUM: a5d166a2e749f6a67badc7a7c90ac596a94c2099 COCOAPODS: 1.15.2 diff --git a/package-lock.json b/package-lock.json index 74b9c136c4f6..1be890a95786 100644 --- a/package-lock.json +++ b/package-lock.json @@ -44,6 +44,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", @@ -69,6 +70,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", @@ -13846,6 +13848,12 @@ "@types/node": "*" } }, + "node_modules/@types/pako": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.3.tgz", + "integrity": "sha512-bq0hMV9opAcrmE0Byyo0fY3Ew4tgOevJmQ9grUhpXQhYfyLJ1Kqg3P33JT5fdbT2AjeAjR51zqqVjAL/HMkx7Q==", + "license": "MIT" + }, "node_modules/@types/parse-json": { "version": "4.0.0", "dev": true, @@ -30634,6 +30642,12 @@ "version": "1.0.0", "license": "BlueOak-1.0.0" }, + "node_modules/pako": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.1.0.tgz", + "integrity": "sha512-w+eufiZ1WuJYgPXbV/PO3NCMEc3xqylkKHzp8bxp1uW4qaSNQUkwmLLEc3kKsfz8lpV1F8Ht3U1Cm+9Srog2ug==", + "license": "(MIT AND Zlib)" + }, "node_modules/param-case": { "version": "3.0.4", "dev": true, diff --git a/package.json b/package.json index 183060968b03..2f537c1b4681 100644 --- a/package.json +++ b/package.json @@ -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", @@ -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", diff --git a/src/libs/Notification/PushNotification/parsePushNotificationPayload.ts b/src/libs/Notification/PushNotification/parsePushNotificationPayload.ts index 8ef20dd9f537..213a2d5f95f2 100644 --- a/src/libs/Notification/PushNotification/parsePushNotificationPayload.ts +++ b/src/libs/Notification/PushNotification/parsePushNotificationPayload.ts @@ -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; } diff --git a/tests/unit/parsePushNotificationPayloadTest.ts b/tests/unit/parsePushNotificationPayloadTest.ts new file mode 100644 index 000000000000..d2c0265e9836 --- /dev/null +++ b/tests/unit/parsePushNotificationPayloadTest.ts @@ -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 +const compressedPayloadWithOnyxData = + '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); + }); +});