diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index 4fb70ab1a89..c88798ebd33 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -92,6 +92,10 @@
android:enabled="true"
android:exported="true" >
+
> notificationMessages = new ConcurrentHashMap<>();
-
+ private static volatile ReactApplicationContext reactApplicationContext;
// Constants
public static final String KEY_REPLY = "KEY_REPLY";
public static final String NOTIFICATION_ID = "NOTIFICATION_ID";
private static final String CHANNEL_ID = "rocketchatrn_channel_01";
private static final String CHANNEL_NAME = "All";
-
+
// Instance fields
private final Context mContext;
private volatile Bundle mBundle;
private final NotificationManager notificationManager;
-
+
public CustomPushNotification(Context context, Bundle bundle) {
this.mContext = context;
this.mBundle = bundle;
this.notificationManager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
-
+
// Ensure notification channel exists
createNotificationChannel();
}
@@ -71,29 +75,80 @@ public static void clearMessages(int notId) {
notificationMessages.remove(Integer.toString(notId));
}
+ public static void setReactApplicationContext(ReactApplicationContext reactContext) {
+ reactApplicationContext = reactContext;
+ }
/**
- * Checks if the app is currently in the foreground.
- * Uses ProcessLifecycleOwner to reliably detect app state.
+ * Check if React Native is initialized
*/
- public static boolean isAppInForeground() {
- return ProcessLifecycleOwner.get().getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.RESUMED);
+ private boolean isReactInitialized() {
+ return reactApplicationContext != null;
}
-
+
public void onReceived() {
String notId = mBundle.getString("notId");
-
if (notId == null || notId.isEmpty()) {
Log.w(TAG, "Missing notification ID, ignoring notification");
return;
}
-
try {
Integer.parseInt(notId);
} catch (NumberFormatException e) {
Log.w(TAG, "Invalid notification ID format: " + notId);
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");
+ }
+
// Process notification immediately - no need to wait for React Native
// MMKV is initialized at app startup, so all notification types can work without React
try {
@@ -102,9 +157,12 @@ public void onReceived() {
Log.e(TAG, "Failed to process notification", e);
}
}
-
+
private void handleNotification() {
Ejson receivedEjson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class);
+
+ if (receivedEjson != null && receivedEjson.notificationType != null
+ && receivedEjson.notificationType.equals("message-id-only")) {
if (receivedEjson != null && receivedEjson.notificationType != null && receivedEjson.notificationType.equals("message-id-only")) {
Log.d(TAG, "Detected message-id-only notification, will fetch full content from server");
@@ -120,43 +178,52 @@ private void handleNotification() {
// For non-message-id-only notifications, process immediately
processNotification();
}
-
+
private void loadNotificationAndProcess(Ejson ejson) {
notificationLoad(ejson, new Callback() {
@Override
public void call(@Nullable Bundle bundle) {
if (bundle != null) {
Log.d(TAG, "Successfully loaded notification content from server, updating notification props");
-
+
if (ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "[BEFORE update] bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false));
- Log.d(TAG, "[BEFORE update] bundle.title=" + (bundle.getString("title") != null ? "[present]" : "[null]"));
- Log.d(TAG, "[BEFORE update] bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0));
+ Log.d(TAG, "[BEFORE update] bundle.notificationLoaded="
+ + bundle.getBoolean("notificationLoaded", false));
+ Log.d(TAG, "[BEFORE update] bundle.title="
+ + (bundle.getString("title") != null ? "[present]" : "[null]"));
+ Log.d(TAG, "[BEFORE update] bundle.message length="
+ + (bundle.getString("message") != null ? bundle.getString("message").length() : 0));
}
-
- synchronized(CustomPushNotification.this) {
+
+ synchronized (CustomPushNotification.this) {
mBundle = bundle;
}
} else {
- Log.w(TAG, "Failed to load notification content from server, will display placeholder notification");
+ Log.w(TAG,
+ "Failed to load notification content from server, will display placeholder notification");
}
-
+
processNotification();
}
});
}
-
+
private void processNotification() {
Ejson loadedEjson = safeFromJson(mBundle.getString("ejson", "{}"), Ejson.class);
String notId = mBundle.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] loadedEjson.notificationType=" + (loadedEjson != null ? loadedEjson.notificationType : "null"));
- Log.d(TAG, "[processNotification] loadedEjson.sender=" + (loadedEjson != null && loadedEjson.sender != null ? loadedEjson.sender.username : "null"));
+ 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] 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
@@ -181,6 +248,22 @@ private boolean isE2ENotification(Ejson ejson) {
* No longer waits for React Native initialization.
*/
private void handleE2ENotification(Bundle bundle, Ejson ejson, String notId) {
+ ReactApplicationContext reactContext = reactApplicationContext;
+ // Check if React context is immediately available
+ if (reactContext != null) {
+ // Fast path: decrypt immediately
+ String decrypted = Encryption.shared.decryptMessage(ejson, reactContext);
+
+ if (decrypted != null) {
+ bundle.putString("message", decrypted);
+ synchronized (this) {
+ mBundle = bundle;
+ }
+ showNotification(bundle, ejson, notId);
+ return;
+ }
+ }
+
// Decrypt immediately using regular Android Context (mContext)
// This works without React Native initialization
String decrypted = Encryption.shared.decryptMessage(ejson, mContext);
@@ -191,16 +274,50 @@ private void handleE2ENotification(Bundle bundle, Ejson ejson, String notId) {
mBundle = bundle;
}
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
- // Use a placeholder message since we can't decrypt
- bundle.putString("message", "Encrypted message");
- synchronized(this) {
- mBundle = bundle;
- }
- showNotification(bundle, ejson, notId);
+ return;
}
+
+ // Slow path: wait for React context asynchronously
+ Log.i(TAG, "Waiting for React context to decrypt E2E notification");
+
+ 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, showing fallback notification");
+ showFallbackNotification(originalBundle, originalEjson, notificationId);
+ }
+
+ @Override
+ public void onTimeout(Bundle originalBundle, Ejson originalEjson, String notificationId) {
+ Log.w(TAG, "Timeout waiting for React context for E2E notification, showing fallback notification");
+ showFallbackNotification(originalBundle, originalEjson, notificationId);
+ }
+ });
+
+ processor.processAsync(bundle, ejson, notId);
+ }
+
+ private void showFallbackNotification(Bundle bundle, Ejson ejson, String notId) {
+ // Show fallback notification so user knows a message arrived
+ // Use a placeholder message since we can't decrypt
+ bundle.putString("message", "Encrypted message");
+ synchronized(this) {
+ mBundle = bundle;
+ }
+ showNotification(bundle, ejson, notId);
}
/**
@@ -224,7 +341,7 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) {
bundle.putLong("time", new Date().getTime());
bundle.putString("username", displaySenderName);
bundle.putString("senderId", hasSender ? ejson.sender._id : "1");
-
+
String avatarUri = ejson != null ? ejson.getAvatarUri() : null;
bundle.putString("avatarUri", avatarUri);
@@ -241,7 +358,10 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) {
} else {
// Show regular notification
if (ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "[Before add to notificationMessages] notId=" + notId + ", bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0) + ", bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false));
+ Log.d(TAG,
+ "[Before add to notificationMessages] notId=" + notId + ", bundle.message length="
+ + (bundle.getString("message") != null ? bundle.getString("message").length() : 0)
+ + ", bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false));
}
// Don't show notification if app is in foreground
@@ -254,7 +374,8 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) {
notificationMessages.get(notId).add(bundle);
if (ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "[After add] notificationMessages[" + notId + "].size=" + notificationMessages.get(notId).size());
+ Log.d(TAG, "[After add] notificationMessages[" + notId + "].size="
+ + notificationMessages.get(notId).size());
}
postNotification(Integer.parseInt(notId));
}
@@ -266,7 +387,7 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) {
*/
private void handleVideoConfNotification(Bundle bundle, Ejson ejson) {
VideoConfNotification videoConf = new VideoConfNotification(mContext);
-
+
Integer status = ejson.status;
String rid = ejson.rid;
// Video conf uses 'caller' field, regular messages use 'sender'
@@ -276,9 +397,9 @@ private void handleVideoConfNotification(Bundle bundle, Ejson ejson) {
} else if (ejson.sender != null && ejson.sender._id != null) {
callerId = ejson.sender._id;
}
-
+
Log.d(TAG, "Video conf notification - status: " + status + ", rid: " + rid);
-
+
if (status == null || status == 0) {
// Incoming call - show notification
videoConf.showIncomingCall(bundle, ejson);
@@ -296,14 +417,13 @@ private void postNotification(int notificationId) {
notificationManager.notify(notificationId, notification.build());
}
}
-
+
private void createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
NotificationChannel channel = new NotificationChannel(
- CHANNEL_ID,
- CHANNEL_NAME,
- NotificationManager.IMPORTANCE_HIGH
- );
+ CHANNEL_ID,
+ CHANNEL_NAME,
+ NotificationManager.IMPORTANCE_HIGH);
if (notificationManager != null) {
notificationManager.createNotificationChannel(channel);
}
@@ -332,12 +452,14 @@ private Notification.Builder buildNotification(int notificationId) {
Intent intent = new Intent(mContext, MainActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
intent.putExtras(mBundle);
-
+
PendingIntent pendingIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
} else {
- pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent, PendingIntent.FLAG_UPDATE_CURRENT);
+ pendingIntent = PendingIntent.getActivity(mContext, notificationId, intent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
}
Notification.Builder notification;
@@ -359,11 +481,14 @@ private Notification.Builder buildNotification(int notificationId) {
notificationIcons(notification, mBundle);
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) {
+ // 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);
+ notificationMarkAsRead(notification, notificationId, mBundle);
} else {
Log.w(TAG, "[buildNotification] ⚠️ Rendering FALLBACK notification");
// Cancel previous fallback notifications from same server
@@ -372,7 +497,7 @@ private Notification.Builder buildNotification(int notificationId) {
return notification;
}
-
+
private void cancelPreviousFallbackNotifications(Ejson ejson) {
for (Map.Entry> bundleList : notificationMessages.entrySet()) {
Iterator iterator = bundleList.getValue().iterator();
@@ -397,6 +522,22 @@ private void cancelPreviousFallbackNotifications(Ejson ejson) {
}
private Bitmap getAvatar(String uri) {
+ if (uri == null || uri.isEmpty()) {
+ if (ENABLE_VERBOSE_LOGS) {
+ Log.w(TAG, "getAvatar called with null/empty URI");
+ }
+ return largeIcon();
+ }
+
+ if (ENABLE_VERBOSE_LOGS) {
+ String sanitizedUri = uri;
+ int queryStart = uri.indexOf("?");
+ if (queryStart != -1) {
+ sanitizedUri = uri.substring(0, queryStart) + "?[auth_params]";
+ }
+ Log.d(TAG, "Fetching avatar from: " + sanitizedUri);
+ }
+
return NotificationHelper.fetchAvatarBitmap(mContext, uri, largeIcon());
}
@@ -451,7 +592,8 @@ private void notificationStyle(Notification.Builder notification, int notId, Bun
List bundles = notificationMessages.get(Integer.toString(notId));
if (ENABLE_VERBOSE_LOGS) {
- Log.d(TAG, "[notificationStyle] notId=" + notId + ", bundles=" + (bundles != null ? bundles.size() : "null"));
+ Log.d(TAG,
+ "[notificationStyle] notId=" + notId + ", bundles=" + (bundles != null ? bundles.size() : "null"));
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) {
@@ -535,9 +677,11 @@ private void notificationReply(Notification.Builder notification, int notificati
PendingIntent replyPendingIntent;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
+ replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_MUTABLE);
} else {
- replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent, PendingIntent.FLAG_UPDATE_CURRENT);
+ replyPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, replyIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
}
RemoteInput remoteInput = new RemoteInput.Builder(KEY_REPLY)
@@ -554,11 +698,44 @@ private void notificationReply(Notification.Builder notification, int notificati
.addAction(replyAction);
}
+ private void notificationMarkAsRead(Notification.Builder notification, int notificationId, Bundle bundle) {
+ String notId = bundle.getString("notId", "1");
+ String ejson = bundle.getString("ejson", "{}");
+ if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N || notId.equals("1") || ejson.equals("{}")) {
+ return;
+ }
+ String label = "Mark as read";
+
+ final Resources res = mContext.getResources();
+ String packageName = mContext.getPackageName();
+ int smallIconResId = res.getIdentifier("ic_notification", "drawable", packageName);
+
+ Intent markAsReadIntent = new Intent(mContext, MarkAsReadBroadcast.class);
+ markAsReadIntent.setAction(MarkAsReadBroadcast.KEY_MARK_AS_READ);
+ markAsReadIntent.putExtra("pushNotification", bundle);
+
+ PendingIntent markAsReadPendingIntent;
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ markAsReadPendingIntent = PendingIntent.getBroadcast(mContext, notificationId + 1000, markAsReadIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+ } else {
+ markAsReadPendingIntent = PendingIntent.getBroadcast(mContext, notificationId + 1000, markAsReadIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT);
+ }
+
+ Notification.Action markAsReadAction = new Notification.Action.Builder(smallIconResId, label,
+ markAsReadPendingIntent)
+ .build();
+
+ notification.addAction(markAsReadAction);
+ }
+
private void notificationDismiss(Notification.Builder notification, int notificationId) {
Intent intent = new Intent(mContext, DismissNotification.class);
intent.putExtra(NOTIFICATION_ID, notificationId);
- PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, intent, PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
+ PendingIntent dismissPendingIntent = PendingIntent.getBroadcast(mContext, notificationId, intent,
+ PendingIntent.FLAG_ONE_SHOT | PendingIntent.FLAG_IMMUTABLE);
notification.setDeleteIntent(dismissPendingIntent);
}
@@ -567,7 +744,7 @@ private void notificationLoad(Ejson ejson, Callback callback) {
LoadNotification loadNotification = new LoadNotification();
loadNotification.load(ejson, callback);
}
-
+
/**
* Safely parses JSON string to object with error handling.
*/
diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/MarkAsReadBroadcast.java b/android/app/src/main/java/chat/rocket/reactnative/notification/MarkAsReadBroadcast.java
new file mode 100644
index 00000000000..5bf2f9eb24b
--- /dev/null
+++ b/android/app/src/main/java/chat/rocket/reactnative/notification/MarkAsReadBroadcast.java
@@ -0,0 +1,99 @@
+package chat.rocket.reactnative.notification;
+
+import android.app.NotificationManager;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.os.Bundle;
+import android.util.Log;
+
+import com.google.gson.Gson;
+import com.wix.reactnativenotifications.core.NotificationIntentAdapter;
+
+import java.io.IOException;
+
+import okhttp3.Call;
+import okhttp3.MediaType;
+import okhttp3.OkHttpClient;
+import okhttp3.Request;
+import okhttp3.RequestBody;
+import okhttp3.Response;
+
+public class MarkAsReadBroadcast extends BroadcastReceiver {
+ private static final String TAG = "RocketChat.MarkAsRead";
+ public static final String KEY_MARK_AS_READ = "KEY_MARK_AS_READ";
+ private static final OkHttpClient client = new OkHttpClient();
+ private static final MediaType JSON = MediaType.parse("application/json; charset=utf-8");
+
+ @Override
+ public void onReceive(Context context, Intent intent) {
+ // Keep receiver alive for async network operation
+ final PendingResult pendingResult = goAsync();
+
+ Bundle bundle = NotificationIntentAdapter.extractPendingNotificationDataFromIntent(intent);
+ NotificationManager notificationManager = (NotificationManager) context
+ .getSystemService(Context.NOTIFICATION_SERVICE);
+
+ String notId = bundle.getString("notId");
+
+ Gson gson = new Gson();
+ Ejson ejson = gson.fromJson(bundle.getString("ejson", "{}"), Ejson.class);
+
+ try {
+ int id = Integer.parseInt(notId);
+ markAsRead(ejson, id, notificationManager, pendingResult);
+ } catch (NumberFormatException e) {
+ Log.e(TAG, "Invalid notification ID: " + notId, e);
+ pendingResult.finish();
+ }
+ }
+
+ protected void markAsRead(final Ejson ejson, final int notId,
+ final NotificationManager notificationManager,
+ final PendingResult pendingResult) {
+ String serverURL = ejson.serverURL();
+ String rid = ejson.rid;
+
+ if (serverURL == null || rid == null) {
+ Log.e(TAG, "Missing serverURL or rid");
+ pendingResult.finish();
+ return;
+ }
+
+ String json = String.format("{\"rid\":\"%s\"}", rid);
+
+ RequestBody body = RequestBody.create(JSON, json);
+ Request request = new Request.Builder()
+ .header("x-auth-token", ejson.token())
+ .header("x-user-id", ejson.userId())
+ .url(String.format("%s/api/v1/subscriptions.read", serverURL))
+ .post(body)
+ .build();
+
+ client.newCall(request).enqueue(new okhttp3.Callback() {
+ @Override
+ public void onFailure(Call call, IOException e) {
+ Log.e(TAG, "Mark as read FAILED: " + e.getMessage());
+ pendingResult.finish();
+ }
+
+ @Override
+ public void onResponse(Call call, final Response response) throws IOException {
+ try {
+ if (response.isSuccessful()) {
+ Log.d(TAG, "Mark as read SUCCESS");
+ CustomPushNotification.clearMessages(notId);
+ notificationManager.cancel(notId);
+ } else {
+ Log.e(TAG, String.format("Mark as read FAILED status %s", response.code()));
+ }
+ } finally {
+ if (response.body() != null) {
+ response.body().close();
+ }
+ pendingResult.finish();
+ }
+ }
+ });
+ }
+}
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
index 543558c1a8d..3e6c27a5871 100644
--- a/android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationModule.kt
+++ b/android/app/src/main/java/chat/rocket/reactnative/notification/PushNotificationModule.kt
@@ -11,6 +11,9 @@ import com.facebook.react.bridge.ReactMethod
*/
class PushNotificationModule(reactContext: ReactApplicationContext) : NativePushNotificationSpec(reactContext) {
+ init {
+ CustomPushNotification.setReactApplicationContext(reactContext)
+ }
companion object {
private const val PREFS_NAME = "RocketChatPrefs"
private const val KEY_PENDING_NOTIFICATION = "pendingNotification"
diff --git a/app/i18n/locales/en.json b/app/i18n/locales/en.json
index 039513dbe47..9aa3daef233 100644
--- a/app/i18n/locales/en.json
+++ b/app/i18n/locales/en.json
@@ -500,6 +500,7 @@
"Logout": "Logout",
"Logout_failed": "Logout failed!",
"Logout_from_other_logged_in_locations": "Logout from other logged in locations",
+ "Mark_as_read": "Mark as read",
"Mark_as_unread": "Mark as unread",
"Mark_as_unread_Info": "Display room as unread when there are unread messages",
"Mark_read": "Mark read",
diff --git a/app/lib/notifications/push.ts b/app/lib/notifications/push.ts
index beae1c10db6..8ec126c98ce 100644
--- a/app/lib/notifications/push.ts
+++ b/app/lib/notifications/push.ts
@@ -89,7 +89,7 @@ const setupNotificationCategories = async (): Promise => {
}
try {
- // Message category with Reply action
+ // Message category with Reply and Mark as Read actions
await Notifications.setNotificationCategoryAsync('MESSAGE', [
{
identifier: 'REPLY_ACTION',
@@ -101,6 +101,13 @@ const setupNotificationCategories = async (): Promise => {
options: {
opensAppToForeground: false
}
+ },
+ {
+ identifier: 'MARK_AS_READ_ACTION',
+ buttonTitle: I18n.t('Mark_as_read'),
+ options: {
+ opensAppToForeground: false
+ }
}
]);
diff --git a/ios/ReplyNotification.swift b/ios/ReplyNotification.swift
index f0318ddc8bd..539b0e23ef3 100644
--- a/ios/ReplyNotification.swift
+++ b/ios/ReplyNotification.swift
@@ -16,20 +16,20 @@ import UserNotifications
class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
private static var shared: ReplyNotification?
private weak var originalDelegate: UNUserNotificationCenterDelegate?
-
+
@objc
public static func configure() {
let instance = ReplyNotification()
shared = instance
-
+
// Store the original delegate (expo-notifications) and set ourselves as the delegate
let center = UNUserNotificationCenter.current()
instance.originalDelegate = center.delegate
center.delegate = instance
}
-
+
// MARK: - UNUserNotificationCenterDelegate
-
+
func userNotificationCenter(
_ center: UNUserNotificationCenter,
didReceive response: UNNotificationResponse,
@@ -40,7 +40,13 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
handleReplyAction(response: response, completionHandler: completionHandler)
return
}
-
+
+ // Handle MARK_AS_READ_ACTION natively
+ if response.actionIdentifier == "MARK_AS_READ_ACTION" {
+ handleMarkAsReadAction(response: response, completionHandler: completionHandler)
+ return
+ }
+
// Forward to original delegate (expo-notifications)
if let originalDelegate = originalDelegate {
originalDelegate.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler)
@@ -48,7 +54,7 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
completionHandler()
}
}
-
+
func userNotificationCenter(
_ center: UNUserNotificationCenter,
willPresent notification: UNNotification,
@@ -61,7 +67,7 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
completionHandler([])
}
}
-
+
func userNotificationCenter(
_ center: UNUserNotificationCenter,
openSettingsFor notification: UNNotification?
@@ -73,17 +79,17 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
}
}
}
-
+
// MARK: - Reply Handling
-
+
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),
@@ -96,9 +102,11 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
completionHandler()
return
}
-
+
let message = textResponse.userText
let rocketchat = RocketChat(server: payload.host.removeTrailingSlash())
+ let backgroundTask = UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
+
var backgroundTask: UIBackgroundTaskIdentifier = .invalid
backgroundTask = UIApplication.shared.beginBackgroundTask {
@@ -120,7 +128,7 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
}
completionHandler()
}
-
+
guard let response = response, response.success else {
// Show failure notification
let content = UNMutableNotificationContent()
@@ -132,4 +140,26 @@ class ReplyNotification: NSObject, UNUserNotificationCenterDelegate {
}
}
}
+
+ private func handleMarkAsReadAction(response: UNNotificationResponse, completionHandler: @escaping () -> Void) {
+ 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 {
+ completionHandler()
+ return
+ }
+
+ let rocketchat = RocketChat(server: payload.host.removeTrailingSlash())
+ let backgroundTask = UIApplication.shared.beginBackgroundTask(expirationHandler: nil)
+
+ rocketchat.markAsRead(rid: rid) { response in
+ DispatchQueue.main.async {
+ UIApplication.shared.endBackgroundTask(backgroundTask)
+ completionHandler()
+ }
+ }
+ }
}
diff --git a/ios/Shared/RocketChat/API/Requests/MarkAsRead.swift b/ios/Shared/RocketChat/API/Requests/MarkAsRead.swift
new file mode 100644
index 00000000000..3e45d63e3d8
--- /dev/null
+++ b/ios/Shared/RocketChat/API/Requests/MarkAsRead.swift
@@ -0,0 +1,34 @@
+//
+// MarkAsRead.swift
+// RocketChatRN
+//
+// Created for Mark as Read notification action
+// Copyright © 2025 Rocket.Chat. All rights reserved.
+//
+
+import Foundation
+
+struct MarkAsReadBody: Codable {
+ let rid: String
+}
+
+struct MarkAsReadResponse: Response {
+ var success: Bool
+}
+
+final class MarkAsReadRequest: Request {
+ typealias ResponseType = MarkAsReadResponse
+
+ let method: HTTPMethod = .post
+ let path = "/api/v1/subscriptions.read"
+
+ let rid: String
+
+ init(rid: String) {
+ self.rid = rid
+ }
+
+ func body() -> Data? {
+ return try? JSONEncoder().encode(MarkAsReadBody(rid: rid))
+ }
+}
diff --git a/ios/Shared/RocketChat/RocketChat.swift b/ios/Shared/RocketChat/RocketChat.swift
index 83d50139e6d..bafdf2c8cc4 100644
--- a/ios/Shared/RocketChat/RocketChat.swift
+++ b/ios/Shared/RocketChat/RocketChat.swift
@@ -11,53 +11,51 @@ import Foundation
final class RocketChat {
typealias Server = String
typealias RoomId = String
-
+
let server: Server
let api: API?
private var encryptionQueue = DispatchQueue(label: "chat.rocket.encryptionQueue")
-
+
init(server: Server) {
self.server = server
self.api = API(server: server)
}
-
- func getPushWithId(_ msgId: String, completion: @escaping((Notification?) -> Void)) {
+
+ func getPushWithId(_ msgId: String, completion: @escaping (Notification?) -> Void) {
api?.fetch(request: PushRequest(msgId: msgId), retry: Retry(retries: 4)) { response in
switch response {
case .resource(let response):
let notification = response.data.notification
completion(notification)
-
+
case .error:
completion(nil)
- break
}
}
}
-
- func sendMessage(rid: String, message: String, threadIdentifier: String?, completion: @escaping((MessageResponse?) -> Void)) {
+
+ func sendMessage(rid: String, message: String, threadIdentifier: String?, completion: @escaping (MessageResponse?) -> Void) {
let id = String.random(length: 17)
-
+
let encrypted = Database(server: server).readRoomEncrypted(for: rid)
-
+
if encrypted {
let encryption = Encryption(server: server, rid: rid)
guard let content = encryption.encryptContent(message) else {
return
}
-
+
// For backward compatibility, also set msg field
let msg = content.algorithm == "rc.v2.aes-sha2" ? "" : content.ciphertext
-
+
api?.fetch(request: SendMessageRequest(id: id, roomId: rid, text: msg, content: content, threadIdentifier: threadIdentifier, messageType: .e2e)) { response in
switch response {
case .resource(let response):
completion(response)
-
+
case .error:
completion(nil)
- break
}
}
} else {
@@ -65,19 +63,30 @@ final class RocketChat {
switch response {
case .resource(let response):
completion(response)
-
+
case .error:
completion(nil)
- break
}
}
}
}
-
+
func decryptContent(rid: String, content: EncryptedContent) -> String? {
encryptionQueue.sync {
let encryption = Encryption(server: server, rid: rid)
return encryption.decryptContent(content: content)
}
}
+
+ func markAsRead(rid: String, completion: @escaping (MarkAsReadResponse?) -> Void) {
+ api?.fetch(request: MarkAsReadRequest(rid: rid)) { response in
+ switch response {
+ case .resource(let response):
+ completion(response)
+
+ case .error:
+ completion(nil)
+ }
+ }
+ }
}