From 34232a2462f644470bcd7b5a60f654aee521cb53 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 8 Jan 2026 16:45:17 -0300 Subject: [PATCH 1/3] Set version to 4.68.2 --- android/app/build.gradle | 2 +- ios/RocketChatRN.xcodeproj/project.pbxproj | 4 ++-- ios/RocketChatRN/Info.plist | 2 +- ios/ShareRocketChatRN/Info.plist | 2 +- package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/android/app/build.gradle b/android/app/build.gradle index f0f06675f4..4abd593074 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -90,7 +90,7 @@ android { minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion versionCode VERSIONCODE as Integer - versionName "4.69.0" + versionName "4.68.2" vectorDrawables.useSupportLibrary = true manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String] resValue "string", "rn_config_reader_custom_package", "chat.rocket.reactnative" diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index cca807529b..d4e1efe264 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -3006,7 +3006,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 4.69.0; + MARKETING_VERSION = 4.68.2; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3059,7 +3059,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 4.69.0; + MARKETING_VERSION = 4.68.2; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_RELEASE"; PRODUCT_BUNDLE_IDENTIFIER = chat.rocket.reactnative.NotificationService; diff --git a/ios/RocketChatRN/Info.plist b/ios/RocketChatRN/Info.plist index dbee5af49d..cf9e141f0c 100644 --- a/ios/RocketChatRN/Info.plist +++ b/ios/RocketChatRN/Info.plist @@ -28,7 +28,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 4.69.0 + 4.68.2 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/ShareRocketChatRN/Info.plist b/ios/ShareRocketChatRN/Info.plist index 322edff5df..a54e61a991 100644 --- a/ios/ShareRocketChatRN/Info.plist +++ b/ios/ShareRocketChatRN/Info.plist @@ -26,7 +26,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 4.69.0 + 4.68.2 CFBundleVersion 1 KeychainGroup diff --git a/package.json b/package.json index d1d72997c8..ad024aa328 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rocket-chat-reactnative", - "version": "4.69.0", + "version": "4.68.2", "private": true, "packageManager": "yarn@1.22.22", "scripts": { From 5cc10bac18b13111928d809b224a816d2dbb3558 Mon Sep 17 00:00:00 2001 From: Diego Mello Date: Thu, 8 Jan 2026 16:56:38 -0300 Subject: [PATCH 2/3] fix(Android): Push notification on killed apps and deep link --- .../chat/rocket/reactnative/MainActivity.kt | 56 +----- .../rocket/reactnative/MainApplication.kt | 13 +- .../notification/CustomPushNotification.java | 169 +++++------------- .../E2ENotificationProcessor.java | 160 ----------------- .../reactnative/notification/Ejson.java | 7 - .../reactnative/notification/Encryption.java | 97 ++++++++-- .../NativePushNotificationSpec.kt | 24 +++ .../notification/NotificationIntentHandler.kt | 153 ++++++++++++++++ .../notification/PushNotificationModule.kt | 78 ++++++++ .../PushNotificationTurboPackage.kt | 38 ++++ .../notification/VideoConfBroadcast.kt | 2 + .../native/NativePushNotificationAndroid.ts | 9 + app/lib/notifications/index.ts | 68 ++++++- app/lib/notifications/push.ts | 60 +++++++ app/sagas/state.js | 5 + 15 files changed, 571 insertions(+), 368 deletions(-) delete mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/NativePushNotificationSpec.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationModule.kt create mode 100644 android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationTurboPackage.kt create mode 100644 app/lib/native/NativePushNotificationAndroid.ts diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt b/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt index 502ae26784..e4ab65ceba 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainActivity.kt @@ -9,9 +9,7 @@ import android.os.Bundle import com.zoontek.rnbootsplash.RNBootSplash import android.content.Intent import android.content.res.Configuration -import chat.rocket.reactnative.notification.VideoConfModule -import chat.rocket.reactnative.notification.VideoConfNotification -import com.google.gson.GsonBuilder +import chat.rocket.reactnative.notification.NotificationIntentHandler class MainActivity : ReactActivity() { @@ -32,56 +30,16 @@ class MainActivity : ReactActivity() { RNBootSplash.init(this, R.style.BootTheme) super.onCreate(null) - // Handle video conf action from notification - intent?.let { handleVideoConfIntent(it) } + // Handle notification intents + intent?.let { NotificationIntentHandler.handleIntent(this, it) } } public override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - // Handle video conf action when activity is already running - handleVideoConfIntent(intent) - } - - private fun handleVideoConfIntent(intent: Intent) { - if (intent.getBooleanExtra("videoConfAction", false)) { - val notificationId = intent.getIntExtra("notificationId", 0) - val event = intent.getStringExtra("event") ?: return - val rid = intent.getStringExtra("rid") ?: "" - val callerId = intent.getStringExtra("callerId") ?: "" - val callerName = intent.getStringExtra("callerName") ?: "" - val host = intent.getStringExtra("host") ?: "" - val callId = intent.getStringExtra("callId") ?: "" - - android.util.Log.d("RocketChat.MainActivity", "Handling video conf intent - event: $event, rid: $rid, host: $host, callId: $callId") - - // Cancel the notification - if (notificationId != 0) { - VideoConfNotification.cancelById(this, notificationId) - } - - // Store action for JS to pick up - include all required fields - val data = mapOf( - "notificationType" to "videoconf", - "rid" to rid, - "event" to event, - "host" to host, - "callId" to callId, - "caller" to mapOf( - "_id" to callerId, - "name" to callerName - ) - ) - - val gson = GsonBuilder().create() - val jsonData = gson.toJson(data) - - android.util.Log.d("RocketChat.MainActivity", "Storing video conf action: $jsonData") - - VideoConfModule.storePendingAction(this, jsonData) - - // Clear the video conf flag to prevent re-processing - intent.removeExtra("videoConfAction") - } + setIntent(intent) + + // Handle notification intents when activity is already running + NotificationIntentHandler.handleIntent(this, intent) } override fun invokeDefaultOnBackPressed() { diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 89622d5d0a..5ac2e25f48 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -5,11 +5,8 @@ import android.content.res.Configuration import com.facebook.react.PackageList import com.facebook.react.ReactApplication import com.facebook.react.ReactHost -import com.facebook.react.ReactInstanceEventListener import com.facebook.react.ReactNativeHost import com.facebook.react.ReactPackage -import com.facebook.react.bridge.ReactContext -import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.defaults.DefaultNewArchitectureEntryPoint.load import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost import com.facebook.react.defaults.DefaultReactNativeHost @@ -21,8 +18,8 @@ import expo.modules.ApplicationLifecycleDispatcher import chat.rocket.reactnative.networking.SSLPinningTurboPackage; import chat.rocket.reactnative.storage.MMKVKeyManager; import chat.rocket.reactnative.storage.SecureStoragePackage; -import chat.rocket.reactnative.notification.CustomPushNotification; import chat.rocket.reactnative.notification.VideoConfTurboPackage +import chat.rocket.reactnative.notification.PushNotificationTurboPackage /** * Main Application class. @@ -45,6 +42,7 @@ open class MainApplication : Application(), ReactApplication { add(SSLPinningTurboPackage()) add(WatermelonDBJSIPackage()) add(VideoConfTurboPackage()) + add(PushNotificationTurboPackage()) add(SecureStoragePackage()) } @@ -71,13 +69,6 @@ open class MainApplication : Application(), ReactApplication { // Load the native entry point for the New Architecture load() - // Register listener to set React context when initialized - reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener { - override fun onReactContextInitialized(context: ReactContext) { - CustomPushNotification.setReactContext(context as ReactApplicationContext) - } - }) - ApplicationLifecycleDispatcher.onApplicationCreate(this) } diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java index cad28734ea..0a96cf5b1c 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/CustomPushNotification.java @@ -21,7 +21,6 @@ import com.bumptech.glide.Glide; import com.bumptech.glide.load.resource.bitmap.RoundedCorners; import com.bumptech.glide.request.RequestOptions; -import com.facebook.react.bridge.ReactApplicationContext; import com.google.gson.Gson; import java.util.ArrayList; @@ -49,7 +48,6 @@ public class CustomPushNotification { private static final boolean ENABLE_VERBOSE_LOGS = BuildConfig.DEBUG; // Shared state - public static volatile ReactApplicationContext reactApplicationContext; private static final Gson gson = new Gson(); private static final Map> notificationMessages = new ConcurrentHashMap<>(); @@ -61,7 +59,7 @@ public class CustomPushNotification { // Instance fields private final Context mContext; - private Bundle mBundle; + private volatile Bundle mBundle; private final NotificationManager notificationManager; public CustomPushNotification(Context context, Bundle bundle) { @@ -73,24 +71,9 @@ public CustomPushNotification(Context context, Bundle bundle) { createNotificationChannel(); } - /** - * Sets the React application context when React Native initializes. - * Called from MainApplication when React context is ready. - */ - public static void setReactContext(ReactApplicationContext context) { - reactApplicationContext = context; - } - public static void clearMessages(int notId) { notificationMessages.remove(Integer.toString(notId)); } - - /** - * Check if React Native is initialized - */ - private boolean isReactInitialized() { - return reactApplicationContext != null; - } public void onReceived() { String notId = mBundle.getString("notId"); @@ -107,58 +90,10 @@ public void onReceived() { return; } - // Check if React is ready - needed for MMKV access (avatars, encryption, message-id-only) - if (!isReactInitialized()) { - Log.w(TAG, "React not initialized yet, waiting before processing notification..."); - - // Wait for React to initialize with timeout - new Thread(() -> { - int attempts = 0; - int maxAttempts = 50; // 5 seconds total (50 * 100ms) - - while (!isReactInitialized() && attempts < maxAttempts) { - try { - Thread.sleep(100); // Wait 100ms - attempts++; - - if (attempts % 10 == 0 && ENABLE_VERBOSE_LOGS) { - Log.d(TAG, "Still waiting for React initialization... (" + (attempts * 100) + "ms elapsed)"); - } - } catch (InterruptedException e) { - Log.e(TAG, "Wait interrupted", e); - Thread.currentThread().interrupt(); - return; - } - } - - if (isReactInitialized()) { - Log.i(TAG, "React initialized after " + (attempts * 100) + "ms, proceeding with notification"); - try { - handleNotification(); - } catch (Exception e) { - Log.e(TAG, "Failed to process notification after React initialization", e); - } - } else { - Log.e(TAG, "Timeout waiting for React initialization after " + (maxAttempts * 100) + "ms, processing without MMKV"); - try { - handleNotification(); - } catch (Exception e) { - Log.e(TAG, "Failed to process notification without React context", e); - } - } - }).start(); - - return; // Exit early, notification will be processed in the thread - } - - if (ENABLE_VERBOSE_LOGS) { - Log.d(TAG, "React already initialized, proceeding with notification"); - } - try { handleNotification(); } catch (Exception e) { - Log.e(TAG, "Failed to process notification on main thread", e); + Log.e(TAG, "Failed to process notification", e); } } @@ -201,26 +136,27 @@ public void call(@Nullable Bundle bundle) { } private void processNotification() { - Ejson loadedEjson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class); - String notId = mBundle.getString("notId", "1"); + final Bundle bundle = mBundle; + Ejson loadedEjson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); + String notId = bundle.getString("notId", "1"); if (ENABLE_VERBOSE_LOGS) { Log.d(TAG, "[processNotification] notId=" + notId); - Log.d(TAG, "[processNotification] bundle.notificationLoaded=" + mBundle.getBoolean("notificationLoaded", false)); - Log.d(TAG, "[processNotification] bundle.title=" + (mBundle.getString("title") != null ? "[present]" : "[null]")); - Log.d(TAG, "[processNotification] bundle.message length=" + (mBundle.getString("message") != null ? mBundle.getString("message").length() : 0)); + Log.d(TAG, "[processNotification] bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); + Log.d(TAG, "[processNotification] bundle.title=" + (bundle.getString("title") != null ? "[present]" : "[null]")); + Log.d(TAG, "[processNotification] bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0)); Log.d(TAG, "[processNotification] loadedEjson.notificationType=" + (loadedEjson != null ? loadedEjson.notificationType : "null")); Log.d(TAG, "[processNotification] loadedEjson.sender=" + (loadedEjson != null && loadedEjson.sender != null ? loadedEjson.sender.username : "null")); } // Handle E2E encrypted notifications if (isE2ENotification(loadedEjson)) { - handleE2ENotification(mBundle, loadedEjson, notId); - return; // E2E processor will handle showing the notification + handleE2ENotification(bundle, loadedEjson, notId); + return; } - // Handle regular (non-E2E) notifications - showNotification(mBundle, loadedEjson, notId); + // Handle regular notifications + showNotification(bundle, loadedEjson, notId); } /** @@ -231,54 +167,36 @@ private boolean isE2ENotification(Ejson ejson) { } /** - * Handles E2E encrypted notifications by delegating to the async processor. + * Handles E2E encrypted notifications */ private void handleE2ENotification(Bundle bundle, Ejson ejson, String notId) { - // Check if React context is immediately available - if (reactApplicationContext != null) { - // Fast path: decrypt immediately - String decrypted = Encryption.shared.decryptMessage(ejson, reactApplicationContext); - - if (decrypted != null) { - bundle.putString("message", decrypted); + if (Encryption.shared == null) { + Log.e(TAG, "Encryption singleton is null, cannot decrypt E2E notification"); + bundle.putString("message", "Encrypted message"); + synchronized(this) { mBundle = bundle; - ejson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); - showNotification(bundle, ejson, notId); - } else { - Log.w(TAG, "E2E decryption failed for notification"); } + showNotification(bundle, ejson, notId); return; } - // Slow path: wait for React context asynchronously - Log.i(TAG, "Waiting for React context to decrypt E2E notification"); + String decrypted = Encryption.shared.decryptMessage(ejson, mContext); - E2ENotificationProcessor processor = new E2ENotificationProcessor( - // Context provider - () -> reactApplicationContext, - - // Callback - new E2ENotificationProcessor.NotificationCallback() { - @Override - public void onDecryptionComplete(Bundle decryptedBundle, Ejson decryptedEjson, String notificationId) { - mBundle = decryptedBundle; - Ejson finalEjson = safeFromJson(decryptedBundle.getString("ejson", "{}"), Ejson.class); - showNotification(decryptedBundle, finalEjson, notificationId); - } - - @Override - public void onDecryptionFailed(Bundle originalBundle, Ejson originalEjson, String notificationId) { - Log.w(TAG, "E2E decryption failed for notification"); - } - - @Override - public void onTimeout(Bundle originalBundle, Ejson originalEjson, String notificationId) { - Log.w(TAG, "Timeout waiting for React context for E2E notification"); - } + if (decrypted != null) { + bundle.putString("message", decrypted); + synchronized(this) { + mBundle = bundle; } - ); - - processor.processAsync(bundle, ejson, notId); + showNotification(bundle, ejson, notId); + } else { + Log.w(TAG, "E2E decryption failed for notification, showing fallback notification"); + // Show fallback notification so user knows a message arrived + bundle.putString("message", "Encrypted message"); + synchronized(this) { + mBundle = bundle; + } + showNotification(bundle, ejson, notId); + } } /** @@ -305,6 +223,10 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) { } bundle.putString("avatarUri", avatarUri); + synchronized(this) { + mBundle = bundle; + } + // Handle special notification types if (ejson != null && "videoconf".equals(ejson.notificationType)) { handleVideoConfNotification(bundle, ejson); @@ -373,11 +295,12 @@ private void createNotificationChannel() { } private Notification.Builder buildNotification(int notificationId) { + final Bundle bundle = mBundle; String notId = Integer.toString(notificationId); - String title = mBundle.getString("title"); - String message = mBundle.getString("message"); - Boolean notificationLoaded = mBundle.getBoolean("notificationLoaded", false); - Ejson ejson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class); + String title = bundle.getString("title"); + String message = bundle.getString("message"); + Boolean notificationLoaded = bundle.getBoolean("notificationLoaded", false); + Ejson ejson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); if (ENABLE_VERBOSE_LOGS) { Log.d(TAG, "[buildNotification] notId=" + notId); @@ -389,7 +312,7 @@ private Notification.Builder buildNotification(int notificationId) { // Create pending intent to open the app Intent intent = new Intent(mContext, MainActivity.class); intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP); - intent.putExtras(mBundle); + intent.putExtras(bundle); PendingIntent pendingIntent; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { @@ -414,14 +337,14 @@ private Notification.Builder buildNotification(int notificationId) { .setAutoCancel(true); notificationColor(notification); - notificationIcons(notification, mBundle); + notificationIcons(notification, bundle); notificationDismiss(notification, notificationId); // if notificationType is null (RC < 3.5) or notificationType is different of message-id-only or notification was loaded successfully if (ejson == null || ejson.notificationType == null || !ejson.notificationType.equals("message-id-only") || notificationLoaded) { Log.i(TAG, "[buildNotification] ✅ Rendering FULL notification style"); - notificationStyle(notification, notificationId, mBundle); - notificationReply(notification, notificationId, mBundle); + notificationStyle(notification, notificationId, bundle); + notificationReply(notification, notificationId, bundle); } else { Log.w(TAG, "[buildNotification] ⚠️ Rendering FALLBACK notification"); // Cancel previous fallback notifications from same server diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java b/android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java deleted file mode 100644 index 532464bf25..0000000000 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/E2ENotificationProcessor.java +++ /dev/null @@ -1,160 +0,0 @@ -package chat.rocket.reactnative.notification; - -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.Log; - -import com.facebook.react.bridge.ReactApplicationContext; - -import java.util.Date; -import java.util.concurrent.atomic.AtomicInteger; - -/** - * Handles asynchronous processing of End-to-End encrypted push notifications. - * - * When an E2E notification arrives before React Native is initialized, this processor - * waits for the React context to become available, decrypts the message, and then - * triggers the notification display. - * - * Thread-safe and handles timeout scenarios gracefully. - */ -public class E2ENotificationProcessor { - private static final String TAG = "RocketChat.E2E.Async"; - - // Configuration constants - private static final int POLLING_INTERVAL_MS = 100; // Check every 100ms - private static final int MAX_WAIT_TIME_MS = 3000; // Wait up to 3 seconds - private static final int MAX_ATTEMPTS = MAX_WAIT_TIME_MS / POLLING_INTERVAL_MS; - - private final Handler mainHandler; - private final ReactContextProvider contextProvider; - private final NotificationCallback callback; - - /** - * Interface to provide React context. - */ - public interface ReactContextProvider { - ReactApplicationContext getReactContext(); - } - - /** - * Callback interface for notification processing results. - */ - public interface NotificationCallback { - void onDecryptionComplete(Bundle decryptedBundle, Ejson ejson, String notId); - void onDecryptionFailed(Bundle originalBundle, Ejson ejson, String notId); - void onTimeout(Bundle originalBundle, Ejson ejson, String notId); - } - - /** - * Creates a new E2E notification processor. - * - * @param contextProvider Provider for React context - * @param callback Callback for processing results - */ - public E2ENotificationProcessor(ReactContextProvider contextProvider, NotificationCallback callback) { - this.mainHandler = new Handler(Looper.getMainLooper()); - this.contextProvider = contextProvider; - this.callback = callback; - } - - /** - * Processes an E2E encrypted notification asynchronously. - * - * This method returns immediately. The notification will be decrypted and shown - * once React context becomes available, or after a timeout. - * - * @param bundle The notification bundle - * @param ejson The parsed notification data - * @param notId The notification ID - */ - public void processAsync(final Bundle bundle, final Ejson ejson, final String notId) { - final AtomicInteger attempts = new AtomicInteger(0); - - final Runnable pollForContextRunnable = new Runnable() { - @Override - public void run() { - int currentAttempt = attempts.incrementAndGet(); - ReactApplicationContext reactContext = contextProvider.getReactContext(); - - if (reactContext != null) { - // Context is available - decrypt in background thread - Log.i(TAG, "React context available after " + currentAttempt + " attempts"); - decryptAndNotify(reactContext, bundle, ejson, notId); - - } else if (currentAttempt < MAX_ATTEMPTS) { - // Context not ready - poll again - mainHandler.postDelayed(this, POLLING_INTERVAL_MS); - - } else { - // Timeout - give up - Log.w(TAG, "Timeout waiting for React context after " + MAX_WAIT_TIME_MS + "ms"); - handleTimeout(bundle, ejson, notId); - } - } - }; - - // Start polling - mainHandler.post(pollForContextRunnable); - } - - /** - * Decrypts the message in a background thread and invokes the callback on the main thread. - */ - private void decryptAndNotify(final ReactApplicationContext reactContext, - final Bundle bundle, - final Ejson ejson, - final String notId) { - // Decrypt in background thread to avoid blocking - new Thread(() -> { - try { - String decrypted = Encryption.shared.decryptMessage(ejson, reactContext); - - if (decrypted != null) { - bundle.putString("message", decrypted); - - // Call directly on background thread - notification building needs background thread for image loading - try { - callback.onDecryptionComplete(bundle, ejson, notId); - } catch (Exception e) { - Log.e(TAG, "Error in decryption callback", e); - } - - } else { - Log.w(TAG, "Decryption returned null - failed to decrypt"); - handleDecryptionFailure(bundle, ejson, notId); - } - - } catch (Exception e) { - Log.e(TAG, "Exception during decryption", e); - handleDecryptionFailure(bundle, ejson, notId); - } - }, "E2E-Decrypt-" + notId).start(); - } - - /** - * Handles decryption failure by invoking the callback on the current thread. - */ - private void handleDecryptionFailure(final Bundle bundle, final Ejson ejson, final String notId) { - try { - callback.onDecryptionFailed(bundle, ejson, notId); - } catch (Exception e) { - Log.e(TAG, "Error in failure callback", e); - } - } - - /** - * Handles timeout by invoking the callback on the main thread. - */ - private void handleTimeout(final Bundle bundle, final Ejson ejson, final String notId) { - mainHandler.post(() -> { - try { - callback.onTimeout(bundle, ejson, notId); - } catch (Exception e) { - Log.e(TAG, "Error in timeout callback", e); - } - }); - } -} - diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java index 036d03f24f..ffb403e342 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Ejson.java @@ -2,7 +2,6 @@ import android.util.Log; -import com.facebook.react.bridge.Callback; import com.tencent.mmkv.MMKV; import java.math.BigInteger; @@ -10,12 +9,6 @@ import chat.rocket.reactnative.BuildConfig; import chat.rocket.reactnative.storage.MMKVKeyManager; -class RNCallback implements Callback { - public void invoke(Object... args) { - - } -} - class Utils { static public String toHex(String arg) { try { diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java b/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java index 8eafd8d1ff..f638410061 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/Encryption.java @@ -188,14 +188,23 @@ public Room readRoom(final Ejson ejson, Context context) { } cursor.moveToFirst(); - String e2eKey = cursor.getString(cursor.getColumnIndex("e2e_key")); - Boolean encrypted = cursor.getInt(cursor.getColumnIndex("encrypted")) > 0; + 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); } catch (Exception e) { - Log.e("[ENCRYPTION]", "Error reading room", e); + Log.e(TAG, "Error reading room", e); return null; } finally { @@ -236,7 +245,31 @@ public String readUserKey(final Ejson ejson) throws Exception { return null; } - PrivateKey privKey = gson.fromJson(privateKey, PrivateKey.class); + PrivateKey privKey; + try { + // First, try parsing as direct JSON object + privKey = gson.fromJson(privateKey, PrivateKey.class); + } catch (com.google.gson.JsonSyntaxException e) { + // If that fails, it might be a JSON-encoded string (double-encoded) + // Try parsing as a string first, then parse that string as JSON + try { + String decoded = gson.fromJson(privateKey, String.class); + privKey = gson.fromJson(decoded, PrivateKey.class); + } catch (Exception e2) { + Log.e(TAG, "Failed to parse private key", e2); + throw new Exception("Failed to parse private key: " + e2.getMessage(), e2); + } + } + + if (privKey == null) { + return null; + } + + // Validate that required fields are present + if (privKey.n == null || privKey.e == null || privKey.d == null) { + Log.e(TAG, "PrivateKey missing required fields (n, e, or d)"); + return null; + } WritableMap jwk = Arguments.createMap(); jwk.putString("n", privKey.n); @@ -252,9 +285,19 @@ public String readUserKey(final Ejson ejson) throws Exception { } public RoomKeyResult decryptRoomKey(final String e2eKey, final Ejson ejson) throws Exception { + if (e2eKey == null || e2eKey.isEmpty()) { + return null; + } + // Parse using prefixed base64 - PrefixedData parsed = decodePrefixedBase64(e2eKey); - keyId = parsed.prefix; + PrefixedData parsed; + try { + parsed = decodePrefixedBase64(e2eKey); + keyId = parsed.prefix; + } catch (Exception e) { + Log.e(TAG, "Failed to decode prefixed base64", e); + throw e; + } // Decrypt the session key String userKey = readUserKey(ejson); @@ -263,22 +306,54 @@ public RoomKeyResult decryptRoomKey(final String e2eKey, final Ejson ejson) thro } String base64EncryptedData = Base64.encodeToString(parsed.data, Base64.NO_WRAP); - String decrypted = RSACrypto.INSTANCE.decrypt(base64EncryptedData, userKey); + String decrypted; + try { + decrypted = RSACrypto.INSTANCE.decrypt(base64EncryptedData, userKey); + if (decrypted == null) { + return null; + } + } catch (Exception e) { + Log.e(TAG, "RSA decryption failed", e); + throw e; + } // Parse sessionKey to determine v1 vs v2 from "alg" field - JsonObject sessionKey = gson.fromJson(decrypted, JsonObject.class); + JsonObject sessionKey; + try { + sessionKey = gson.fromJson(decrypted, JsonObject.class); + if (sessionKey == null) { + return null; + } + } catch (com.google.gson.JsonSyntaxException e) { + Log.e(TAG, "Failed to parse decrypted session key as JSON", e); + throw new Exception("Failed to parse decrypted session key as JSON: " + e.getMessage(), e); + } + + if (!sessionKey.has("k")) { + return null; + } + String k = sessionKey.get("k").getAsString(); - byte[] decoded = Base64.decode(k, Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE); + byte[] decoded; + try { + decoded = Base64.decode(k, Base64.NO_PADDING | Base64.NO_WRAP | Base64.URL_SAFE); + } catch (Exception e) { + Log.e(TAG, "Failed to decode 'k' from base64", e); + throw e; + } + String decryptedKey = CryptoUtils.INSTANCE.bytesToHex(decoded); // Determine format from "alg" field + String algorithm; if (sessionKey.has("alg") && "A256GCM".equals(sessionKey.get("alg").getAsString())) { algorithm = "rc.v2.aes-sha2"; - return new RoomKeyResult(decryptedKey, "rc.v2.aes-sha2"); } else { algorithm = "rc.v1.aes-sha2"; - return new RoomKeyResult(decryptedKey, "rc.v1.aes-sha2"); } + this.algorithm = algorithm; + + return new RoomKeyResult(decryptedKey, algorithm); } private String decryptContent(Ejson.Content content, String e2eKey) throws Exception { diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NativePushNotificationSpec.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/NativePushNotificationSpec.kt new file mode 100644 index 0000000000..488a51687a --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NativePushNotificationSpec.kt @@ -0,0 +1,24 @@ +package chat.rocket.reactnative.notification + +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.turbomodule.core.interfaces.TurboModule + +abstract class NativePushNotificationSpec(reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext), TurboModule { + + companion object { + const val NAME = "PushNotificationModule" + } + + override fun getName(): String = NAME + + @ReactMethod + abstract fun getPendingNotification(promise: Promise) + + @ReactMethod + abstract fun clearPendingNotification() +} + diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt new file mode 100644 index 0000000000..322677141c --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationIntentHandler.kt @@ -0,0 +1,153 @@ +package chat.rocket.reactnative.notification + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.util.Log +import com.google.gson.GsonBuilder + +/** + * Handles notification Intent processing from MainActivity. + * Extracts notification data from Intents and stores it for React Native to process. + */ +class NotificationIntentHandler { + + companion object { + private const val TAG = "RocketChat.NotificationIntentHandler" + + /** + * Handles a notification Intent from MainActivity. + * Processes both video conf and regular notification intents. + */ + @JvmStatic + fun handleIntent(context: Context, intent: Intent) { + // Handle video conf action first + if (handleVideoConfIntent(context, intent)) { + return + } + + // Handle regular notification tap + handleNotificationIntent(context, intent) + } + + /** + * Handles video conference notification Intent. + * @return true if this was a video conf intent, false otherwise + */ + @JvmStatic + private fun handleVideoConfIntent(context: Context, intent: Intent): Boolean { + if (!intent.getBooleanExtra("videoConfAction", false)) { + return false + } + + val notificationId = intent.getIntExtra("notificationId", 0) + val event = intent.getStringExtra("event") ?: return true + + val rid = intent.getStringExtra("rid") ?: "" + val callerId = intent.getStringExtra("callerId") ?: "" + val callerName = intent.getStringExtra("callerName") ?: "" + val host = intent.getStringExtra("host") ?: "" + val callId = intent.getStringExtra("callId") ?: "" + + // Cancel the notification + if (notificationId != 0) { + VideoConfNotification.cancelById(context, notificationId) + } + + // Store action for JS to pick up - include all required fields + val data = mapOf( + "notificationType" to "videoconf", + "rid" to rid, + "event" to event, + "host" to host, + "callId" to callId, + "caller" to mapOf( + "_id" to callerId, + "name" to callerName + ) + ) + + val gson = GsonBuilder().create() + val jsonData = gson.toJson(data) + + VideoConfModule.storePendingAction(context, jsonData) + + // Clear the video conf flag to prevent re-processing + intent.removeExtra("videoConfAction") + + return true + } + + /** + * Handles regular notification tap (non-video conf). + * Extracts Intent extras and stores them for React Native to pick up. + */ + @JvmStatic + private fun handleNotificationIntent(context: Context, intent: Intent) { + val extras = intent.extras ?: return + + // Check if this Intent has notification data (ejson) + val ejson = extras.getString("ejson") + if (ejson.isNullOrEmpty()) { + return + } + + try { + // Extract all notification data from Intent extras + // Only include serializable types to avoid JSON serialization errors + val notificationData = mutableMapOf() + + // Copy all extras to the notification data map, filtering out non-serializable types + extras.keySet().forEach { key -> + try { + when (val value = extras.get(key)) { + is String -> notificationData[key] = value + is Int -> notificationData[key] = value + is Boolean -> notificationData[key] = value + is Long -> notificationData[key] = value + is Float -> notificationData[key] = value + is Double -> notificationData[key] = value + is Byte -> notificationData[key] = value + is Char -> notificationData[key] = value + is Short -> notificationData[key] = value + // Skip complex types that can't be serialized (Bundle, Parcelable, etc.) + is Bundle -> { + // Skip Bundle objects - they're not JSON serializable + Log.w(TAG, "Skipping Bundle extra: $key") + } + null -> { + // Skip null values + } + else -> { + // For other types, try to convert to String only if it's a simple type + // Skip complex objects that might not serialize properly + val stringValue = value.toString() + // Only include if it's a reasonable string representation (not object reference) + if (!stringValue.startsWith("android.") && !stringValue.contains("@")) { + notificationData[key] = stringValue + } else { + Log.w(TAG, "Skipping non-serializable extra: $key (type: ${value.javaClass.simpleName})") + } + } + } + } catch (e: Exception) { + Log.w(TAG, "Error processing extra $key: ${e.message}") + } + } + + // Convert to JSON and store for React Native + val gson = GsonBuilder().create() + val jsonData = gson.toJson(notificationData) + + // Store notification data with error handling + try { + PushNotificationModule.storePendingNotification(context, jsonData) + } catch (e: Exception) { + Log.e(TAG, "Failed to store pending notification: ${e.message}", e) + } + } catch (e: Exception) { + Log.e(TAG, "Error handling notification intent: ${e.message}", e) + } + } + } +} diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationModule.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationModule.kt new file mode 100644 index 0000000000..543558c1a8 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationModule.kt @@ -0,0 +1,78 @@ +package chat.rocket.reactnative.notification + +import android.content.Context +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod + +/** + * Native module to expose push notification Intent data to JavaScript. + * Used to retrieve pending notification data when the app opens from a notification tap. + */ +class PushNotificationModule(reactContext: ReactApplicationContext) : NativePushNotificationSpec(reactContext) { + + companion object { + private const val PREFS_NAME = "RocketChatPrefs" + private const val KEY_PENDING_NOTIFICATION = "pendingNotification" + + /** + * Stores notification Intent data from a notification tap. + * Called from MainActivity when receiving a notification Intent. + * @throws Exception if storage fails + */ + @JvmStatic + fun storePendingNotification(context: Context, notificationJson: String) { + try { + val success = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .putString(KEY_PENDING_NOTIFICATION, notificationJson) + .commit() // Use commit() instead of apply() to ensure synchronous write and catch errors + + if (!success) { + android.util.Log.e("RocketChat.PushNotificationModule", "Failed to store pending notification: commit() returned false") + throw RuntimeException("Failed to store pending notification") + } + } catch (e: Exception) { + android.util.Log.e("RocketChat.PushNotificationModule", "Error storing pending notification: ${e.message}", e) + throw e + } + } + } + + /** + * Gets any pending notification data from a notification tap. + * Returns null if no pending notification. + */ + @ReactMethod + override fun getPendingNotification(promise: Promise) { + try { + val prefs = reactApplicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + val notification = prefs.getString(KEY_PENDING_NOTIFICATION, null) + + // Clear the notification after reading + notification?.let { + prefs.edit().remove(KEY_PENDING_NOTIFICATION).apply() + } + + promise.resolve(notification) + } catch (e: Exception) { + promise.reject("ERROR", e.message) + } + } + + /** + * Clears any pending notification data. + */ + @ReactMethod + override fun clearPendingNotification() { + try { + reactApplicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) + .edit() + .remove(KEY_PENDING_NOTIFICATION) + .apply() + } catch (e: Exception) { + // Ignore errors + } + } +} + diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationTurboPackage.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationTurboPackage.kt new file mode 100644 index 0000000000..2c30630f3b --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationTurboPackage.kt @@ -0,0 +1,38 @@ +package chat.rocket.reactnative.notification + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider + +/** + * React Native TurboModule package for push notification module. + */ +class PushNotificationTurboPackage : TurboReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return if (name == NativePushNotificationSpec.NAME) { + PushNotificationModule(reactContext) + } else { + null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + mapOf( + NativePushNotificationSpec.NAME to ReactModuleInfo( + NativePushNotificationSpec.NAME, + NativePushNotificationSpec.NAME, + false, // canOverrideExistingModule + false, // needsEagerInit + false, // hasConstants + false, // isCxxModule + true // isTurboModule + ) + ) + } + } +} + diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt index d5aa014fe8..94f2c9f768 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/VideoConfBroadcast.kt @@ -48,6 +48,8 @@ class VideoConfBroadcast : BroadcastReceiver() { "notificationType" to (extras.getString("notificationType") ?: "videoconf"), "rid" to (extras.getString("rid") ?: ""), "event" to event, + "host" to (extras.getString("host") ?: ""), + "callId" to (extras.getString("callId") ?: ""), "caller" to mapOf( "_id" to (extras.getString("callerId") ?: ""), "name" to (extras.getString("callerName") ?: "") diff --git a/app/lib/native/NativePushNotificationAndroid.ts b/app/lib/native/NativePushNotificationAndroid.ts new file mode 100644 index 0000000000..0c764bebb7 --- /dev/null +++ b/app/lib/native/NativePushNotificationAndroid.ts @@ -0,0 +1,9 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + getPendingNotification(): Promise; + clearPendingNotification(): void; +} + +export default TurboModuleRegistry.get('PushNotificationModule'); diff --git a/app/lib/notifications/index.ts b/app/lib/notifications/index.ts index 65adab40d6..15bff1ac5e 100644 --- a/app/lib/notifications/index.ts +++ b/app/lib/notifications/index.ts @@ -1,4 +1,5 @@ import EJSON from 'ejson'; +import { Platform } from 'react-native'; import { appInit } from '../../actions/app'; import { deepLinkingClickCallPush, deepLinkingOpen } from '../../actions/deepLinking'; @@ -21,9 +22,15 @@ export const onNotification = (push: INotification): void => { // Handle video conf notification actions (Accept/Decline buttons) if (identifier === 'ACCEPT_ACTION' || identifier === 'DECLINE_ACTION') { if (push?.payload?.ejson) { - const notification = EJSON.parse(push.payload.ejson); - store.dispatch(deepLinkingClickCallPush({ ...notification, event: identifier === 'ACCEPT_ACTION' ? 'accept' : 'decline' })); - return; + try { + const notification = EJSON.parse(push.payload.ejson); + store.dispatch( + deepLinkingClickCallPush({ ...notification, event: identifier === 'ACCEPT_ACTION' ? 'accept' : 'decline' }) + ); + return; + } catch (e) { + console.warn('[notifications/index.ts] Failed to parse video conf notification:', e); + } } } @@ -38,6 +45,10 @@ export const onNotification = (push: INotification): void => { } // Handle regular message notifications + if (!notification?.rid || !notification?.type || !notification?.host) { + store.dispatch(appInit()); + return; + } const { rid, name, sender, type, host, messageId }: IEjson = notification; const types: Record = { c: 'channel', @@ -45,9 +56,11 @@ export const onNotification = (push: INotification): void => { p: 'group', l: 'channels' }; - let roomName = type === SubscriptionType.DIRECT ? sender.username : name; - if (type === SubscriptionType.OMNICHANNEL) { - roomName = sender.name; + let roomName = name; + if (type === SubscriptionType.DIRECT) { + roomName = sender?.username ?? name; + } else if (type === SubscriptionType.OMNICHANNEL) { + roomName = sender?.name ?? name; } const params = { @@ -59,7 +72,7 @@ export const onNotification = (push: INotification): void => { store.dispatch(deepLinkingOpen(params)); return; } catch (e) { - console.warn(e); + console.warn('[notifications/index.ts] Failed to parse ejson:', e); } } store.dispatch(appInit()); @@ -77,3 +90,44 @@ export const initializePushNotifications = async (): Promise => { + if (Platform.OS === 'android') { + try { + const NativePushNotificationModule = require('../native/NativePushNotificationAndroid').default; + if (NativePushNotificationModule) { + const pendingNotification = await NativePushNotificationModule.getPendingNotification(); + if (pendingNotification) { + try { + const notificationData = JSON.parse(pendingNotification); + const notification: INotification = { + payload: { + message: notificationData.message || '', + style: notificationData.style || '', + ejson: notificationData.ejson || '', + collapse_key: notificationData.collapse_key || '', + notId: notificationData.notId || '', + msgcnt: notificationData.msgcnt || '', + title: notificationData.title || '', + from: notificationData.from || '', + image: notificationData.image || '', + soundname: notificationData.soundname || '', + action: notificationData.action + }, + identifier: notificationData.notId || '' + }; + onNotification(notification); + } catch (e) { + console.warn('[notifications/index.ts] Failed to parse pending notification:', e); + } + } + } + } catch (e) { + console.warn('[notifications/index.ts] Error checking pending notification:', e); + } + } +}; diff --git a/app/lib/notifications/push.ts b/app/lib/notifications/push.ts index c5725ec6f4..beae1c10db 100644 --- a/app/lib/notifications/push.ts +++ b/app/lib/notifications/push.ts @@ -7,6 +7,7 @@ import { isIOS } from '../methods/helpers'; import { store as reduxStore } from '../store/auxStore'; import { registerPushToken } from '../services/restApi'; import I18n from '../../i18n'; +import NativePushNotificationModule from '../native/NativePushNotificationAndroid'; export let deviceToken = ''; @@ -214,6 +215,65 @@ export const pushNotificationConfigure = (onNotification: (notification: INotifi }); // Get initial notification (app was opened by tapping a notification) + // First check native module for stored notification data (Android - when notification was created natively) + if (Platform.OS === 'android' && NativePushNotificationModule) { + return NativePushNotificationModule.getPendingNotification() + .then(pendingNotification => { + if (pendingNotification) { + try { + // Parse the stored notification data + const notificationData = JSON.parse(pendingNotification); + + // Transform to INotification format + const transformed: INotification = { + payload: { + message: notificationData.message || '', + style: notificationData.style || '', + ejson: notificationData.ejson || '', + collapse_key: notificationData.collapse_key || '', + notId: notificationData.notId || '', + msgcnt: notificationData.msgcnt || '', + title: notificationData.title || '', + from: notificationData.from || '', + image: notificationData.image || '', + soundname: notificationData.soundname || '', + action: notificationData.action + }, + identifier: notificationData.notId || '' + }; + + return transformed; + } catch (parseError) { + console.error('[push.ts] Error parsing notification data:', parseError); + return null; + } + } + return null; + }) + .catch(e => { + console.error('[push.ts] Error getting pending notification from native module:', e); + return null; + }) + .then(nativeNotification => { + if (nativeNotification) { + return nativeNotification; + } + + // Fallback to expo-notifications (for iOS or if native module doesn't have data) + const lastResponse = Notifications.getLastNotificationResponse(); + if (lastResponse) { + return transformNotificationResponse(lastResponse); + } + + return null; + }) + .catch(e => { + console.error('[push.ts] Error in promise chain:', e); + return null; + }); + } + + // Fallback to expo-notifications (for iOS or if native module doesn't have data) const lastResponse = Notifications.getLastNotificationResponse(); if (lastResponse) { return Promise.resolve(transformNotificationResponse(lastResponse)); diff --git a/app/sagas/state.js b/app/sagas/state.js index 2797f1da76..25b183b1e6 100644 --- a/app/sagas/state.js +++ b/app/sagas/state.js @@ -7,6 +7,7 @@ import { APP_STATE } from '../actions/actionsTypes'; import { RootEnum } from '../definitions'; import { checkAndReopen } from '../lib/services/connect'; import { setUserPresenceOnline, setUserPresenceAway } from '../lib/services/restApi'; +import { checkPendingNotification } from '../lib/notifications'; const appHasComeBackToForeground = function* appHasComeBackToForeground() { const appRoot = yield select(state => state.app.root); @@ -28,6 +29,10 @@ const appHasComeBackToForeground = function* appHasComeBackToForeground() { try { yield localAuthenticate(server.server); checkAndReopen(); + // Check for pending notification when app comes to foreground (Android - notification tap while in background) + checkPendingNotification().catch((e) => { + log('[state.js] Error checking pending notification:', e); + }); return yield setUserPresenceOnline(); } catch (e) { log(e); From 460628068bdcb6f701cad357552998cf93281536 Mon Sep 17 00:00:00 2001 From: Rohit Bansal <40559587+Rohit3523@users.noreply.github.com> Date: Wed, 7 Jan 2026 21:58:30 +0530 Subject: [PATCH 3/3] feat: render timestamp markdown (#6266) --- app/containers/markdown/Markdown.stories.tsx | 12 + .../__snapshots__/Markdown.test.tsx.snap | 531 ++++++++++++++++++ app/containers/markdown/components/Inline.tsx | 3 + .../markdown/components/Timestamp.tsx | 65 +++ package.json | 4 +- 5 files changed, 613 insertions(+), 2 deletions(-) create mode 100644 app/containers/markdown/components/Timestamp.tsx diff --git a/app/containers/markdown/Markdown.stories.tsx b/app/containers/markdown/Markdown.stories.tsx index cb1cb64fcd..16472f10e4 100644 --- a/app/containers/markdown/Markdown.stories.tsx +++ b/app/containers/markdown/Markdown.stories.tsx @@ -156,3 +156,15 @@ export const Lists = () => ( /> ); + +export const Timestamp = () => ( + + + + + + + + + +); diff --git a/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap b/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap index 043ce49097..0ff288d776 100644 --- a/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap +++ b/app/containers/markdown/__snapshots__/Markdown.test.tsx.snap @@ -4327,3 +4327,534 @@ exports[`Story Snapshots: Text should match snapshot 1`] = ` `; + +exports[`Story Snapshots: Timestamp should match snapshot 1`] = ` + + + + + + t: + + + 12:00 PM + + + + + + + + + T: + + + 12:00:00 PM + + + + + + + + + d: + + + 01/01/2025 + + + + + + + + + D: + + + Wednesday, Jan 01, 2025 + + + + + + + + + f: + + + Wednesday, Jan 01, 2025 12:00 PM + + + + + + + + + F: + + + Wednesday, Jan 01, 2025 12:00:00 PM + + + + + + + + + R: + + + a year ago + + + + + +`; diff --git a/app/containers/markdown/components/Inline.tsx b/app/containers/markdown/components/Inline.tsx index 4a3d096f1d..4811bcabdc 100644 --- a/app/containers/markdown/components/Inline.tsx +++ b/app/containers/markdown/components/Inline.tsx @@ -10,6 +10,7 @@ import Plain from './Plain'; import InlineCode from './InlineCode'; import Image from './Image'; import MarkdownContext from '../contexts/MarkdownContext'; +import Timestamp from './Timestamp'; // import { InlineKaTeX, KaTeX } from './Katex'; interface IParagraphProps { @@ -70,6 +71,8 @@ const Inline = ({ value, forceTrim }: IParagraphProps): React.ReactElement | nul case 'INLINE_KATEX': // return ; return {block.value}; + case 'TIMESTAMP': + return ; default: return null; } diff --git a/app/containers/markdown/components/Timestamp.tsx b/app/containers/markdown/components/Timestamp.tsx new file mode 100644 index 0000000000..31731c322a --- /dev/null +++ b/app/containers/markdown/components/Timestamp.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { Text } from 'react-native'; + +import dayjs from '../../../lib/dayjs'; +import { useTheme } from '../../../theme'; +import { LISTENER } from '../../Toast'; +import EventEmitter from '../../../lib/methods/helpers/events'; +import sharedStyles from '../../../views/Styles'; + +interface ITimestampProps { + value: { timestamp: string; format: 't' | 'T' | 'd' | 'D' | 'f' | 'F' | 'R' }; +} + +const Timestamp = ({ value }: ITimestampProps): React.ReactElement => { + const { colors } = useTheme(); + + const formatDate = React.useMemo(() => { + const timestamp = parseInt(value.timestamp) * 1000; + + if (value.format === 't') { + return dayjs(timestamp).format('hh:mm A'); + } + + if (value.format === 'T') { + return dayjs(timestamp).format('hh:mm:ss A'); + } + + if (value.format === 'd') { + return dayjs(timestamp).format('MM/DD/YYYY'); + } + + if (value.format === 'D') { + return dayjs(timestamp).format('dddd, MMM DD, YYYY'); + } + + if (value.format === 'f') { + return dayjs(timestamp).format('dddd, MMM DD, YYYY hh:mm A'); + } + + if (value.format === 'F') { + return dayjs(timestamp).format('dddd, MMM DD, YYYY hh:mm:ss A'); + } + + if (value.format === 'R') { + return dayjs(timestamp).fromNow(); + } + + return 'Invalid Date'; + }, [value]); + + const handlePress = React.useCallback(() => { + const message = dayjs(parseInt(value.timestamp) * 1000).format('dddd, MMM DD, YYYY hh:mm A'); + EventEmitter.emit(LISTENER, { message }); + }, [value.timestamp]); + + return ( + + {` ${formatDate} `} + + ); +}; + +export default Timestamp; diff --git a/package.json b/package.json index ad024aa328..69549d4a88 100644 --- a/package.json +++ b/package.json @@ -5,8 +5,8 @@ "packageManager": "yarn@1.22.22", "scripts": { "start": "react-native start", - "test": "jest", - "test-update": "jest --updateSnapshot", + "test": "TZ=UTC jest", + "test-update": "TZ=UTC jest --updateSnapshot", "lint": "eslint . && tsc", "prettier-lint": "prettier --write . && yarn lint", "ios": "npx react-native run-ios",