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) + } + } + } }