diff --git a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt index 53c06df7f3..ea5b43b871 100644 --- a/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt +++ b/android/app/src/main/java/chat/rocket/reactnative/MainApplication.kt @@ -54,12 +54,10 @@ open class MainApplication : Application(), ReactApplication, INotificationsAppl SoLoader.init(this, OpenSourceMergedSoMapping) Bugsnag.start(this) - if (BuildConfig.IS_NEW_ARCHITECTURE_ENABLED) { - // If you opted-in for the New Architecture, we load the native entry point for this app. - load() - } + // Load entry point for the new architecture + load() - reactNativeHost.reactInstanceManager.addReactInstanceEventListener(object : ReactInstanceEventListener { + reactHost.addReactInstanceEventListener(object : ReactInstanceEventListener { override fun onReactContextInitialized(context: ReactContext) { CustomPushNotification.setReactContext(context as ReactApplicationContext) } 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 2a1e13273b..c974082474 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 @@ -69,11 +69,15 @@ public void onReceived() throws InvalidNotificationException { Ejson receivedEjson = safeFromJson(received.getString("ejson", "{}"), Ejson.class); if (receivedEjson != null && receivedEjson.notificationType != null && receivedEjson.notificationType.equals("message-id-only")) { + android.util.Log.d("RocketChat.CustomPush", "Detected message-id-only notification, will fetch full content from server"); notificationLoad(receivedEjson, new Callback() { @Override public void call(@Nullable Bundle bundle) { if (bundle != null) { + android.util.Log.d("RocketChat.CustomPush", "Successfully loaded notification content from server, updating notification props"); mNotificationProps = createProps(bundle); + } else { + android.util.Log.w("RocketChat.CustomPush", "Failed to load notification content from server, will display placeholder notification"); } } }); @@ -154,6 +158,7 @@ protected Notification.Builder getNotificationBuilder(PendingIntent intent) { // message couldn't be loaded from server (Fallback notification) } else { + android.util.Log.w("RocketChat.CustomPush", "Displaying fallback notification for message-id-only (content failed to load from server)"); Gson gson = new Gson(); // iterate over the current notification ids to dismiss fallback notifications from same server for (Map.Entry> bundleList : notificationMessages.entrySet()) { @@ -168,6 +173,7 @@ protected Notification.Builder getNotificationBuilder(PendingIntent intent) { String id = not.getString("notId"); // cancel this notification notificationManager.cancel(Integer.parseInt(id)); + android.util.Log.d("RocketChat.CustomPush", "Cancelled previous fallback notification from same server"); } } } 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 2d21073885..d134f5b7d4 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) { @@ -29,12 +31,15 @@ static public String toHex(String arg) { } public class Ejson { + private static final String TAG = "RocketChat.Ejson"; + String host; String rid; String type; Sender sender; String messageId; String notificationType; + String messageType; String senderName; String msg; @@ -45,35 +50,59 @@ public class Ejson { private ReactApplicationContext reactContext; private MMKV mmkv; + + private boolean initializationAttempted = false; private String TOKEN_KEY = "reactnativemeteor_usertoken-"; public Ejson() { - AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); - if (facade != null && facade.getRunningReactContext() instanceof ReactApplicationContext) { - this.reactContext = (ReactApplicationContext) facade.getRunningReactContext(); + // Don't initialize MMKV in constructor - use lazy initialization instead + } + + /** + * Lazily initialize MMKV when first needed. + * + * NOTE: MMKV requires ReactApplicationContext (not regular Context) because SecureKeystore + * needs access to React-specific keystore resources. This means MMKV cannot be initialized + * before React Native starts. + */ + private void ensureMMKVInitialized() { + if (initializationAttempted) { + return; } - - // Only initialize MMKV if we have a valid React context - if (this.reactContext != null) { + + initializationAttempted = true; + + // Try to get ReactApplicationContext from available sources + if (this.reactContext == null) { + AppLifecycleFacade facade = AppLifecycleFacadeHolder.get(); + if (facade != null) { + Object runningContext = facade.getRunningReactContext(); + if (runningContext instanceof ReactApplicationContext) { + this.reactContext = (ReactApplicationContext) runningContext; + } + } + + if (this.reactContext == null) { + this.reactContext = CustomPushNotification.reactApplicationContext; + } + } + + // Initialize MMKV if context is available + if (this.reactContext != null && mmkv == null) { try { - // Start MMKV container MMKV.initialize(this.reactContext); SecureKeystore secureKeystore = new SecureKeystore(this.reactContext); - - // https://github.com/ammarahm-ed/react-native-mmkv-storage/blob/master/src/loader.js#L31 + // Alias format from react-native-mmkv-storage String alias = Utils.toHex("com.MMKV.default"); - - // Retrieve container password String password = secureKeystore.getSecureKey(alias); mmkv = MMKV.mmkvWithID("default", MMKV.SINGLE_PROCESS_MODE, password); } catch (Exception e) { - Log.e("Ejson", "Failed to initialize MMKV: " + e.getMessage()); + Log.e(TAG, "Failed to initialize MMKV", e); mmkv = null; } - } else { - Log.w("Ejson", "React context is null, MMKV will not be initialized"); - mmkv = null; + } else if (this.reactContext == null) { + Log.w(TAG, "Cannot initialize MMKV: ReactApplicationContext not available"); } } @@ -85,22 +114,87 @@ public String getAvatarUri() { } 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 ""; + } + + String key = TOKEN_KEY.concat(userId); + if (BuildConfig.DEBUG) { + Log.d(TAG, "Looking up token with key: " + key); } - return ""; + + 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: " + 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() { + ensureMMKVInitialized(); String serverURL = serverURL(); if (mmkv != null && serverURL != null) { return mmkv.decodeString(serverURL.concat("-RC_E2E_PRIVATE_KEY")); @@ -116,13 +210,41 @@ public String serverURL() { return url; } - public static class Sender { - String username; + static class Sender { String _id; + String username; + String name; } - public static class Content { - String ciphertext; + static class Content { String algorithm; + String ciphertext; + String kid; + String iv; } -} + + /** + * Sanitize URL for logging by removing sensitive information + * @param url The URL to sanitize + * @return Sanitized URL showing only the protocol and host + */ + private String sanitizeUrl(String url) { + if (url == null) { + return "[null]"; + } + try { + // Simple sanitization - just show protocol and host + if (url.startsWith("http://") || url.startsWith("https://")) { + int protocolEnd = url.indexOf("://") + 3; + int pathStart = url.indexOf("/", protocolEnd); + if (pathStart != -1) { + return url.substring(0, pathStart); + } + return url; + } + } catch (Exception e) { + // If parsing fails, just return a generic placeholder + } + return "[url]"; + } +} \ 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 afd9940de7..b572db6eb0 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; @@ -43,42 +49,131 @@ class Sender { } 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: " + 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: " + 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 = 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"); + } Bundle bundle = new Bundle(); bundle.putString("notId", json.data.notification.notId); @@ -87,15 +182,53 @@ 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); + } + } + + /** + * Sanitize URL for logging by removing sensitive information + * @param url The URL to sanitize + * @return Sanitized URL showing only the protocol and host + */ + private String sanitizeUrl(String url) { + if (url == null) { + return "[null]"; + } + try { + HttpUrl httpUrl = HttpUrl.parse(url); + if (httpUrl != null) { + return httpUrl.scheme() + "://" + httpUrl.host(); } + } catch (Exception e) { + // If parsing fails, just return a generic placeholder } + return "[url]"; } }