Skip to content

Conversation

@diegolmello
Copy link
Member

@diegolmello diegolmello commented Dec 24, 2025

Proposed changes

This PR fixes a critical issue where push notifications (especially E2E encrypted notifications) failed to display when the Android app was killed. The root cause was that the notification processing code was waiting for React Native to initialize before decrypting E2E messages, which could cause timeouts or failures when the app was completely killed.

Key improvements:

  • E2E notifications can now decrypt immediately without waiting for React Native initialization by using SQLiteDatabase directly instead of WatermelonDB wrapper
  • Removed complex async waiting logic that was prone to timeouts and race conditions
  • Simplified notification queueing - only message-id-only notifications (which need MMKV for API tokens) are queued, while E2E notifications process immediately
  • Added static MainApplication instance for Java code to access application context without React Native dependency
  • Removed E2ENotificationProcessor class - no longer needed with synchronous decryption approach

Issue(s)

Fixes push notification handling when Android app is killed (especially E2E encrypted notifications)

How to test or reproduce

  1. Test E2E notifications when app is killed:

    • Kill the app completely (swipe away from recent apps)
    • Send an E2E encrypted message to the device
    • Verify the notification appears with decrypted content
  2. Test message-id-only notifications:

    • Kill the app completely
    • Send a message-id-only notification
    • Verify the notification is queued and processed once React Native initializes
  3. Test regular notifications:

    • Kill the app completely
    • Send a regular (non-E2E) notification
    • Verify the notification appears correctly
  4. Test app startup scenarios:

    • Send notifications while app is starting up
    • Verify all notifications are processed correctly

Screenshots

N/A - Backend/notification handling changes

Types of changes

  • Bugfix (non-breaking change which fixes an issue)
  • Improvement (non-breaking change which improves a current function)
  • New feature (non-breaking change which adds functionality)
  • Documentation update (if none of the other choices apply)

Checklist

  • I have read the CONTRIBUTING doc
  • I have signed the CLA
  • Lint and unit tests pass locally with my changes
  • I have added tests that prove my fix is effective or that my feature works (if applicable)
  • I have added necessary documentation (if applicable)
  • Any dependent changes have been merged and published in downstream modules

Further comments

Technical Details

Problem:
When the Android app was killed, push notifications (especially E2E encrypted ones) would fail to display because:

  1. The code waited for React Native to initialize before decrypting E2E messages
  2. This waiting logic used polling with timeouts, which could fail
  3. The E2ENotificationProcessor added unnecessary complexity and potential race conditions

Solution:

  1. Direct SQLite access: Changed Encryption.readRoom() to use SQLiteDatabase.openDatabase() directly instead of WatermelonDB wrapper, eliminating the React Native dependency for E2E decryption
  2. Static MainApplication instance: Added a static instance property to MainApplication so Java code can access the application context without React Native
  3. Simplified queueing: Only message-id-only notifications (which need MMKV for API tokens) are queued; E2E notifications process immediately
  4. Removed async processor: Deleted E2ENotificationProcessor.java as it's no longer needed

Files Changed:

  • MainApplication.kt: Added static instance and getInstance() method
  • CustomPushNotification.java: Simplified notification handling, removed async waiting logic, added queueing for message-id-only notifications
  • Encryption.java: Refactored to use SQLiteDatabase directly, removed Context parameter dependency
  • ReplyBroadcast.java: Updated to use new Encryption API (no Context parameter)
  • MMKVKeyManager.java: Removed unused appContext field
  • E2ENotificationProcessor.java: DELETED - no longer needed

Note: There's a TODO comment in Encryption.readRoom() about potential lock issues with concurrent database access. This should be monitored in production.

diegolmello and others added 30 commits December 10, 2025 10:48
…andling and migrate notification logic

- Added expo-notifications for push notification management, replacing react-native-notifications.
- Implemented device token registration and notification response handling.
- Enhanced badge count management and notification dismissal methods.
- Set up notification categories for iOS to support actions like reply and video conference responses.
- Updated MainApplication to reflect new notification architecture.
…ent custom FCM handling

- Removed react-native-notifications dependency and related mock configurations.
- Introduced RCFirebaseMessagingService for handling FCM messages and routing to CustomPushNotification.
- Updated CustomPushNotification to manage notifications without react-native-notifications, enhancing E2E decryption and MessagingStyle support.
- Adjusted MainApplication and notification classes to reflect the new architecture and improve notification processing.
- Cleaned up unused imports and code related to the previous notification system.
…e reply handling

- Removed react-native-notifications dependency and related code from the project.
- Implemented a custom reply notification handler in ReplyNotification to manage direct replies from iOS notifications.
- Updated AppDelegate to configure the new reply notification handler.
- Adjusted Podfile and Podfile.lock to reflect the removal of react-native-notifications and added necessary Expo modules.
- Cleaned up imports and ensured compatibility with the new notification architecture.
- Introduced a new video conference notification system, replacing the previous handling with a custom implementation.
- Added VideoConfNotification class to manage incoming call notifications with accept/decline actions.
- Implemented VideoConfBroadcast receiver to handle notification actions and store them for the JS layer.
- Updated MainActivity to process video conference intents and integrate with the new notification system.
- Enhanced getInitialNotification to check for pending video conference actions.
- Updated AndroidManifest.xml to register the new broadcast receiver.
- Cleaned up related code and ensured compatibility with the new notification architecture.
…or iOS

- Added support for video conference notifications in the iOS NotificationService.
- Implemented logic to process incoming video call notifications, including handling call status and displaying appropriate alerts.
- Updated Payload and NotificationType models to accommodate video conference data.
- Enhanced getInitialNotification to check for video conference actions on iOS using expo-notifications.
- Improved error handling for notification responses.
- Enhanced the onNotification function to streamline processing of video conference notifications, including accept and decline actions.
- Updated getInitialNotification to handle video conference actions more effectively, ensuring proper event dispatching based on user interaction.
- Improved error handling and code readability by reducing nested conditions and clarifying logic flow.
…encies

- Removed @notifee/react-native and @react-native-firebase/messaging from the project, along with related code and configurations.
- Updated notification handling to utilize expo-notifications instead, ensuring a more streamlined approach to push notifications.
- Cleaned up package.json, yarn.lock, and Podfile.lock to reflect the removal of obsolete dependencies.
- Deleted background notification handler and adjusted notification settings management accordingly.
…ng to TurboModules

- Replaced the existing VideoConfModule and related classes with a TurboModule implementation for improved performance and integration.
- Updated MainApplication to use VideoConfTurboPackage instead of the legacy VideoConfPackage.
- Enhanced notification handling by introducing a new User-Agent header in relevant requests.
- Removed obsolete Java classes and streamlined the notification architecture to utilize Kotlin for better maintainability.
- Improved the handling of video conference actions and ensured compatibility with the new TurboModule system.
- Added a 3-second timeout for fetching avatars in CustomPushNotification to prevent blocking the FCM service.
- Enhanced error handling in ReplyBroadcast to return early if the bundle is null.
- Updated push notification configuration to re-register the push token with the server if the user is authenticated.
- Modified appStateMiddleware to handle potential errors when clearing notifications.
- Improved notification delivery logic in iOS NotificationService to handle cases with no messageId or notification content.
- Added functionality to encode and store the full payload from the server in the userInfo dictionary under the key "ejson" for improved navigation.
- Added a method to fetch user avatars from the server and attach them to notifications in NotificationService.
- Introduced a userAgent property in Bundle for consistent User-Agent string usage across API requests.
- Updated avatar URI generation to differentiate between direct messages and group/channel notifications, ensuring the correct avatar is fetched based on the message type.
- Refactored avatar fetching logic in NotificationService to improve clarity and maintainability.
- Added support for Communication Notifications API to update notification content with sender avatars.
- Updated CustomPushNotification to use a volatile Bundle for thread safety.
- Enhanced VideoConfBroadcast to include host and callId in the notification payload.
- Improved notification validation in the onNotification function to ensure required fields are present before dispatching app initialization.
- Refactored iOS NotificationService to manage background tasks more effectively and ensure proper cleanup.
- Added NSUserActivityTypes for message intents in Info.plist files for both iOS targets.
- Enhanced message parsing in CustomPushNotification to handle cases where the message format may not include a colon.
- Updated Ejson to ensure proper URL encoding for avatar paths, improving reliability in generating user and room avatars.
- Added error handling in onNotification to catch parsing errors for video conference notifications, logging warnings for failed attempts.
- Improved room name assignment logic to handle cases where sender information may be missing.
- Updated iOS ReplyNotification to provide user feedback for invalid notification data and failed replies.
- Enhanced CustomPushNotification to dynamically set notification titles for groups, direct messages, and omnichannel based on the Ejson payload.
- Updated Ejson class to include a room name field for better context in notifications.
- Improved iOS NotificationService to determine notification titles based on payload type, ensuring accurate representation of sender or room names.
…ding errors

- Updated avatar URI generation to specify "UTF-8" encoding instead of StandardCharsets.UTF_8.
- Changed exception handling to catch UnsupportedEncodingException specifically for better clarity in error logging.
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (1)

177-220: Address TODO: Database lock issues with concurrent access.

The TODO comment on line 182 flags known lock issues when accessing the WatermelonDB SQLite database concurrently. Direct SQLite access from notification code while WatermelonDB may be active can cause SQLITE_LOCKED or SQLITE_BUSY errors, resulting in notification processing failures.

Additionally, there's no timeout for database operations, which could cause notification handling to hang indefinitely if a lock is held.

Consider these options:

  1. Implement a retry mechanism with exponential backoff for locked database scenarios
  2. Add a timeout to database operations (e.g., using SQLiteDatabase.OpenParams with busy timeout on API 27+)
  3. Use WatermelonDB's own query mechanisms if available from native code
  4. Cache room encryption status in a separate, notification-safe storage
#!/bin/bash
# Check if WatermelonDB provides native query APIs that could be used instead
rg -nP 'WatermelonDB|SQLiteAdapter' --type=java --type=kotlin -C3

# Search for other direct SQLite access patterns in the codebase
rg -nP 'SQLiteDatabase\.open' --type=java -C3
🧹 Nitpick comments (8)
app/lib/notifications/index.ts (2)

24-33: Missing return in catch block causes fallthrough to regular notification handling.

When EJSON parsing fails for video conf actions (ACCEPT/DECLINE), execution continues past the catch block and attempts to re-parse the same payload at line 38. If the second parse also fails, the notification is handled as if no ejson was present, triggering appInit(). If parsing succeeds the second time (unlikely but theoretically possible with race conditions), the video conf action could be mishandled as a regular notification.

Consider adding a return after the warning to exit early on parse failure:

🔎 Proposed fix
 			} catch (e) {
 				console.warn('Failed to parse video conf notification:', e);
+				return;
 			}
 		}
 	}

58-63: LGTM! Clean room name resolution with null-safe fallbacks.

The refactored logic is more readable and handles edge cases well with nullish coalescing. The optional chaining on sender is appropriate defensive coding since the EJSON payload structure isn't strictly validated.

Note: The IEjson interface (line 12) declares sender as a required object with required string fields, but the runtime optional chaining suggests sender can be undefined. Consider updating the interface to match reality:

sender?: { username?: string; name?: string };
ios/RocketChatRN.xcodeproj/project.pbxproj (1)

1-3349: Note: Xcode project files are machine-generated

This .pbxproj file is automatically generated and maintained by Xcode and CocoaPods. The changes appear to reflect updates to Pod dependencies (renaming/reorganizing Pod frameworks from NotificationService to Rocket.Chat naming scheme) and associated build configurations.

If there are any build issues after this change:

  1. Ensure Podfile.lock is committed alongside this change
  2. Team members should run pod install after pulling
  3. Consider regenerating with pod install --repo-update if issues persist
android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt (1)

211-217: Consider adding context about blocking behavior in the docstring.

The getAvatar function delegates to NotificationHelper.fetchAvatarBitmap which is a synchronous blocking call with a 3-second timeout. While this is acceptable for notification handling, consider noting this blocking behavior in the docstring for future maintainers.

Suggested docstring enhancement
     /**
      * Fetches avatar bitmap from URI using Glide.
      * Returns null if fetch fails or times out, in which case notification will display without avatar.
+     * Note: This is a blocking call with a 3-second timeout.
      */
     private fun getAvatar(uri: String): Bitmap? {
         return NotificationHelper.fetchAvatarBitmap(context, uri, null)
     }
android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java (1)

62-85: Well-implemented avatar fetching with proper timeout and error handling.

The 3-second timeout is appropriate given FCM's constraints, and the interrupt status is correctly restored on InterruptedException. The null/empty check at the start prevents unnecessary work.

One consideration: Glide.with(context) should ideally use an Application context to avoid potential memory leaks if an Activity context is passed. In the current usage (notification handling), the context is typically the application context, but explicitly using context.getApplicationContext() would be safer.

Suggested defensive improvement
         try {
             // Use a 3-second timeout to avoid blocking the FCM service for too long
             // FCM has a 10-second limit, so we need to fail fast and use fallback icon
-            Bitmap avatar = Glide.with(context)
+            Bitmap avatar = Glide.with(context.getApplicationContext())
                     .asBitmap()
                     .apply(RequestOptions.bitmapTransform(new RoundedCorners(10)))
                     .load(uri)
ios/NotificationService/NotificationService.swift (1)

86-100: Sender prefix stripping logic handles both username and display name.

The logic to strip the sender prefix from the message body is thorough, trying both the username and the display name as fallbacks. This ensures consistent behavior across different message formats.

Minor observation: the cast bestAttemptContent.body as? String on line 89 is unusual since body is already a String property on UNMutableNotificationContent. This cast will always succeed.

Simplify the body access
-                if let body = bestAttemptContent.body as? String {
+                let body = bestAttemptContent.body
+                if true {

Or simply remove the if let and access bestAttemptContent.body directly.

android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java (1)

303-317: Duplicate title determination logic should be extracted.

The logic for determining notificationTitle (lines 303-317) and conversationTitle (lines 465-481) is nearly identical. This duplication increases maintenance burden and risk of inconsistency.

Extract common title logic to a helper method
+    /**
+     * Determines the display title based on notification type.
+     */
+    private String determineTitle(Ejson ejson, String defaultTitle) {
+        if (ejson == null || ejson.type == null) {
+            return defaultTitle;
+        }
+        
+        if ("p".equals(ejson.type) || "c".equals(ejson.type)) {
+            // For groups/channels, use room name if available
+            return (ejson.name != null && !ejson.name.isEmpty()) ? ejson.name : defaultTitle;
+        } else if ("d".equals(ejson.type)) {
+            // For direct messages, use default title (sender name)
+            return defaultTitle;
+        } else if ("l".equals(ejson.type)) {
+            // For omnichannel, use sender name if available
+            return (ejson.sender != null && ejson.sender.name != null && !ejson.sender.name.isEmpty()) 
+                ? ejson.sender.name : defaultTitle;
+        }
+        
+        return defaultTitle;
+    }

Then use determineTitle(ejson, title) in both buildNotification and notificationStyle.

Also applies to: 465-481

android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (1)

267-291: Improved parsing and validation logic.

The double-parsing fallback (lines 269-280) and required field validation (lines 288-290) improve robustness when handling potentially inconsistent private key data.

The need for double-parsing suggests inconsistent JSON encoding upstream. Consider standardizing the private key storage format to eliminate the fallback path and reduce complexity.

📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 0d13c14 and cfc6120.

📒 Files selected for processing (21)
  • android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt
  • android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java
  • android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java
  • android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java
  • android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java
  • android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java
  • android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java
  • android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt
  • android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt
  • android/app/src/main/java/chat/rocket/reactnative/storage/MMKVKeyManager.java
  • app/lib/notifications/index.ts
  • ios/NotificationService/Info.plist
  • ios/NotificationService/NotificationService.entitlements
  • ios/NotificationService/NotificationService.swift
  • ios/ReplyNotification.swift
  • ios/RocketChatRN.xcodeproj/project.pbxproj
  • ios/RocketChatRN/Info.plist
  • ios/RocketChatRN/RocketChatRN.entitlements
  • ios/Shared/Extensions/Bundle+Extensions.swift
  • ios/Shared/Models/Payload.swift
  • ios/Shared/RocketChat/API/Request.swift
💤 Files with no reviewable changes (1)
  • android/app/src/main/java/chat/rocket/reactnative/storage/MMKVKeyManager.java
🧰 Additional context used
🧬 Code graph analysis (8)
ios/Shared/Extensions/Bundle+Extensions.swift (1)
app/lib/constants/userAgent.ts (1)
  • userAgent (3-5)
android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java (1)
android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (1)
  • Encryption (107-520)
android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java (1)
android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (1)
  • Encryption (107-520)
android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java (1)
android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt (1)
  • context (22-232)
ios/ReplyNotification.swift (1)
ios/Shared/RocketChat/RocketChat.swift (1)
  • sendMessage (39-75)
ios/Shared/RocketChat/API/Request.swift (1)
app/lib/constants/userAgent.ts (1)
  • userAgent (3-5)
android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (1)
app/lib/encryption/utils.ts (1)
  • decodePrefixedBase64 (200-219)
android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java (3)
android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt (2)
  • context (22-232)
  • getAvatar (215-217)
android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (1)
  • Encryption (107-520)
android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java (1)
  • NotificationHelper (22-86)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (1)
  • GitHub Check: ESLint and Test / run-eslint-and-test
🔇 Additional comments (28)
app/lib/notifications/index.ts (1)

47-50: Good defensive guard for incomplete notification data.

The early return when rid, type, or host are missing prevents navigation failures with incomplete data. Falling back to appInit() is appropriate as it initializes the app without attempting invalid deep linking.

ios/ReplyNotification.swift (2)

91-98: Good improvement: User-facing error feedback.

The addition of a failure notification for invalid notification data is a solid improvement over silent failure. Users now receive clear feedback when reply processing cannot proceed.


127-127: Improved error message clarity.

The updated message "Failed to send reply." is more concise and consistent with the error message at line 93.

ios/Shared/Models/Payload.swift (1)

11-15: LGTM! Username field enriches caller information.

The addition of the optional username field to the Caller struct aligns with the Android changes and enables richer notification displays.

ios/RocketChatRN/RocketChatRN.entitlements (1)

15-16: LGTM! Communication entitlement properly configured.

The com.apple.developer.usernotifications.communication entitlement enables communication notification features on iOS, which supports the INSendMessageIntent integration.

ios/NotificationService/NotificationService.entitlements (1)

5-6: LGTM! Notification service extension entitlement properly configured.

The communication entitlement is correctly added to the notification service extension, enabling it to handle communication notifications.

ios/NotificationService/Info.plist (1)

36-46: LGTM! INSendMessageIntent support properly configured.

The addition of both NSUserActivityTypes and NSExtensionAttributes correctly enables the notification service extension to handle communication intents for improved notification threading and display.

android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt (1)

46-57: LGTM! Video conference payload enriched with host and callId.

The additions properly extract the host and callId from extras with safe defaults, enabling the JS layer to handle video conference events with complete context.

ios/RocketChatRN/Info.plist (1)

111-114: LGTM! App-level INSendMessageIntent support configured.

The NSUserActivityTypes declaration at the app level complements the notification service extension configuration, completing the communication notification integration.

ios/Shared/Extensions/Bundle+Extensions.swift (1)

15-23: LGTM! User-Agent generation is well-implemented.

The static Bundle.userAgent property provides centralized UA string generation with good defensive coding (?? "unknown" fallbacks for missing values). The format "RC Mobile; ios {systemVersion}; v{appVersion} ({build})" clearly identifies the native app.

ios/Shared/RocketChat/API/Request.swift (1)

58-58: No verification needed—the iOS implementation is correct.

The Bundle.userAgent returns "RC Mobile; ios {version}; v{appVersion} ({build})", which matches the exact format used in app/lib/methods/helpers/fetch.ts for API requests. The backend already expects and requires this format, as documented in the fetch.ts comment referencing app/statistics/server/lib/UAParserCustom.js.

The confusion in the original comment stems from userAgent.ts, which provides browser-style UA strings for WebView components—not for API requests, which is what the iOS change addresses. Backend compatibility is not a concern.

Likely an incorrect or invalid review comment.

android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java (2)

27-28: 15-second timeout for cold start is reasonable but close to FCM limits.

The increased timeout from 3s to 15s accommodates cold start scenarios where React Native initialization takes longer. However, FCM background message handling has platform-specific time limits (typically 10-20 seconds on Android). If React context isn't available within this window, the notification may still fail silently.

The current implementation handles timeout gracefully via onTimeout callback, which is good.


110-133: Background thread callback invocation is intentional and documented.

The comment on line 117 clarifies that the callback is called on the background thread to support notification image loading. This is a reasonable design choice, but callers must be aware they're not on the main thread.

The decryption call signature change (decryptMessage(ejson) without Context) aligns with the Encryption.java refactor that now uses MainApplication.instance internally.

android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfNotification.kt (1)

159-166: Avatar fetching with graceful fallback is well implemented.

The avatar fetching logic safely handles the case where the avatar URI is null or the fetch fails/times out. The conditional setLargeIcon only applies when a bitmap is successfully retrieved, ensuring the notification displays correctly even without an avatar.

Also applies to: 181-185

android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java (2)

63-116: Centralized avatar URI building with proper URL encoding.

The buildAvatarUri helper method consolidates the common logic for constructing avatar URIs, which improves maintainability. The differentiation between DM (sender avatar) and group/channel (room avatar) paths is correct.

URL encoding with UTF-8 is the right approach for handling special characters in usernames. Note that UnsupportedEncodingException is extremely unlikely since UTF-8 is guaranteed to be available on all Java implementations, but catching it is still correct practice.

Security note: The auth tokens are passed as query parameters (rc_token, rc_uid), which is acceptable assuming all server communication uses HTTPS. These tokens would be visible in server access logs.


118-136: Caller avatar URI generation follows the same pattern.

The getCallerAvatarUri method correctly reuses buildAvatarUri and properly handles the case where caller or username is null.

android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java (1)

129-129: API call updated to match Encryption signature change.

The call to Encryption.shared.encryptMessageContent correctly reflects the removal of the Context parameter, aligning with the broader refactor where Encryption now uses MainApplication.instance internally for context-dependent operations.

ios/NotificationService/NotificationService.swift (3)

17-33: Clean refactoring with guard-based routing.

The restructured didReceive method with guard statements improves readability and provides clear routing based on notification type. Early exit for invalid payloads prevents unnecessary processing.


269-274: Proper timeout configuration for notification service extension.

Both timeoutIntervalForRequest and timeoutIntervalForResource are set to 3 seconds, which is critical for notification service extensions that have strict time limits (~30 seconds total). The comment correctly explains the distinction between these timeout types.


157-168: Dummy recipient for iOS group conversation handling is documented.

The comment on line 159 explains why a placeholder recipient is needed for iOS to properly treat the notification as a group conversation. This is a known iOS quirk for INSendMessageIntent.

android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java (4)

50-63: Thread-safe pending notification queue implementation.

Using CopyOnWriteArrayList ensures thread safety when notifications arrive concurrently before React initialization. The PendingNotification wrapper correctly captures both context and bundle for deferred processing.


90-109: Queued notification processing on React context initialization.

The setReactContext method now correctly processes any notifications that were queued while waiting for React to initialize. The exception handling around individual notification processing prevents one failed notification from blocking others.


201-213: E2E decryption now happens synchronously without React dependency.

This is a significant improvement for killed-app scenarios. The decryption uses MainApplication.instance internally (via Encryption.shared), eliminating the need to wait for React context for E2E notifications.

One concern: if decryption fails (line 211), the notification is silently dropped. Consider whether a fallback (e.g., showing encrypted indicator or generic message) would improve user experience.

Is silently dropping E2E notifications on decryption failure the intended behavior? Users might not realize they received a message if decryption fails.


143-152: Message-id-only notifications correctly queue when React is unavailable.

These notifications require MMKV access (for auth tokens) which depends on React context. Queueing them for later processing is the correct approach. The warning log helps with debugging.

android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt (1)

40-45: Singleton pattern for Application instance is correct.

The companion object with @JvmStatic on lateinit var instance and a private setter is a clean approach for exposing the Application instance to Java code. The @JvmStatic annotation auto-generates a static getter method (getInstance()) for Java interoperability. Setting instance = this in onCreate() is the correct lifecycle point.

While MainApplication.instance would throw UninitializedPropertyAccessException if accessed before onCreate() completes, the actual usage in Encryption.java:230 occurs during FCM notification handling (via getDatabasePath()), which always happens after application initialization. This timing ensures safe access.

android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (3)

306-376: Enhanced error handling and logging.

The granular try-catch blocks (lines 313-319, 329-337, 341-349, 357-362) with specific error messages significantly improve debuggability for E2E decryption failures in production.


406-444: Method signature changes remove Context dependency.

The removal of Context parameters from decryptMessage (line 406) and encryptMessageContent (line 446) simplifies the API. These methods now rely on MainApplication.getInstance() accessed via getDatabasePath().

These changes depend on fixing the critical issues identified in getDatabasePath (null safety and database path calculation). Verify that the earlier issues are resolved before merging.

Also applies to: 446-502


228-230: Remove null check suggestion—MainApplication.getInstance() initialization is guaranteed before notification processing.

MainApplication.instance is a Kotlin lateinit variable initialized in onCreate() at the start of application lifecycle. Android guarantees Application.onCreate() executes before any service or broadcast receiver (including FCM notification handlers) can process messages. Unlike a nullable type, lateinit throws UninitializedPropertyAccessException, not return null. The context at line 232 is safe to use without defensive checks.

Likely an incorrect or invalid review comment.

Comment on lines 103 to 111
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
backgroundTask = UIApplication.shared.beginBackgroundTask {
// Expiration handler - called if system needs to reclaim resources
if backgroundTask != .invalid {
UIApplication.shared.endBackgroundTask(backgroundTask)
backgroundTask = .invalid
}
completionHandler()
}
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🔴 Critical

Critical: Double completion handler invocation and thread-safety issue.

The current implementation has two critical problems:

  1. Double completion handler call: The expiration handler calls completionHandler() at line 110, and the defer block also calls completionHandler() at line 121. If the expiration handler fires before sendMessage completes, both will execute, calling completionHandler() twice. iOS requires completion handlers to be called exactly once.

  2. Race condition: The backgroundTask variable is accessed and modified from multiple threads without synchronization—the expiration handler can run on any thread, while the defer block runs on the main thread. This creates a race condition.

🔎 Proposed fix using a completion flag and synchronization
+  private var completionLock = NSLock()
+  private var hasCalledCompletion = false
+
   private func handleReplyAction(response: UNNotificationResponse, completionHandler: @escaping () -> Void) {
     guard let textResponse = response as? UNTextInputNotificationResponse else {
       completionHandler()
       return
     }
     
     let userInfo = response.notification.request.content.userInfo
     
     guard let ejsonString = userInfo["ejson"] as? String,
           let ejsonData = ejsonString.data(using: .utf8),
           let payload = try? JSONDecoder().decode(Payload.self, from: ejsonData),
           let rid = payload.rid else {
       // Show failure notification to user
       let content = UNMutableNotificationContent()
       content.body = "Failed to send reply. Invalid notification data."
       let request = UNNotificationRequest(identifier: "replyPayloadFailure", content: content, trigger: nil)
       UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
       completionHandler()
       return
     }
     
     let message = textResponse.userText
     let rocketchat = RocketChat(server: payload.host.removeTrailingSlash())
     
+    let completionLock = NSLock()
+    var hasCalledCompletion = false
+    
+    let safeComplete = {
+      completionLock.lock()
+      defer { completionLock.unlock() }
+      if !hasCalledCompletion {
+        hasCalledCompletion = true
+        completionHandler()
+      }
+    }
+    
     var backgroundTask: UIBackgroundTaskIdentifier = .invalid
     backgroundTask = UIApplication.shared.beginBackgroundTask {
       // Expiration handler - called if system needs to reclaim resources
+      completionLock.lock()
+      let taskToEnd = backgroundTask
+      backgroundTask = .invalid
+      completionLock.unlock()
+      
-      if backgroundTask != .invalid {
-        UIApplication.shared.endBackgroundTask(backgroundTask)
-        backgroundTask = .invalid
-      }
-      completionHandler()
+      if taskToEnd != .invalid {
+        UIApplication.shared.endBackgroundTask(taskToEnd)
+      }
+      safeComplete()
     }
     
     rocketchat.sendMessage(rid: rid, message: message, threadIdentifier: payload.tmid) { response in
       // Ensure we're on the main thread for UI operations
       DispatchQueue.main.async {
         defer {
+          completionLock.lock()
+          let taskToEnd = backgroundTask
+          backgroundTask = .invalid
+          completionLock.unlock()
+          
-          if backgroundTask != .invalid {
-            UIApplication.shared.endBackgroundTask(backgroundTask)
-            backgroundTask = .invalid
-          }
-          completionHandler()
+          if taskToEnd != .invalid {
+            UIApplication.shared.endBackgroundTask(taskToEnd)
+          }
+          safeComplete()
         }
         
         guard let response = response, response.success else {
           // Show failure notification
           let content = UNMutableNotificationContent()
           content.body = "Failed to send reply."
           let request = UNNotificationRequest(identifier: "replyFailure", content: content, trigger: nil)
           UNUserNotificationCenter.current().add(request, withCompletionHandler: nil)
           return
         }
       }
     }
   }

This fix ensures:

  • completionHandler() is called exactly once using a synchronized flag
  • Thread-safe access to backgroundTask using a lock
  • Background task is properly ended before calling completion

Committable suggestion skipped: line range outside the PR's diff.

Comment on lines 1670 to 1687
407D3EDE3DABEE15D27BD87D /* ShellScript */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
);
name = "[Expo] Configure project";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-defaults-RocketChatRN/Pods-defaults-RocketChatRN-frameworks.sh",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-defaults-NotificationService-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-defaults-RocketChatRN/expo-configure-project.sh\"\n";
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-defaults-RocketChatRN/Pods-defaults-RocketChatRN-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's read the exact lines mentioned to confirm the content
sed -n '1670,1687p' ios/RocketChatRN.xcodeproj/project.pbxproj

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 853


🏁 Script executed:

# Let's also check the overall structure of the file to understand the context
wc -l ios/RocketChatRN.xcodeproj/project.pbxproj

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 125


🏁 Script executed:

# Search for other shell script phases to understand the pattern
rg "shellScript = " ios/RocketChatRN.xcodeproj/project.pbxproj -A 1 -B 10 | head -100

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 5971


🏁 Script executed:

# Search for NotificationService target in the project file
rg "NotificationService" ios/RocketChatRN.xcodeproj/project.pbxproj | head -20

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 3537


🏁 Script executed:

# Check if there are named vs unnamed build phases and their purpose
rg "PBXShellScriptBuildPhase" ios/RocketChatRN.xcodeproj/project.pbxproj -A 5 | rg "name = |shellScript = " | head -40

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 60


🏁 Script executed:

# Check for duplicate output paths to see if this is a duplicate/orphaned script
rg "Pods-defaults-NotificationService-checkManifestLockResult.txt" ios/RocketChatRN.xcodeproj/project.pbxproj

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 255


🏁 Script executed:

# Search for the build phases that contain these output paths and identify which targets they belong to
rg -B 20 "Pods-defaults-NotificationService-checkManifestLockResult.txt" ios/RocketChatRN.xcodeproj/project.pbxproj | rg "isa = PBXNativeTarget|isa = PBXAggregateTarget|buildPhases = \(|shellScript|name = "

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 1603


🏁 Script executed:

# More specifically, find the section at 1670-1687 and identify its parent target
sed -n '1600,1700p' ios/RocketChatRN.xcodeproj/project.pbxproj | rg -B 30 "407D3EDE3DABEE15D27BD87D" | head -40

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 2573


🏁 Script executed:

# Find which target this build phase is assigned to
rg "407D3EDE3DABEE15D27BD87D" ios/RocketChatRN.xcodeproj/project.pbxproj -B 50 | rg "isa = PBXNativeTarget|name = " | head -10

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 221


🏁 Script executed:

# Get the surrounding context to see which target contains this build phase
rg -n "407D3EDE3DABEE15D27BD87D" ios/RocketChatRN.xcodeproj/project.pbxproj

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 184


🏁 Script executed:

# Check which target's buildPhases includes line 1282
sed -n '1200,1350p' ios/RocketChatRN.xcodeproj/project.pbxproj | head -200

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 5664


🏁 Script executed:

# More targeted - look for the buildPhases array that includes this phase ID
sed -n '1270,1290p' ios/RocketChatRN.xcodeproj/project.pbxproj

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 1118


🏁 Script executed:

# Now check the NotificationService target's build phases
sed -n '3150,3250p' ios/RocketChatRN.xcodeproj/project.pbxproj | grep -A 30 "NotificationService"

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 60


🏁 Script executed:

# Find NotificationService target definition and its buildPhases
rg -A 30 "1EFEB5942493B6640072EDC0 /\* NotificationService \*/" ios/RocketChatRN.xcodeproj/project.pbxproj | head -50

Repository: RocketChat/Rocket.Chat.ReactNative

Length of output: 2231


Build script configuration has mismatched output path

The unnamed shell script at this location is assigned to the RocketChatRN target but outputs to the NotificationService manifest lock file:

  • Executes: Pods-defaults-RocketChatRN-frameworks.sh (correct for RocketChatRN)
  • Output path: Pods-defaults-NotificationService-checkManifestLockResult.txt (belongs to NotificationService target)

This breaks build phase dependency tracking. Regenerate the Xcode project configuration by running pod install --repo-update from the ios directory.

🤖 Prompt for AI Agents
In ios/RocketChatRN.xcodeproj/project.pbxproj around lines 1670 to 1687, the
ShellScript build phase for the RocketChatRN target runs the
Pods-defaults-RocketChatRN-frameworks.sh script but its outputPaths points to
Pods-defaults-NotificationService-checkManifestLockResult.txt; regenerate the
Xcode project by running pod install --repo-update from the ios directory to fix
target-specific entries, or manually update the build phase outputPaths to the
correct file (e.g. Pods-defaults-RocketChatRN-checkManifestLockResult.txt) so
the output matches the RocketChatRN target and rebuild to verify dependency
tracking is restored.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (1)

177-234: Critical: Remove duplicate code block in readRoom.

Lines 195-207 are duplicated at lines 209-221. This appears to be a copy-paste error that results in:

  • Cursor operations executed twice unnecessarily
  • The first cursor close (line 207) making subsequent cursor operations (line 209) fail or behave unexpectedly

Remove one of these duplicate blocks.

🔎 Proposed fix
             cursor.moveToFirst();
             int e2eKeyColumnIndex = cursor.getColumnIndex("e2e_key");
             int encryptedColumnIndex = cursor.getColumnIndex("encrypted");
             
             if (e2eKeyColumnIndex == -1) {
                 Log.e(TAG, "e2e_key column not found in subscriptions table");
                 cursor.close();
                 return null;
             }
             
             String e2eKey = cursor.getString(e2eKeyColumnIndex);
             Boolean encrypted = encryptedColumnIndex != -1 && cursor.getInt(encryptedColumnIndex) > 0;
             cursor.close();
-
-            cursor.moveToFirst();
-            int e2eKeyColumnIndex = cursor.getColumnIndex("e2e_key");
-            int encryptedColumnIndex = cursor.getColumnIndex("encrypted");
-            
-            if (e2eKeyColumnIndex == -1) {
-                Log.e(TAG, "e2e_key column not found in subscriptions table");
-                cursor.close();
-                return null;
-            }
-            
-            String e2eKey = cursor.getString(e2eKeyColumnIndex);
-            Boolean encrypted = encryptedColumnIndex != -1 && cursor.getInt(encryptedColumnIndex) > 0;
-            cursor.close();

             return new Room(e2eKey, encrypted);
🤖 Fix all issues with AI Agents
In
@android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java:
- Around line 265-271: The comment/code mismatch in Encryption.java: the code
uses context.getFilesDir().getParentFile() but the comments say "files
directory"; update the implementation to use context.getFilesDir() (assign
internalDir = context.getFilesDir()), keep fullDbName = name + ".db", and update
the inline comments to state "Use files directory (where WatermelonDB stores
files when appGroupPath is empty)" so comments and variables (fullDbName,
internalDir) consistently reflect the files directory path.
📜 Review details

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Jira integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between fd974f3 and 8249aaa.

📒 Files selected for processing (3)
  • android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java
  • android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java
  • android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java
💤 Files with no reviewable changes (1)
  • android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java
🧰 Additional context used
🧬 Code graph analysis (1)
android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (1)
ios/Shared/RocketChat/Database.swift (2)
  • getDatabasePath (32-36)
  • openDatabase (38-44)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (2)
  • GitHub Check: ESLint and Test / run-eslint-and-test
  • GitHub Check: format
🔇 Additional comments (7)
android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java (4)

50-63: LGTM! Thread-safe pending notification holder.

The PendingNotification holder class and CopyOnWriteArrayList provide a clean, thread-safe mechanism for queueing notifications that arrive before React Native initialization.


143-152: LGTM! Correct queueing for message-id-only notifications.

Message-id-only notifications require MMKV (which needs React context) for fetching tokens to make API calls. Queueing them until React is ready is the correct approach.


198-213: LGTM! Simplified E2E decryption flow.

E2E decryption now happens immediately using direct SQLiteDatabase access via Encryption.shared.decryptMessage(ejson), eliminating the previous async processor dependency. The error handling is appropriate.


88-109: MainApplication.getInstance() initialization is properly sequenced and safe.

The static instance is initialized at the very start of MainActivity.onCreate() (line 70), which executes before Firebase services can receive any messages. Android guarantees that Application.onCreate() completes before any service components are started, so early notifications will never encounter an uninitialized getInstance(). The queuing mechanism in setReactContext() (lines 88-109) correctly handles cases where notifications arrive before React is initialized, not before the singleton is set.

android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java (3)

420-458: LGTM! Simplified decryptMessage signature.

Removing the Context parameter and using readRoom(ejson) directly simplifies the API and aligns with the new direct SQLiteDatabase access pattern. The fallback logic for V1/V2 formats is preserved.


460-516: LGTM! Consistent encryptMessageContent signature change.

The Context parameter removal is consistent with decryptMessage and the overall refactoring. The encryption logic for V1/V2 formats is preserved correctly.


242-244: No action required — initialization order is safe.

The switch to MainApplication.getInstance() is sound. Android's framework guarantees that Application.onCreate() executes before any Service or broadcast receiver can deliver callbacks, and the singleton instance is set at line 70 in onCreate() — very early, before any other initialization. Additionally, CustomPushNotification already includes a pending notifications queue mechanism that handles the edge case of notifications arriving before React Native is fully initialized (see pendingNotifications and setReactContext() at lines 63–116).

Comment on lines +182 to +184
// TODO: It's getting lock issues
// Open database in read-only mode (safer for concurrent access with WatermelonDB)
db = SQLiteDatabase.openDatabase(dbPath, null, SQLiteDatabase.OPEN_READONLY);
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Resolve TODO: Address database lock issues before merging.

The TODO indicates known lock issues when accessing the SQLiteDatabase concurrently with WatermelonDB. This is a production code path for push notifications and must be resolved. Consider:

  • Using SQLiteDatabase.OPEN_READONLY | SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING to reduce lock contention
  • Implementing retry logic with exponential backoff
  • Adding timeout handling to prevent indefinite blocking

Do not merge production code with unresolved concurrency TODOs that could cause notification failures.

Would you like me to generate a more robust implementation with retry logic and proper lock handling?

Comment on lines +265 to +271
// WatermelonDB on Android: when appGroupPath is empty (Android), SQLiteAdapter stores files
// in the app's files directory, not the databases directory
// It appends ".db" internally, so the actual file is "{name}.db.db"
String fullDbName = name + ".db";
// Use files directory (where WatermelonDB stores files when appGroupPath is empty)
File internalDir = context.getFilesDir().getParentFile();
return new File(internalDir, fullDbName).getAbsolutePath();
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Resolve comment-code inconsistency from previous review.

This issue was flagged in a previous review but remains unresolved. The comments claim WatermelonDB uses the "files directory" (lines 266, 269), but the code uses context.getFilesDir().getParentFile() — the parent of the files directory.

Clarify and fix:

  • If databases are in /data/data/<package>/files/, use context.getFilesDir() and update code
  • If databases are in /data/data/<package>/ (parent), update all comments to state "parent app data directory" not "files directory"

Based on learnings from past review comments.

🤖 Recommended approach

Verify the actual WatermelonDB path by checking what appGroupPath is set to in your WatermelonDB initialization, then align code and comments to match reality. The iOS snippet shows databases in the group directory; Android should similarly document its actual storage location accurately.

🤖 Prompt for AI Agents
In
@android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java
around lines 265 - 271, The comment/code mismatch in Encryption.java: the code
uses context.getFilesDir().getParentFile() but the comments say "files
directory"; update the implementation to use context.getFilesDir() (assign
internalDir = context.getFilesDir()), keep fullDbName = name + ".db", and update
the inline comments to state "Use files directory (where WatermelonDB stores
files when appGroupPath is empty)" so comments and variables (fullDbName,
internalDir) consistently reflect the files directory path.

…yption class

- Added a static method to retrieve the MainApplication instance for easier access.
- Removed redundant code in the Encryption class related to cursor handling for e2e_key and encrypted columns.
@diegolmello diegolmello requested a deployment to experimental_ios_build January 6, 2026 18:15 — with GitHub Actions Waiting
@diegolmello diegolmello requested a deployment to official_android_build January 6, 2026 18:15 — with GitHub Actions Waiting
@diegolmello diegolmello temporarily deployed to experimental_android_build January 6, 2026 18:15 — with GitHub Actions Inactive
@diegolmello diegolmello closed this Jan 6, 2026
@diegolmello diegolmello deleted the fix.push-android-killed-app branch January 6, 2026 21:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants