diff --git a/android/app/build.gradle b/android/app/build.gradle index 536e23f850b..8a0872062d0 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.67.0" + versionName "4.67.1" vectorDrawables.useSupportLibrary = true manifestPlaceholders = [BugsnagAPIKey: BugsnagAPIKey as String] missingDimensionStrategy "RNNotifications.reactNativeVersion", "reactNative60" // See note below! 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 d17f1b234db..18f89fb0ca4 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 @@ -32,12 +32,13 @@ import java.util.ArrayList; import java.util.Date; -import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutionException; +import chat.rocket.reactnative.BuildConfig; import chat.rocket.reactnative.R; /** @@ -47,12 +48,13 @@ * For E2E notifications, waits for React Native initialization before decrypting and displaying. */ public class CustomPushNotification extends PushNotification { - private static final String TAG = "RocketChat.Push"; + private static final String TAG = "RocketChat.CustomPush"; + private static final boolean ENABLE_VERBOSE_LOGS = BuildConfig.DEBUG; // Shared state - public static ReactApplicationContext reactApplicationContext; + public static volatile ReactApplicationContext reactApplicationContext; private static final Gson gson = new Gson(); - private static final Map> notificationMessages = new HashMap<>(); + private static final Map> notificationMessages = new ConcurrentHashMap<>(); // Constants public static final String KEY_REPLY = "KEY_REPLY"; @@ -81,28 +83,140 @@ public static void clearMessages(int notId) { @Override public void onReceived() throws InvalidNotificationException { + Bundle bundle = mNotificationProps.asBundle(); + String notId = bundle.getString("notId"); + + if (notId == null || notId.isEmpty()) { + throw new InvalidNotificationException("Missing notification ID"); + } + + try { + Integer.parseInt(notId); + } catch (NumberFormatException e) { + throw new InvalidNotificationException("Invalid notification ID format: " + notId); + } + + // Check if React is ready - needed for MMKV access (avatars, encryption, message-id-only) + if (!mAppLifecycleFacade.isReactInitialized()) { + android.util.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 (!mAppLifecycleFacade.isReactInitialized() && attempts < maxAttempts) { + try { + Thread.sleep(100); // Wait 100ms + attempts++; + + if (attempts % 10 == 0 && ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "Still waiting for React initialization... (" + (attempts * 100) + "ms elapsed)"); + } + } catch (InterruptedException e) { + android.util.Log.e(TAG, "Wait interrupted", e); + Thread.currentThread().interrupt(); + return; + } + } + + if (mAppLifecycleFacade.isReactInitialized()) { + android.util.Log.i(TAG, "React initialized after " + (attempts * 100) + "ms, proceeding with notification"); + try { + handleNotification(); + } catch (Exception e) { + android.util.Log.e(TAG, "Failed to process notification after React initialization", e); + } + } else { + android.util.Log.e(TAG, "Timeout waiting for React initialization after " + (maxAttempts * 100) + "ms, processing without MMKV"); + try { + handleNotification(); + } catch (Exception e) { + android.util.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) { + android.util.Log.d(TAG, "React already initialized, proceeding with notification"); + } - // Load notification data from server if needed + try { + handleNotification(); + } catch (Exception e) { + android.util.Log.e(TAG, "Failed to process notification on main thread", e); + throw new InvalidNotificationException("Notification processing failed: " + e.getMessage()); + } + } + + private void handleNotification() { Bundle received = mNotificationProps.asBundle(); Ejson receivedEjson = safeFromJson(received.getString("ejson", "{}"), Ejson.class); - if (receivedEjson != null && receivedEjson.notificationType != null && - receivedEjson.notificationType.equals("message-id-only")) { - notificationLoad(receivedEjson, new Callback() { - @Override - public void call(@Nullable Bundle bundle) { - if (bundle != null) { + if (receivedEjson != null && receivedEjson.notificationType != null && receivedEjson.notificationType.equals("message-id-only")) { + android.util.Log.d(TAG, "Detected message-id-only notification, will fetch full content from server"); + loadNotificationAndProcess(receivedEjson); + return; // Exit early, notification will be processed in callback + } + + // 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) { + android.util.Log.d(TAG, "Successfully loaded notification content from server, updating notification props"); + + if (ENABLE_VERBOSE_LOGS) { + // BEFORE createProps + android.util.Log.d(TAG, "[BEFORE createProps] bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); + android.util.Log.d(TAG, "[BEFORE createProps] bundle.title=" + (bundle.getString("title") != null ? "[present]" : "[null]")); + android.util.Log.d(TAG, "[BEFORE createProps] bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0)); + android.util.Log.d(TAG, "[BEFORE createProps] bundle has ejson=" + (bundle.getString("ejson") != null)); + } + + synchronized(CustomPushNotification.this) { mNotificationProps = createProps(bundle); } + + if (ENABLE_VERBOSE_LOGS) { + // AFTER createProps - verify it worked + Bundle verifyBundle = mNotificationProps.asBundle(); + android.util.Log.d(TAG, "[AFTER createProps] mNotificationProps.notificationLoaded=" + verifyBundle.getBoolean("notificationLoaded", false)); + android.util.Log.d(TAG, "[AFTER createProps] mNotificationProps.title=" + (verifyBundle.getString("title") != null ? "[present]" : "[null]")); + android.util.Log.d(TAG, "[AFTER createProps] mNotificationProps.message length=" + (verifyBundle.getString("message") != null ? verifyBundle.getString("message").length() : 0)); + android.util.Log.d(TAG, "[AFTER createProps] mNotificationProps has ejson=" + (verifyBundle.getString("ejson") != null)); + } + } else { + android.util.Log.w(TAG, "Failed to load notification content from server, will display placeholder notification"); } - }); - } - - // Re-read values (may have changed from notificationLoad) + + processNotification(); + } + }); + } + + private void processNotification() { + // We should re-read these values since that can be changed by notificationLoad Bundle bundle = mNotificationProps.asBundle(); Ejson loadedEjson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); String notId = bundle.getString("notId", "1"); + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "[processNotification] notId=" + notId); + android.util.Log.d(TAG, "[processNotification] bundle.notificationLoaded=" + bundle.getBoolean("notificationLoaded", false)); + android.util.Log.d(TAG, "[processNotification] bundle.title=" + (bundle.getString("title") != null ? "[present]" : "[null]")); + android.util.Log.d(TAG, "[processNotification] bundle.message length=" + (bundle.getString("message") != null ? bundle.getString("message").length() : 0)); + android.util.Log.d(TAG, "[processNotification] loadedEjson.notificationType=" + (loadedEjson != null ? loadedEjson.notificationType : "null")); + android.util.Log.d(TAG, "[processNotification] loadedEjson.sender=" + (loadedEjson != null && loadedEjson.sender != null ? loadedEjson.sender.username : "null")); + } + // Handle E2E encrypted notifications if (isE2ENotification(loadedEjson)) { handleE2ENotification(bundle, loadedEjson, notId); @@ -191,7 +305,12 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) { bundle.putLong("time", new Date().getTime()); bundle.putString("username", hasSender ? ejson.sender.username : title); bundle.putString("senderId", hasSender ? ejson.sender._id : "1"); - bundle.putString("avatarUri", ejson != null ? ejson.getAvatarUri() : null); + + String avatarUri = ejson != null ? ejson.getAvatarUri() : null; + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "[showNotification] avatarUri=" + (avatarUri != null ? "[present]" : "[null]")); + } + bundle.putString("avatarUri", avatarUri); // Handle special notification types if (ejson != null && ejson.notificationType instanceof String && @@ -199,7 +318,13 @@ private void showNotification(Bundle bundle, Ejson ejson, String notId) { notifyReceivedToJS(); } else { // Show regular notification + if (ENABLE_VERBOSE_LOGS) { + android.util.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)); + } notificationMessages.get(notId).add(bundle); + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "[After add] notificationMessages[" + notId + "].size=" + notificationMessages.get(notId).size()); + } postNotification(Integer.parseInt(notId)); notifyReceivedToJS(); } @@ -224,6 +349,16 @@ protected Notification.Builder getNotificationBuilder(PendingIntent intent) { Boolean notificationLoaded = bundle.getBoolean("notificationLoaded", false); Ejson ejson = safeFromJson(bundle.getString("ejson", "{}"), Ejson.class); + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "[getNotificationBuilder] notId=" + notId); + android.util.Log.d(TAG, "[getNotificationBuilder] notificationLoaded=" + notificationLoaded); + android.util.Log.d(TAG, "[getNotificationBuilder] title=" + (title != null ? "[present]" : "[null]")); + android.util.Log.d(TAG, "[getNotificationBuilder] message length=" + (message != null ? message.length() : 0)); + android.util.Log.d(TAG, "[getNotificationBuilder] ejson=" + (ejson != null ? "present" : "null")); + android.util.Log.d(TAG, "[getNotificationBuilder] ejson.notificationType=" + (ejson != null ? ejson.notificationType : "null")); + android.util.Log.d(TAG, "[getNotificationBuilder] ejson.sender=" + (ejson != null && ejson.sender != null ? ejson.sender.username : "null")); + } + notification .setContentTitle(title) .setContentText(message) @@ -240,11 +375,13 @@ protected Notification.Builder getNotificationBuilder(PendingIntent intent) { // 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) { + android.util.Log.i(TAG, "[getNotificationBuilder] ✅ Rendering FULL notification style (ejson=" + (ejson != null) + ", notificationType=" + (ejson != null ? ejson.notificationType : "null") + ", notificationLoaded=" + notificationLoaded + ")"); notificationStyle(notification, notificationId, bundle); notificationReply(notification, notificationId, bundle); // message couldn't be loaded from server (Fallback notification) } else { + android.util.Log.w(TAG, "[getNotificationBuilder] ⚠️ Rendering FALLBACK notification (ejson=" + (ejson != null) + ", notificationType=" + (ejson != null ? ejson.notificationType : "null") + ", notificationLoaded=" + notificationLoaded + ")"); // iterate over the current notification ids to dismiss fallback notifications from same server for (Map.Entry> bundleList : notificationMessages.entrySet()) { // iterate over the notifications with this id (same host + rid) @@ -257,7 +394,16 @@ protected Notification.Builder getNotificationBuilder(PendingIntent intent) { if (ejson != null && notEjson != null && ejson.serverURL().equals(notEjson.serverURL())) { String id = not.getString("notId"); // cancel this notification - notificationManager.cancel(Integer.parseInt(id)); + if (notificationManager != null) { + try { + notificationManager.cancel(Integer.parseInt(id)); + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "Cancelled previous fallback notification from same server"); + } + } catch (NumberFormatException e) { + android.util.Log.e(TAG, "Invalid notification ID for cancel: " + id, e); + } + } } } } @@ -274,14 +420,42 @@ private void notifyReceivedToJS() { } private Bitmap getAvatar(String uri) { + if (uri == null || uri.isEmpty()) { + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.w(TAG, "getAvatar called with null/empty URI"); + } + return largeIcon(); + } + + // Sanitize URL for logging (remove query params with tokens) + String sanitizedUri = uri; + int queryStart = uri.indexOf("?"); + if (queryStart != -1) { + sanitizedUri = uri.substring(0, queryStart) + "?[auth_params]"; + } + + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "Fetching avatar from: " + sanitizedUri); + } + try { - return Glide.with(mContext) + Bitmap avatar = Glide.with(mContext) .asBitmap() .apply(RequestOptions.bitmapTransform(new RoundedCorners(10))) .load(uri) .submit(100, 100) .get(); + + if (avatar != null) { + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "Successfully loaded avatar"); + } + } else { + android.util.Log.w(TAG, "Avatar loaded but is null"); + } + return avatar != null ? avatar : largeIcon(); } catch (final ExecutionException | InterruptedException e) { + android.util.Log.e(TAG, "Failed to fetch avatar: " + e.getMessage(), e); return largeIcon(); } } @@ -324,7 +498,11 @@ private void notificationChannel(Notification.Builder notification) { NotificationManager.IMPORTANCE_HIGH); final NotificationManager notificationManager = (NotificationManager) mContext.getSystemService(Context.NOTIFICATION_SERVICE); - notificationManager.createNotificationChannel(channel); + if (notificationManager != null) { + notificationManager.createNotificationChannel(channel); + } else { + android.util.Log.e(TAG, "NotificationManager is null, cannot create notification channel"); + } notification.setChannelId(CHANNEL_ID); } @@ -351,6 +529,15 @@ private void notificationColor(Notification.Builder notification) { private void notificationStyle(Notification.Builder notification, int notId, Bundle bundle) { List bundles = notificationMessages.get(Integer.toString(notId)); + if (ENABLE_VERBOSE_LOGS) { + android.util.Log.d(TAG, "[notificationStyle] notId=" + notId + ", bundles=" + (bundles != null ? bundles.size() : "null")); + if (bundles != null && bundles.size() > 0) { + Bundle firstBundle = bundles.get(0); + android.util.Log.d(TAG, "[notificationStyle] first bundle.message length=" + (firstBundle.getString("message") != null ? firstBundle.getString("message").length() : 0)); + android.util.Log.d(TAG, "[notificationStyle] first bundle.notificationLoaded=" + firstBundle.getBoolean("notificationLoaded", false)); + } + } + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.N) { Notification.InboxStyle messageStyle = new Notification.InboxStyle(); if (bundles != null) { 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 e926e9271f1..c12017dd0bc 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 @@ -12,6 +12,8 @@ import java.math.BigInteger; +import chat.rocket.reactnative.BuildConfig; + class RNCallback implements Callback { public void invoke(Object... args) { @@ -64,7 +66,7 @@ public Ejson() { * needs access to React-specific keystore resources. This means MMKV cannot be initialized * before React Native starts. */ - private void ensureMMKVInitialized() { + private synchronized void ensureMMKVInitialized() { if (initializationAttempted) { return; } @@ -105,28 +107,112 @@ private void ensureMMKVInitialized() { } public String getAvatarUri() { - if (type == null) { + if (sender == null || sender.username == null || sender.username.isEmpty()) { + Log.w(TAG, "Cannot generate avatar URI: sender or username is null"); + return null; + } + + String server = serverURL(); + if (server == null || server.isEmpty()) { + Log.w(TAG, "Cannot generate avatar URI: serverURL is null"); + return null; + } + + String userToken = token(); + String uid = userId(); + + if (userToken.isEmpty() || uid.isEmpty()) { + Log.w(TAG, "Cannot generate avatar URI: missing auth credentials (token=" + !userToken.isEmpty() + ", uid=" + !uid.isEmpty() + ")"); return null; } - return serverURL() + "/avatar/" + this.sender.username + "?rc_token=" + token() + "&rc_uid=" + userId(); + + String uri = server + "/avatar/" + sender.username + "?format=png&size=100&rc_token=" + userToken + "&rc_uid=" + uid; + + if (BuildConfig.DEBUG) { + Log.d(TAG, "Generated avatar URI for user: " + sender.username); + } + + return uri; } public String token() { ensureMMKVInitialized(); String userId = userId(); - if (mmkv != null && userId != null) { - return mmkv.decodeString(TOKEN_KEY.concat(userId)); + + if (mmkv == null) { + Log.e(TAG, "token() called but MMKV is null"); + return ""; + } + + if (userId == null || userId.isEmpty()) { + Log.w(TAG, "token() called but userId is null or empty"); + return ""; } - return ""; + + String key = TOKEN_KEY.concat(userId); + if (BuildConfig.DEBUG) { + Log.d(TAG, "Looking up token with key: " + key); + } + + String token = mmkv.decodeString(key); + + if (token == null || token.isEmpty()) { + Log.w(TAG, "No token found in MMKV for userId"); + } else if (BuildConfig.DEBUG) { + Log.d(TAG, "Successfully retrieved token from MMKV"); + } + + return token != null ? token : ""; } public String userId() { ensureMMKVInitialized(); String serverURL = serverURL(); - if (mmkv != null && serverURL != null) { - return mmkv.decodeString(TOKEN_KEY.concat(serverURL)); + String key = TOKEN_KEY.concat(serverURL); + + if (mmkv == null) { + Log.e(TAG, "userId() called but MMKV is null"); + return ""; } - return ""; + + if (serverURL == null) { + Log.e(TAG, "userId() called but serverURL is null"); + return ""; + } + + if (BuildConfig.DEBUG) { + Log.d(TAG, "Looking up userId with key: " + key); + } + + String userId = mmkv.decodeString(key); + + if (userId == null || userId.isEmpty()) { + Log.w(TAG, "No userId found in MMKV for server: " + NotificationHelper.sanitizeUrl(serverURL)); + + // Only list keys in debug builds for diagnostics + if (BuildConfig.DEBUG) { + try { + String[] allKeys = mmkv.allKeys(); + if (allKeys != null && allKeys.length > 0) { + Log.d(TAG, "Available MMKV keys count: " + allKeys.length); + // Log only keys that match the TOKEN_KEY pattern for security + for (String k : allKeys) { + if (k != null && k.startsWith("reactnativemeteor_usertoken")) { + Log.d(TAG, "Found auth key: " + k); + } + } + } else { + Log.w(TAG, "MMKV has no keys stored"); + } + } catch (Exception e) { + Log.e(TAG, "Error listing MMKV keys", e); + } + } + } else if (BuildConfig.DEBUG) { + Log.d(TAG, "Successfully retrieved userId from MMKV"); + } + + return userId != null ? userId : ""; } public String privateKey() { @@ -158,4 +244,4 @@ static class Content { String kid; String iv; } -} +} \ No newline at end of file diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/LoadNotification.java b/android/app/src/main/java/chat/rocket/reactnative/notification/LoadNotification.java index afd9940de71..6b6158393fc 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/LoadNotification.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/LoadNotification.java @@ -1,14 +1,20 @@ package chat.rocket.reactnative.notification; import android.os.Bundle; +import android.util.Log; import com.facebook.react.bridge.ReactApplicationContext; import com.google.gson.Gson; +import com.google.gson.JsonSyntaxException; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; import okhttp3.HttpUrl; import okhttp3.OkHttpClient; import okhttp3.Request; import okhttp3.Response; +import okhttp3.ResponseBody; class JsonResponse { Data data; @@ -31,54 +37,163 @@ class Payload { String notificationType; String name; String messageType; + String senderName; + String msg; + String tmid; + Content content; class Sender { String _id; String username; String name; } + + class Content { + String algorithm; + String ciphertext; + String kid; + String iv; + } } } } } public class LoadNotification { + private static final String TAG = "RocketChat.LoadNotif"; private int RETRY_COUNT = 0; private int[] TIMEOUT = new int[]{0, 1, 3, 5, 10}; private String TOKEN_KEY = "reactnativemeteor_usertoken-"; public void load(ReactApplicationContext reactApplicationContext, final Ejson ejson, Callback callback) { - final OkHttpClient client = new OkHttpClient(); - HttpUrl.Builder url = HttpUrl.parse(ejson.serverURL().concat("/api/v1/push.get")).newBuilder(); + Log.i(TAG, "Starting notification load for message-id-only notification"); + + // Validate ejson object + if (ejson == null) { + Log.e(TAG, "Failed to load notification: ejson is null"); + callback.call(null); + return; + } + + final String serverURL = ejson.serverURL(); + final String messageId = ejson.messageId; + + Log.d(TAG, "Notification payload - serverURL: " + NotificationHelper.sanitizeUrl(serverURL) + ", messageId: " + (messageId != null ? "[present]" : "[null]")); + + // Validate required fields + if (serverURL == null || serverURL.isEmpty()) { + Log.e(TAG, "Failed to load notification: serverURL is null or empty"); + callback.call(null); + return; + } + + if (messageId == null || messageId.isEmpty()) { + Log.e(TAG, "Failed to load notification: messageId is null or empty"); + callback.call(null); + return; + } final String userId = ejson.userId(); final String userToken = ejson.token(); - if (userId == null || userToken == null) { + if (userId == null || userId.isEmpty()) { + Log.w(TAG, "Failed to load notification: userId is null or empty (user may not be logged in)"); + callback.call(null); + return; + } + + if (userToken == null || userToken.isEmpty()) { + Log.w(TAG, "Failed to load notification: userToken is null or empty (user may not be logged in)"); + callback.call(null); + return; + } + + // Configure OkHttpClient with proper timeouts + final OkHttpClient client = new OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + HttpUrl.Builder urlBuilder; + try { + urlBuilder = HttpUrl.parse(serverURL.concat("/api/v1/push.get")).newBuilder(); + } catch (Exception e) { + Log.e(TAG, "Failed to parse server URL: " + NotificationHelper.sanitizeUrl(serverURL), e); + callback.call(null); return; } Request request = new Request.Builder() .header("x-user-id", userId) .header("x-auth-token", userToken) - .url(url.addQueryParameter("id", ejson.messageId).build()) + .url(urlBuilder.addQueryParameter("id", messageId).build()) .build(); + + String sanitizedEndpoint = NotificationHelper.sanitizeUrl(serverURL) + "/api/v1/push.get"; + Log.d(TAG, "Built request to endpoint: " + sanitizedEndpoint); - runRequest(client, request, callback); + runRequest(client, request, callback, sanitizedEndpoint); } - private void runRequest(OkHttpClient client, Request request, Callback callback) { + private void runRequest(OkHttpClient client, Request request, Callback callback, String sanitizedEndpoint) { try { - Thread.sleep(TIMEOUT[RETRY_COUNT] * 1000); + int delay = TIMEOUT[RETRY_COUNT]; + if (delay > 0) { + Log.d(TAG, "Retry attempt " + RETRY_COUNT + ", waiting " + delay + " seconds before request"); + } else { + Log.d(TAG, "Attempt " + (RETRY_COUNT + 1) + ", executing request to " + sanitizedEndpoint); + } + + Thread.sleep(delay * 1000); Response response = client.newCall(request).execute(); - String body = response.body().string(); + int statusCode = response.code(); + + ResponseBody responseBody = response.body(); + if (responseBody == null) { + Log.e(TAG, "Request failed: response body is null (status: " + statusCode + ")"); + throw new IOException("Response body is null"); + } + + String body = responseBody.string(); + if (!response.isSuccessful()) { - throw new Exception("Error"); + if (statusCode == 401 || statusCode == 403) { + Log.w(TAG, "Authentication failed: HTTP " + statusCode + " - user may need to re-login"); + } else if (statusCode >= 500) { + Log.e(TAG, "Server error: HTTP " + statusCode + " - server may be experiencing issues"); + } else { + Log.w(TAG, "Request failed with HTTP " + statusCode); + } + throw new IOException("HTTP " + statusCode); } + + Log.i(TAG, "Successfully received response (HTTP " + statusCode + "), parsing notification data"); Gson gson = new Gson(); - JsonResponse json = gson.fromJson(body, JsonResponse.class); + JsonResponse json; + try { + json = gson.fromJson(body, JsonResponse.class); + } catch (JsonSyntaxException e) { + Log.e(TAG, "Failed to parse JSON response", e); + throw e; + } + + // Validate parsed response structure + if (json == null || json.data == null || json.data.notification == null) { + Log.e(TAG, "Invalid response structure: missing required fields"); + throw new IllegalStateException("Invalid response structure"); + } + + // Log encryption fields if present + if (json.data.notification.payload != null) { + boolean hasEncryption = json.data.notification.payload.msg != null || json.data.notification.payload.content != null; + if (hasEncryption) { + Log.d(TAG, "Notification contains encrypted content: msg=" + (json.data.notification.payload.msg != null) + + ", content=" + (json.data.notification.payload.content != null)); + } + } Bundle bundle = new Bundle(); bundle.putString("notId", json.data.notification.notId); @@ -87,15 +202,33 @@ private void runRequest(OkHttpClient client, Request request, Callback callback) bundle.putString("ejson", gson.toJson(json.data.notification.payload)); bundle.putBoolean("notificationLoaded", true); + Log.i(TAG, "Successfully loaded and parsed notification data"); callback.call(bundle); + } catch (IOException e) { + Log.e(TAG, "Network error on attempt " + (RETRY_COUNT + 1) + ": " + e.getClass().getSimpleName() + " - " + e.getMessage()); + handleRetryOrFailure(client, request, callback, sanitizedEndpoint); + } catch (JsonSyntaxException e) { + Log.e(TAG, "JSON parsing error: " + e.getMessage()); + handleRetryOrFailure(client, request, callback, sanitizedEndpoint); + } catch (InterruptedException e) { + Log.e(TAG, "Request interrupted: " + e.getMessage()); + Thread.currentThread().interrupt(); // Restore interrupt status + callback.call(null); } catch (Exception e) { - if (RETRY_COUNT <= TIMEOUT.length) { - RETRY_COUNT++; - runRequest(client, request, callback); - } else { - callback.call(null); - } + Log.e(TAG, "Unexpected error on attempt " + (RETRY_COUNT + 1) + ": " + e.getClass().getSimpleName() + " - " + e.getMessage()); + handleRetryOrFailure(client, request, callback, sanitizedEndpoint); + } + } + + private void handleRetryOrFailure(OkHttpClient client, Request request, Callback callback, String sanitizedEndpoint) { + if (RETRY_COUNT < TIMEOUT.length - 1) { + RETRY_COUNT++; + Log.d(TAG, "Will retry request (attempt " + (RETRY_COUNT + 1) + " of " + TIMEOUT.length + ")"); + runRequest(client, request, callback, sanitizedEndpoint); + } else { + Log.e(TAG, "All retry attempts exhausted (" + TIMEOUT.length + " attempts). Notification load failed."); + callback.call(null); } } } diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java new file mode 100644 index 00000000000..ac2f1256301 --- /dev/null +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/NotificationHelper.java @@ -0,0 +1,27 @@ +package chat.rocket.reactnative.notification; + +import chat.rocket.reactnative.BuildConfig; + +/** + * Shared utility methods for notification handling + */ +public class NotificationHelper { + + /** + * Sanitize URL for logging by removing sensitive information + * @param url The URL to sanitize + * @return Full URL in debug builds, generic placeholder in production + */ + public static String sanitizeUrl(String url) { + if (url == null) { + return "[null]"; + } + // In debug builds, show full URL for debugging + // In production, hide workspace URLs for privacy + if (BuildConfig.DEBUG) { + return url; + } + return "[workspace]"; + } +} + diff --git a/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java b/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java index 6b096122e17..428332c3bf3 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java +++ b/android/app/src/main/java/chat/rocket/reactnative/notification/ReplyBroadcast.java @@ -51,7 +51,12 @@ public void onReceive(Context context, Intent intent) { Gson gson = new Gson(); Ejson ejson = gson.fromJson(bundle.getString("ejson", "{}"), Ejson.class); - replyToMessage(ejson, Integer.parseInt(notId), message); + try { + int id = Integer.parseInt(notId); + replyToMessage(ejson, id, message); + } catch (NumberFormatException e) { + Log.e("RocketChat.ReplyBroadcast", "Invalid notification ID: " + notId, e); + } } } diff --git a/app/lib/encryption/room.ts b/app/lib/encryption/room.ts index ff7dc5f5b35..685ddb214eb 100644 --- a/app/lib/encryption/room.ts +++ b/app/lib/encryption/room.ts @@ -19,7 +19,6 @@ import getSingleMessage from '../methods/getSingleMessage'; import type { IAttachment, IMessage, - IUpload, TSendFileMessageFileInfo, IServerAttachment, TSubscriptionModel, @@ -482,46 +481,20 @@ export default class EncryptionRoom { try { const content = await this.encryptText(EJSON.stringify({ msg: message.msg || '' })); - return { + const encryptedMessage = { ...message, t: E2E_MESSAGE_TYPE, e2e: E2E_STATUS.PENDING, e2eMentions: getE2EEMentions(message.msg), content - }; - } catch (e) { - // Do nothing - console.error(e); - } + } as IMessage; - return message; - }; - - // Encrypt upload - encryptUpload = async (message: IUpload) => { - if (!this.ready) { - return message; - } + delete encryptedMessage.msg; - try { - let description = ''; - - if (message.description) { - const encryptedResult = await this.encryptText(EJSON.stringify({ msg: message.description })); - description = - encryptedResult.algorithm === 'rc.v1.aes-sha2' - ? encryptedResult.ciphertext - : EJSON.stringify({ kid: encryptedResult.kid, iv: encryptedResult.iv, ciphertext: encryptedResult.ciphertext }); - } - - return { - ...message, - t: E2E_MESSAGE_TYPE, - e2e: E2E_STATUS.PENDING, - description - }; - } catch { + return encryptedMessage; + } catch (e) { // Do nothing + console.error(e); } return message; diff --git a/app/lib/methods/sendFileMessage/sendFileMessageV2.ts b/app/lib/methods/sendFileMessage/sendFileMessageV2.ts index 80b9844f20d..30369aef2a9 100644 --- a/app/lib/methods/sendFileMessage/sendFileMessageV2.ts +++ b/app/lib/methods/sendFileMessage/sendFileMessageV2.ts @@ -73,7 +73,7 @@ export async function sendFileMessageV2( 'Content-Type': 'application/json' }, body: JSON.stringify({ - msg: file.msg || undefined, + msg: (content ? '' : file.msg) || undefined, tmid: tmid || undefined, description: file.description || undefined, t: content ? 'e2e' : undefined, diff --git a/ios/RocketChatRN.xcodeproj/project.pbxproj b/ios/RocketChatRN.xcodeproj/project.pbxproj index 2e5feffae44..ecc392b69e1 100644 --- a/ios/RocketChatRN.xcodeproj/project.pbxproj +++ b/ios/RocketChatRN.xcodeproj/project.pbxproj @@ -3034,7 +3034,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 4.67.0; + MARKETING_VERSION = 4.67.1; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = "$(inherited) -D EXPO_CONFIGURATION_DEBUG"; @@ -3079,7 +3079,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 4.67.0; + MARKETING_VERSION = 4.67.1; 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 5b21e49e333..c2190e5c443 100644 --- a/ios/RocketChatRN/Info.plist +++ b/ios/RocketChatRN/Info.plist @@ -28,7 +28,7 @@ CFBundlePackageType APPL CFBundleShortVersionString - 4.67.0 + 4.67.1 CFBundleSignature ???? CFBundleURLTypes diff --git a/ios/ShareRocketChatRN/Info.plist b/ios/ShareRocketChatRN/Info.plist index 3f7e0666061..e33ed2f36db 100644 --- a/ios/ShareRocketChatRN/Info.plist +++ b/ios/ShareRocketChatRN/Info.plist @@ -26,7 +26,7 @@ CFBundlePackageType XPC! CFBundleShortVersionString - 4.67.0 + 4.67.1 CFBundleVersion 1 KeychainGroup diff --git a/package.json b/package.json index d441c006c7a..d0bdcd2a3bb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "rocket-chat-reactnative", - "version": "4.67.0", + "version": "4.67.1", "private": true, "packageManager": "yarn@1.22.22", "scripts": {