From 6662d7dc7b68b1fb28cdd47d77b5da4e2dd6d5fc Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 10 Mar 2026 08:29:38 +0000 Subject: [PATCH 1/7] [SDK-368] Fix in-app creation causing crash due to problems with webview (#999) --- ...IterableInAppFragmentHTMLNotification.java | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java index b6b9a99f6..51894424f 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableInAppFragmentHTMLNotification.java @@ -4,6 +4,7 @@ import android.app.Dialog; import android.content.Context; import android.content.DialogInterface; +import android.content.res.Resources; import android.graphics.Color; import android.graphics.Point; import android.graphics.Rect; @@ -200,9 +201,12 @@ public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup c applyWindowGravity(getDialog().getWindow(), "onCreateView"); } - webView = new IterableWebView(getContext()); + webView = createWebViewSafely(getContext()); + if (webView == null) { + dismissAllowingStateLoss(); + return null; + } webView.setId(R.id.webView); - webView.createWithHtml(this, htmlString); if (orientationListener == null) { @@ -324,7 +328,9 @@ public void onSaveInstanceState(@NonNull Bundle outState) { */ @Override public void onStop() { - orientationListener.disable(); + if (orientationListener != null) { + orientationListener.disable(); + } super.onStop(); } @@ -747,6 +753,18 @@ InAppLayout getInAppLayout(Rect padding) { return InAppLayout.CENTER; } } + + private IterableWebView createWebViewSafely(Context context) { + try { + return new IterableWebView(context); + } catch (Resources.NotFoundException e) { + IterableLogger.e(TAG, "Failed to create WebView - system WebView resource issue", e); + return null; + } catch (RuntimeException e) { + IterableLogger.e(TAG, "Failed to create WebView - unexpected error", e); + return null; + } + } } enum InAppLayout { From b99a17ad207574983e12fb9edcf55cbda36d60d7 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 10 Mar 2026 10:16:33 +0000 Subject: [PATCH 2/7] [SDK-361] Preventing push notification from killing exisitng activity (#994) --- iterableapi/src/main/AndroidManifest.xml | 1 + .../com/iterable/iterableapi/IterableNotificationBuilder.java | 2 +- .../com/iterable/iterableapi/IterableNotificationHelper.java | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/iterableapi/src/main/AndroidManifest.xml b/iterableapi/src/main/AndroidManifest.xml index 5c55dbe8f..2684877a7 100644 --- a/iterableapi/src/main/AndroidManifest.xml +++ b/iterableapi/src/main/AndroidManifest.xml @@ -29,6 +29,7 @@ android:name=".IterableTrampolineActivity" android:exported="false" android:launchMode="singleTask" + android:taskAffinity="" android:excludeFromRecents="true" android:theme="@style/TrampolineActivity.Transparent"/> diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationBuilder.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationBuilder.java index 346fce45d..97ad1d0eb 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationBuilder.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationBuilder.java @@ -135,7 +135,7 @@ private PendingIntent getPendingIntent(Context context, IterableNotificationData if (button.openApp) { IterableLogger.d(TAG, "Go through TrampolineActivity"); buttonIntent.setClass(context, IterableTrampolineActivity.class); - buttonIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + buttonIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); pendingButtonIntent = PendingIntent.getActivity(context, buttonIntent.hashCode(), buttonIntent, pendingIntentFlag); } else { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java index 2625b32bf..f005010c2 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableNotificationHelper.java @@ -194,7 +194,7 @@ public IterableNotificationBuilder createNotification(Context context, Bundle ex trampolineActivityIntent.setClass(context, IterableTrampolineActivity.class); trampolineActivityIntent.putExtras(extras); trampolineActivityIntent.putExtra(IterableConstants.ITERABLE_DATA_ACTION_IDENTIFIER, IterableConstants.ITERABLE_ACTION_DEFAULT); - trampolineActivityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TASK); + trampolineActivityIntent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); // Action buttons if (notificationData.getActionButtons() != null) { From fe7d91c52f4ed3a5ed17097dcf1fb42ea9c8f026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Sat, 7 Feb 2026 20:39:07 -0800 Subject: [PATCH 3/7] [SDK-342] - AuthRetry logic Includes 1. getRemoteConfiguration checking for the flag. And assuming false if no values exist. Also stores in sharedPreferences to load from it next time. 2. isAutoRetryOnJWTFailure on `IterableAPI` which other classes with use. Not public. 3. Introduced three states which Taskmanager can rely on. VALID, INVALID, UNKNOWN. 4. Listener pub/sub added which will invoke onAuthTokenReady() on listner class. Adding array of AuthTokenReadyListener on Authmanager and its add and remove list methods. 5. Right now authHandler null returns true hoping to bypass jwt. But JWT based request will fail if API needs jwt. This could pile up storage. But this seems like a valid scenario 6. handleAuthTokenSucess sets authtoken to unknown when a new token is set. I hope it should be right approach. Because its new and unknown at this point. 7. `IterableConfig` has final boolean `autoRetryOnJwtFailure`. Its not something I want customers to be able to configure. It should be removed before going to master. It should be internal only variable. 8. IterableRequestTask - has some changes introduced due to the flow not falling under jwt failure. Response code coming has -1 was the root cause. Hence the change around responseCode >= 0 && responseCode < 400 9. `IterableRequestTask` - Line 215 - Still not sure if RequestProcessor check should happen. It does make sense to have it as only offline stored request will be reprocessed. And this feature should only work for those requests going through offline Request processor 10. `IterableRequestTask` - fromJSON method change is something I havent checked properly. I think its for unit testing the changes are done. 11. IterableTaskRunner. Would like to see if I can remove the method - isJWTFailure(responseCode). May be its a small abstraction thats needed. 12. Havent checked unit tests properly yet --- .../com/iterable/iterableapi/IterableApi.java | 25 ++ .../iterableapi/IterableAuthManager.java | 81 +++++ .../iterable/iterableapi/IterableConfig.java | 23 ++ .../iterableapi/IterableConstants.java | 2 + .../iterableapi/IterableRequestTask.java | 71 ++++- .../iterableapi/IterableTaskRunner.java | 53 +++- .../iterableapi/OfflineRequestProcessor.java | 7 + .../iterableapi/IterableTaskRunnerTest.java | 280 +++++++++++++++++- 8 files changed, 525 insertions(+), 17 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 319968d6b..add052f23 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -43,6 +43,7 @@ public class IterableApi { private IterableNotificationData _notificationData; private String _deviceId; private boolean _firstForegroundHandled; + private boolean _autoRetryOnJwtFailure; private IterableHelper.SuccessHandler _setUserSuccessCallbackHandler; private IterableHelper.FailureHandler _setUserFailureCallbackHandler; @@ -104,6 +105,14 @@ public void execute(@Nullable String data) { SharedPreferences sharedPref = sharedInstance.getMainActivityContext().getSharedPreferences(IterableConstants.SHARED_PREFS_SAVED_CONFIGURATION, Context.MODE_PRIVATE); SharedPreferences.Editor editor = sharedPref.edit(); editor.putBoolean(IterableConstants.SHARED_PREFS_OFFLINE_MODE_KEY, offlineConfiguration); + + // Parse autoRetry flag from remote config. If not present, fall back to local config. + if (jsonData.has(IterableConstants.KEY_AUTO_RETRY)) { + boolean autoRetryRemote = jsonData.getBoolean(IterableConstants.KEY_AUTO_RETRY); + editor.putBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, autoRetryRemote); + _autoRetryOnJwtFailure = autoRetryRemote; + } + editor.apply(); } catch (JSONException e) { IterableLogger.e(TAG, "Failed to read remote configuration"); @@ -194,6 +203,22 @@ static void loadLastSavedConfiguration(Context context) { SharedPreferences sharedPref = context.getSharedPreferences(IterableConstants.SHARED_PREFS_SAVED_CONFIGURATION, Context.MODE_PRIVATE); boolean offlineMode = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_OFFLINE_MODE_KEY, false); sharedInstance.apiClient.setOfflineProcessingEnabled(offlineMode); + + // Load autoRetry: if a remote value was previously saved, use it; otherwise fall back to local config. + if (sharedPref.contains(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY)) { + sharedInstance._autoRetryOnJwtFailure = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, false); + } else { + sharedInstance._autoRetryOnJwtFailure = sharedInstance.config.autoRetryOnJwtFailure; + } + } + + /** + * Returns whether auto-retry on JWT failure is enabled. + * The remote configuration flag takes precedence when present; + * otherwise the local {@link IterableConfig#autoRetryOnJwtFailure} value is used. + */ + boolean isAutoRetryOnJwtFailure() { + return _autoRetryOnJwtFailure; } /** diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java index 915dbbb2a..3a960e54a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -9,6 +9,7 @@ import org.json.JSONObject; import java.io.UnsupportedEncodingException; +import java.util.ArrayList; import java.util.Timer; import java.util.TimerTask; import java.util.concurrent.ExecutorService; @@ -18,6 +19,25 @@ public class IterableAuthManager implements IterableActivityMonitor.AppStateCall private static final String TAG = "IterableAuth"; private static final String expirationString = "exp"; + /** + * Represents the state of the JWT auth token. + * VALID: Last request succeeded with this token. + * INVALID: A 401 JWT error was received; processing should pause. + * UNKNOWN: A new token was obtained but not yet verified by a request. + */ + enum AuthState { + VALID, + INVALID, + UNKNOWN + } + + /** + * Listener interface for components that need to react when a new auth token is ready. + */ + interface AuthTokenReadyListener { + void onAuthTokenReady(); + } + private final IterableApi api; private final IterableAuthHandler authHandler; private final long expiringAuthTokenRefreshPeriod; @@ -34,6 +54,9 @@ public class IterableAuthManager implements IterableActivityMonitor.AppStateCall private volatile boolean isTimerScheduled; private volatile boolean isInForeground = true; // Assume foreground initially + private volatile AuthState authState = AuthState.UNKNOWN; + private final ArrayList authTokenReadyListeners = new ArrayList<>(); + private final ExecutorService executor = Executors.newSingleThreadExecutor(); IterableAuthManager(IterableApi api, IterableAuthHandler authHandler, RetryPolicy authRetryPolicy, long expiringAuthTokenRefreshPeriod) { @@ -45,6 +68,58 @@ public class IterableAuthManager implements IterableActivityMonitor.AppStateCall this.activityMonitor.addCallback(this); } + void addAuthTokenReadyListener(AuthTokenReadyListener listener) { + authTokenReadyListeners.add(listener); + } + + void removeAuthTokenReadyListener(AuthTokenReadyListener listener) { + authTokenReadyListeners.remove(listener); + } + + /** + * Returns true if the auth token is in a state that allows requests to proceed. + * Requests can proceed when auth state is VALID or UNKNOWN (newly obtained token). + * If no authHandler is configured (JWT not used), this always returns true. + */ + boolean isAuthTokenReady() { + if (authHandler == null) { + return true; + } + return authState != AuthState.INVALID; + } + + /** + * Marks the auth token as invalid. Called when a 401 JWT error is received. + */ + void setAuthTokenInvalid() { + setAuthState(AuthState.INVALID); + } + + AuthState getAuthState() { + return authState; + } + + /** + * Centralized auth state setter. Notifies AuthTokenReadyListeners only when + * transitioning from INVALID to a ready state (UNKNOWN or VALID), which means + * a new token has been obtained after a prior auth failure. + */ + private void setAuthState(AuthState newState) { + AuthState previousState = this.authState; + this.authState = newState; + + if (previousState == AuthState.INVALID && newState != AuthState.INVALID) { + notifyAuthTokenReadyListeners(); + } + } + + private void notifyAuthTokenReadyListeners() { + ArrayList listenersCopy = new ArrayList<>(authTokenReadyListeners); + for (AuthTokenReadyListener listener : listenersCopy) { + listener.onAuthTokenReady(); + } + } + public synchronized void requestNewAuthToken(boolean hasFailedPriorAuth, IterableHelper.SuccessHandler successCallback) { requestNewAuthToken(hasFailedPriorAuth, successCallback, true); } @@ -61,6 +136,9 @@ void reset() { void setIsLastAuthTokenValid(boolean isValid) { isLastAuthTokenValid = isValid; + if (isValid) { + setAuthState(AuthState.VALID); + } } void resetRetryCount() { @@ -132,6 +210,9 @@ public void run() { private void handleAuthTokenSuccess(String authToken, IterableHelper.SuccessHandler successCallback) { if (authToken != null) { + // Token obtained but not yet verified by a request - set state to UNKNOWN. + // setAuthState will notify listeners only if previous state was INVALID. + setAuthState(AuthState.UNKNOWN); IterableApi.getInstance().setAuthToken(authToken); queueExpirationRefresh(authToken); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 6e4bf7c45..4c4a34ed6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -140,6 +140,14 @@ public class IterableConfig { @Nullable final IterableAPIMobileFrameworkInfo mobileFrameworkInfo; + /** + * When set to true, the SDK will automatically retry API requests that fail due to + * JWT authentication errors (401). Failed requests are retained in the local DB and + * processing is paused until a valid JWT token is obtained. + * This value can be overridden by the remote configuration flag `autoRetry`. + */ + final boolean autoRetryOnJwtFailure; + /** * Base URL for Webview content loading. Specifically used to enable CORS for external resources. * If null or empty, defaults to empty string (original behavior with about:blank origin). @@ -183,6 +191,7 @@ private IterableConfig(Builder builder) { decryptionFailureHandler = builder.decryptionFailureHandler; mobileFrameworkInfo = builder.mobileFrameworkInfo; webViewBaseUrl = builder.webViewBaseUrl; + autoRetryOnJwtFailure = builder.autoRetryOnJwtFailure; } public static class Builder { @@ -211,6 +220,7 @@ public static class Builder { private IterableIdentityResolution identityResolution = new IterableIdentityResolution(); private IterableUnknownUserHandler iterableUnknownUserHandler; private String webViewBaseUrl; + private boolean autoRetryOnJwtFailure = false; public Builder() {} @@ -453,6 +463,19 @@ public Builder setMobileFrameworkInfo(@NonNull IterableAPIMobileFrameworkInfo mo return this; } + /** + * Enable or disable automatic retry of API requests that fail due to JWT authentication + * errors (401). When enabled, failed requests are retained in the local DB and processing + * is paused until a valid JWT token is obtained. + * This value can be overridden by the remote configuration flag `autoRetry`. + * @param autoRetryOnJwtFailure `true` to enable auto-retry on JWT failure + */ + @NonNull + public Builder setAutoRetryOnJwtFailure(boolean autoRetryOnJwtFailure) { + this.autoRetryOnJwtFailure = autoRetryOnJwtFailure; + return this; + } + /** * Set the base URL for WebView content loading. Used to enable CORS for external resources. * If not set or null, defaults to empty string (original behavior with about:blank origin). diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java index 85c4b7066..eb2d3fc4d 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java @@ -56,6 +56,7 @@ public final class IterableConstants { public static final String KEY_INBOX_SESSION_ID = "inboxSessionId"; public static final String KEY_EMBEDDED_SESSION_ID = "id"; public static final String KEY_OFFLINE_MODE = "offlineMode"; + public static final String KEY_AUTO_RETRY = "autoRetry"; public static final String KEY_FIRETV = "FireTV"; public static final String KEY_CREATE_NEW_FIELDS = "createNewFields"; public static final String KEY_IS_USER_KNOWN = "isUserKnown"; @@ -130,6 +131,7 @@ public final class IterableConstants { public static final String SHARED_PREFS_FCM_MIGRATION_DONE_KEY = "itbl_fcm_migration_done"; public static final String SHARED_PREFS_SAVED_CONFIGURATION = "itbl_saved_configuration"; public static final String SHARED_PREFS_OFFLINE_MODE_KEY = "itbl_offline_mode"; + public static final String SHARED_PREFS_AUTO_RETRY_KEY = "itbl_auto_retry"; public static final String SHARED_PREFS_EVENT_LIST_KEY = "itbl_event_list"; public static final String SHARED_PREFS_USER_UPDATE_OBJECT_KEY = "itbl_user_update_object"; public static final String SHARED_PREFS_UNKNOWN_SESSIONS = "itbl_unknown_sessions"; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index f052da780..58903a7d0 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -18,6 +18,7 @@ import java.io.BufferedReader; import java.io.BufferedWriter; import java.io.IOException; +import java.io.InputStream; import java.io.InputStreamReader; import java.io.OutputStream; import java.io.OutputStreamWriter; @@ -153,20 +154,27 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque // Read the response body try { BufferedReader in; - if (responseCode < 400) { + if (responseCode >= 0 && responseCode < 400) { in = new BufferedReader( new InputStreamReader(urlConnection.getInputStream())); } else { - in = new BufferedReader( - new InputStreamReader(urlConnection.getErrorStream())); + InputStream errorStream = urlConnection.getErrorStream(); + if (errorStream != null) { + in = new BufferedReader( + new InputStreamReader(errorStream)); + } else { + in = null; + } } - String inputLine; - StringBuffer response = new StringBuffer(); - while ((inputLine = in.readLine()) != null) { - response.append(inputLine); + if (in != null) { + String inputLine; + StringBuffer response = new StringBuffer(); + while ((inputLine = in.readLine()) != null) { + response.append(inputLine); + } + in.close(); + requestResult = response.toString(); } - in.close(); - requestResult = response.toString(); } catch (IOException e) { logError(iterableApiRequest, baseUrl, e); error = e.getMessage(); @@ -186,13 +194,36 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque jsonError = e.getMessage(); } + // If getResponseCode() returned -1 (e.g. due to network inspector + // interference) but the response body contains JWT error codes, + // we can infer the actual response was a 401. + if (responseCode == -1 && matchesJWTErrorCodes(jsonResponse)) { + responseCode = 401; + } + // Handle HTTP status codes if (responseCode == 401) { if (matchesJWTErrorCodes(jsonResponse)) { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "JWT Authorization header error"); IterableApi.getInstance().getAuthManager().handleAuthFailure(iterableApiRequest.authToken, getMappedErrorCodeForMessage(jsonResponse)); - // We handle the JWT Retry for both online and offline here rather than handling online request in onPostExecute - requestNewAuthTokenAndRetry(iterableApiRequest); + + // [F] When autoRetry is enabled and this is an offline task, skip the inline + // retry. The task stays in the DB and IterableTaskRunner will retry it once + // a valid JWT is obtained via the AuthTokenReadyListener callback. + // For online requests or when autoRetry is disabled, use the existing inline retry. + boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); + if (autoRetry && iterableApiRequest.getProcessorType() == IterableApiRequest.ProcessorType.OFFLINE) { + // Schedule a delayed token refresh (respects retry policy). + // Do NOT retry the request inline -- IterableTaskRunner will handle + // the retry after the AuthTokenReadyListener callback fires. + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + authManager.setIsLastAuthTokenValid(false); + long retryInterval = authManager.getNextRetryInterval(); + authManager.scheduleAuthTokenRefresh(retryInterval, false, null); + } else { + // Existing behavior: retry request inline after obtaining new token + requestNewAuthTokenAndRetry(iterableApiRequest); + } } else { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "Invalid API Key"); } @@ -498,13 +529,27 @@ public JSONObject toJSONObject() throws JSONException { } static IterableApiRequest fromJSON(JSONObject jsonData, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { + return fromJSON(jsonData, null, onSuccess, onFailure); + } + + /** + * Deserializes an IterableApiRequest from JSON. + * @param authTokenOverride If non-null, uses this token instead of the one stored in JSON. + * This allows offline tasks to use the latest auth token rather + * than the stale one captured at queue time. + */ + static IterableApiRequest fromJSON(JSONObject jsonData, @Nullable String authTokenOverride, @Nullable IterableHelper.SuccessHandler onSuccess, @Nullable IterableHelper.FailureHandler onFailure) { try { String apikey = jsonData.getString("apiKey"); String resourcePath = jsonData.getString("resourcePath"); String requestType = jsonData.getString("requestType"); - String authToken = ""; - if (jsonData.has("authToken")) { + String authToken; + if (authTokenOverride != null) { + authToken = authTokenOverride; + } else if (jsonData.has("authToken")) { authToken = jsonData.getString("authToken"); + } else { + authToken = ""; } JSONObject json = jsonData.getJSONObject("data"); return new IterableApiRequest(apikey, resourcePath, json, requestType, authToken, onSuccess, onFailure); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java index d27e7102d..7ee1c2363 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java @@ -14,7 +14,7 @@ import java.util.ArrayList; -class IterableTaskRunner implements IterableTaskStorage.TaskCreatedListener, Handler.Callback, IterableNetworkConnectivityManager.IterableNetworkMonitorListener, IterableActivityMonitor.AppStateCallback { +class IterableTaskRunner implements IterableTaskStorage.TaskCreatedListener, Handler.Callback, IterableNetworkConnectivityManager.IterableNetworkMonitorListener, IterableActivityMonitor.AppStateCallback, IterableAuthManager.AuthTokenReadyListener { private static final String TAG = "IterableTaskRunner"; private IterableTaskStorage taskStorage; private IterableActivityMonitor activityMonitor; @@ -39,6 +39,9 @@ interface TaskCompletedListener { private ArrayList taskCompletedListeners = new ArrayList<>(); + // Tracks whether processing is paused due to a JWT auth failure + private volatile boolean isPausedForAuth = false; + IterableTaskRunner(IterableTaskStorage taskStorage, IterableActivityMonitor activityMonitor, IterableNetworkConnectivityManager networkConnectivityManager, @@ -87,6 +90,12 @@ public void onSwitchToBackground() { } + @Override + public void onAuthTokenReady() { + isPausedForAuth = false; + runNow(); + } + private synchronized void runNow() { handler.removeMessages(OPERATION_PROCESS_TASKS); handler.sendEmptyMessage(OPERATION_PROCESS_TASKS); @@ -118,7 +127,15 @@ private void processTasks() { return; } + boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); + while (networkConnectivityManager.isConnected()) { + // [F] When autoRetry is enabled, also check that auth token is ready before processing + if (autoRetry && !IterableApi.getInstance().getAuthManager().isAuthTokenReady()) { + IterableLogger.d(TAG, "Auth token not ready, pausing task processing"); + return; + } + IterableTask task = taskStorage.getNextScheduledTask(); if (task == null) { @@ -127,7 +144,11 @@ private void processTasks() { boolean proceed = processTask(task); if (!proceed) { - scheduleRetry(); + // Only schedule timed retry for non-auth failures. + // Auth failures will resume via onAuthTokenReady() callback. + if (!autoRetry || !isPausedForAuth) { + scheduleRetry(); + } return; } } @@ -135,11 +156,16 @@ private void processTasks() { @WorkerThread private boolean processTask(@NonNull IterableTask task) { + isPausedForAuth = false; + if (task.taskType == IterableTaskType.API) { IterableApiResponse response = null; TaskResult result = TaskResult.FAILURE; try { - IterableApiRequest request = IterableApiRequest.fromJSON(getTaskDataWithDate(task), null, null); + // Use the current live auth token instead of the stale one stored in the DB. + // The token in the DB was captured at queue time and may have since expired. + String currentAuthToken = IterableApi.getInstance().getAuthToken(); + IterableApiRequest request = IterableApiRequest.fromJSON(getTaskDataWithDate(task), currentAuthToken, null, null); request.setProcessorType(IterableApiRequest.ProcessorType.OFFLINE); response = IterableRequestTask.executeApiRequest(request); } catch (Exception e) { @@ -147,10 +173,22 @@ private boolean processTask(@NonNull IterableTask task) { healthMonitor.onDBError(); } + boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); + if (response != null) { if (response.success) { result = TaskResult.SUCCESS; } else { + // [F] If autoRetry is enabled and response is a 401 JWT error, + // retain the task and pause processing until a valid JWT is obtained. + if (autoRetry && isJwtFailure(response)) { + IterableLogger.d(TAG, "JWT auth failure on task " + task.id + ". Retaining task and pausing processing."); + IterableApi.getInstance().getAuthManager().setAuthTokenInvalid(); + isPausedForAuth = true; + callTaskCompletedListeners(task.id, TaskResult.RETRY, response); + return false; + } + if (isRetriableError(response.errorMessage)) { result = TaskResult.RETRY; } else { @@ -185,6 +223,15 @@ private boolean isRetriableError(String errorMessage) { return errorMessage.contains("failed to connect"); } + /** + * Checks if the response indicates a JWT authentication failure (401). + * In the offline processing context, the API key is known to be valid (the task was + * queued with it), so any 401 response is a JWT auth error. + */ + private boolean isJwtFailure(IterableApiResponse response) { + return response.responseCode == 401; + } + @WorkerThread private void callTaskCompletedListeners(final String taskId, final TaskResult result, final IterableApiResponse response) { for (final TaskCompletedListener listener : taskCompletedListeners) { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java index e60b08293..dc5060a93 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java @@ -43,6 +43,13 @@ class OfflineRequestProcessor implements RequestProcessor { networkConnectivityManager, healthMonitor); taskScheduler = new TaskScheduler(taskStorage, taskRunner); + + // Register task runner as auth token ready listener for JWT auto-retry support + try { + IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); + } catch (Exception e) { + IterableLogger.d("OfflineRequestProcessor", "AuthManager not available yet for listener registration"); + } } @VisibleForTesting diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java index b9145748d..bd5059048 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java @@ -16,20 +16,24 @@ import static android.os.Looper.getMainLooper; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.doReturn; import static org.robolectric.Shadows.shadowOf; @RunWith(TestRunner.class) -public class IterableTaskRunnerTest { +public class IterableTaskRunnerTest extends BaseTest { private IterableTaskRunner taskRunner; private IterableTaskStorage mockTaskStorage; private IterableActivityMonitor mockActivityMonitor; @@ -51,6 +55,7 @@ public void setUp() throws Exception { @After public void tearDown() throws Exception { server.shutdown(); + IterableTestUtils.resetIterableApi(); } @Test @@ -161,6 +166,279 @@ public void testIfNetworkCheckedBeforeProcessingTask() throws Exception { verify(mockNetworkConnectivityManager, times(2)).isConnected(); } + // region Auto-Retry on JWT Failure Tests + + /** + * Helper to create a JWT 401 error response body matching IterableRequestTask's JWT error codes. + */ + private String createJwt401ResponseBody() throws Exception { + JSONObject body = new JSONObject(); + body.put("code", "InvalidJwtPayload"); + body.put("msg", "jwt token is expired"); + return body.toString(); + } + + /** + * Helper to initialize IterableApi with autoRetry enabled and a mock auth handler. + */ + private IterableAuthHandler initApiWithAutoRetry(boolean autoRetryEnabled) { + IterableApi.sharedInstance = new IterableApi(); + final IterableAuthHandler mockAuthHandler = mock(IterableAuthHandler.class); + doReturn(null).when(mockAuthHandler).onAuthTokenRequested(); + + IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { + @Override + public IterableConfig.Builder run(IterableConfig.Builder builder) { + return builder + .setAutoRetryOnJwtFailure(autoRetryEnabled) + .setAuthHandler(mockAuthHandler); + } + }); + return mockAuthHandler; + } + + @Test + public void testAutoRetryEnabled_JwtFailure_TaskRetainedInDB() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", "expired_token", null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // Server returns 401 with JWT error code + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(createJwt401ResponseBody())); + + IterableTaskRunner.TaskCompletedListener taskCompletedListener = mock(IterableTaskRunner.TaskCompletedListener.class); + taskRunner.addTaskCompletedListener(taskCompletedListener); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + // Task should NOT be deleted from DB when autoRetry is enabled + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + + // Completion listener should be called with RETRY result + shadowOf(getMainLooper()).idle(); + verify(taskCompletedListener).onTaskCompleted(any(String.class), eq(IterableTaskRunner.TaskResult.RETRY), any(IterableApiResponse.class)); + + // Auth state should be INVALID + assertEquals(IterableAuthManager.AuthState.INVALID, IterableApi.getInstance().getAuthManager().getAuthState()); + } + + @Test + public void testAutoRetryDisabled_JwtFailure_TaskDeletedFromDB() throws Exception { + initApiWithAutoRetry(false); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", "expired_token", null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // Server returns 401 with JWT error code + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(createJwt401ResponseBody())); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + // Task should be deleted from DB when autoRetry is disabled (existing behavior) + shadowOf(getMainLooper()).idle(); + verify(mockTaskStorage).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_ProcessingPausesWhenAuthInvalid() throws Exception { + initApiWithAutoRetry(true); + + // Mark auth as invalid + IterableApi.getInstance().getAuthManager().setAuthTokenInvalid(); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + // No request should be made because auth is invalid + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(recordedRequest); + + // Task should NOT be deleted since processing was paused + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_ProcessingResumesOnAuthTokenReady() throws Exception { + initApiWithAutoRetry(true); + + // Mark auth as invalid first + IterableApi.getInstance().getAuthManager().setAuthTokenInvalid(); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // First attempt: auth is invalid, should not process + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(recordedRequest); + + // Now simulate auth token becoming ready (UNKNOWN state, ready to make requests) + IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(false); // Reset state + // Manually set auth state to UNKNOWN (simulating new token obtained) + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + // Calling onAuthTokenReady should trigger processing + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + taskRunner.onAuthTokenReady(); + runHandlerTasks(taskRunner); + + // Now request should be made + recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/api/test", recordedRequest.getPath()); + + // Task should be deleted after successful execution + verify(mockTaskStorage).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_SuccessfulRequest_TaskDeleted() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + IterableTaskRunner.TaskCompletedListener taskCompletedListener = mock(IterableTaskRunner.TaskCompletedListener.class); + taskRunner.addTaskCompletedListener(taskCompletedListener); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + // Task should be deleted on success even with autoRetry enabled + verify(mockTaskStorage).deleteTask(any(String.class)); + + shadowOf(getMainLooper()).idle(); + verify(taskCompletedListener).onTaskCompleted(any(String.class), eq(IterableTaskRunner.TaskResult.SUCCESS), any(IterableApiResponse.class)); + } + + @Test + public void testAutoRetryEnabled_Any401_TaskRetainedInDB() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // Server returns 401 without JWT-specific error code. + // In offline context, the API key is valid (task was queued with it), + // so any 401 is treated as a JWT auth issue and the task is retained. + JSONObject body401 = new JSONObject(); + body401.put("code", "InvalidApiKey"); + body401.put("msg", "Invalid API key"); + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(body401.toString())); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + // Any 401 should retain the task when autoRetry is enabled (offline tasks have valid API keys) + shadowOf(getMainLooper()).idle(); + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_Non401Error_TaskDeletedNormally() throws Exception { + initApiWithAutoRetry(true); + + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // Server returns 400 (not 401) - should be treated as a normal failure + JSONObject body400 = new JSONObject(); + body400.put("msg", "Bad request"); + server.enqueue(new MockResponse() + .setResponseCode(400) + .setBody(body400.toString())); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + // Non-401 errors should delete the task as a FAILURE + shadowOf(getMainLooper()).idle(); + verify(mockTaskStorage).deleteTask(any(String.class)); + } + + @Test + public void testAuthManagerListenerRegistration() { + initApiWithAutoRetry(true); + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + + // Register the task runner as a listener + authManager.addAuthTokenReadyListener(taskRunner); + + // Auth should be ready initially (UNKNOWN state) + assertTrue(authManager.isAuthTokenReady()); + + // Mark invalid + authManager.setAuthTokenInvalid(); + assertFalse(authManager.isAuthTokenReady()); + assertEquals(IterableAuthManager.AuthState.INVALID, authManager.getAuthState()); + + // Mark valid + authManager.setIsLastAuthTokenValid(true); + assertTrue(authManager.isAuthTokenReady()); + assertEquals(IterableAuthManager.AuthState.VALID, authManager.getAuthState()); + } + + // endregion + private void runHandlerTasks(IterableTaskRunner taskRunner) throws InterruptedException { shadowOf(taskRunner.handler.getLooper()).idle(); } From be05ccb04c7b248d269563e687bf8b283f340ce4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E2=80=9CAkshay?= <“ayyanchira.akshay@gmail.com”> Date: Mon, 23 Feb 2026 13:40:34 -0800 Subject: [PATCH 4/7] Removing variable and setters from IterableConfig --- .../com/iterable/iterableapi/IterableApi.java | 13 +++-------- .../iterable/iterableapi/IterableConfig.java | 23 ------------------- .../iterableapi/IterableTaskRunnerTest.java | 11 ++++++++- 3 files changed, 13 insertions(+), 34 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index add052f23..d2fc5e441 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -106,7 +106,7 @@ public void execute(@Nullable String data) { SharedPreferences.Editor editor = sharedPref.edit(); editor.putBoolean(IterableConstants.SHARED_PREFS_OFFLINE_MODE_KEY, offlineConfiguration); - // Parse autoRetry flag from remote config. If not present, fall back to local config. + // Parse autoRetry flag from remote config. if (jsonData.has(IterableConstants.KEY_AUTO_RETRY)) { boolean autoRetryRemote = jsonData.getBoolean(IterableConstants.KEY_AUTO_RETRY); editor.putBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, autoRetryRemote); @@ -204,18 +204,11 @@ static void loadLastSavedConfiguration(Context context) { boolean offlineMode = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_OFFLINE_MODE_KEY, false); sharedInstance.apiClient.setOfflineProcessingEnabled(offlineMode); - // Load autoRetry: if a remote value was previously saved, use it; otherwise fall back to local config. - if (sharedPref.contains(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY)) { - sharedInstance._autoRetryOnJwtFailure = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, false); - } else { - sharedInstance._autoRetryOnJwtFailure = sharedInstance.config.autoRetryOnJwtFailure; - } + sharedInstance._autoRetryOnJwtFailure = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, false); } /** - * Returns whether auto-retry on JWT failure is enabled. - * The remote configuration flag takes precedence when present; - * otherwise the local {@link IterableConfig#autoRetryOnJwtFailure} value is used. + * Returns whether auto-retry on JWT failure is enabled, as determined by remote configuration. */ boolean isAutoRetryOnJwtFailure() { return _autoRetryOnJwtFailure; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 4c4a34ed6..6e4bf7c45 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -140,14 +140,6 @@ public class IterableConfig { @Nullable final IterableAPIMobileFrameworkInfo mobileFrameworkInfo; - /** - * When set to true, the SDK will automatically retry API requests that fail due to - * JWT authentication errors (401). Failed requests are retained in the local DB and - * processing is paused until a valid JWT token is obtained. - * This value can be overridden by the remote configuration flag `autoRetry`. - */ - final boolean autoRetryOnJwtFailure; - /** * Base URL for Webview content loading. Specifically used to enable CORS for external resources. * If null or empty, defaults to empty string (original behavior with about:blank origin). @@ -191,7 +183,6 @@ private IterableConfig(Builder builder) { decryptionFailureHandler = builder.decryptionFailureHandler; mobileFrameworkInfo = builder.mobileFrameworkInfo; webViewBaseUrl = builder.webViewBaseUrl; - autoRetryOnJwtFailure = builder.autoRetryOnJwtFailure; } public static class Builder { @@ -220,7 +211,6 @@ public static class Builder { private IterableIdentityResolution identityResolution = new IterableIdentityResolution(); private IterableUnknownUserHandler iterableUnknownUserHandler; private String webViewBaseUrl; - private boolean autoRetryOnJwtFailure = false; public Builder() {} @@ -463,19 +453,6 @@ public Builder setMobileFrameworkInfo(@NonNull IterableAPIMobileFrameworkInfo mo return this; } - /** - * Enable or disable automatic retry of API requests that fail due to JWT authentication - * errors (401). When enabled, failed requests are retained in the local DB and processing - * is paused until a valid JWT token is obtained. - * This value can be overridden by the remote configuration flag `autoRetry`. - * @param autoRetryOnJwtFailure `true` to enable auto-retry on JWT failure - */ - @NonNull - public Builder setAutoRetryOnJwtFailure(boolean autoRetryOnJwtFailure) { - this.autoRetryOnJwtFailure = autoRetryOnJwtFailure; - return this; - } - /** * Set the base URL for WebView content loading. Used to enable CORS for external resources. * If not set or null, defaults to empty string (original behavior with about:blank origin). diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java index bd5059048..83e996589 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java @@ -1,5 +1,9 @@ package com.iterable.iterableapi; +import android.content.Context; + +import androidx.test.core.app.ApplicationProvider; + import com.iterable.iterableapi.unit.TestRunner; import org.json.JSONObject; @@ -186,11 +190,16 @@ private IterableAuthHandler initApiWithAutoRetry(boolean autoRetryEnabled) { final IterableAuthHandler mockAuthHandler = mock(IterableAuthHandler.class); doReturn(null).when(mockAuthHandler).onAuthTokenRequested(); + Context context = ApplicationProvider.getApplicationContext(); + context.getSharedPreferences(IterableConstants.SHARED_PREFS_SAVED_CONFIGURATION, Context.MODE_PRIVATE) + .edit() + .putBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, autoRetryEnabled) + .apply(); + IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { @Override public IterableConfig.Builder run(IterableConfig.Builder builder) { return builder - .setAutoRetryOnJwtFailure(autoRetryEnabled) .setAuthHandler(mockAuthHandler); } }); From b691ea225ec087c0d5ca42299acb8bc496307fb6 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 10 Mar 2026 15:03:46 +0000 Subject: [PATCH 5/7] Add auto-retry for offline tasks on JWT failure with auth-gated queue processing --- .../iterableapi/IterableApiClient.java | 23 ++-- .../iterableapi/IterableRequestTask.java | 36 +++--- .../iterableapi/IterableTaskRunner.java | 6 +- .../iterableapi/OfflineRequestProcessor.java | 15 ++- .../iterableapi/IterableTaskRunnerTest.java | 122 +++++++++++++++++- 5 files changed, 163 insertions(+), 39 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java index bdb3a2578..e4b941cb5 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApiClient.java @@ -52,17 +52,20 @@ private RequestProcessor getRequestProcessor() { } void setOfflineProcessingEnabled(boolean offlineMode) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - if (offlineMode) { - if (this.requestProcessor == null || this.requestProcessor.getClass() != OfflineRequestProcessor.class) { - this.requestProcessor = new OfflineRequestProcessor(authProvider.getContext()); - } - } else { - if (this.requestProcessor == null || this.requestProcessor.getClass() != OnlineRequestProcessor.class) { - this.requestProcessor = new OnlineRequestProcessor(); - } - } + if (offlineMode && this.requestProcessor instanceof OfflineRequestProcessor) { + return; } + if (!offlineMode && this.requestProcessor instanceof OnlineRequestProcessor) { + return; + } + + if (this.requestProcessor instanceof OfflineRequestProcessor) { + ((OfflineRequestProcessor) this.requestProcessor).dispose(); + } + + this.requestProcessor = offlineMode + ? new OfflineRequestProcessor(authProvider.getContext()) + : new OnlineRequestProcessor(); } void getRemoteConfiguration(IterableHelper.IterableActionHandler actionHandler) { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index 58903a7d0..33ecddd3f 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -207,23 +207,7 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "JWT Authorization header error"); IterableApi.getInstance().getAuthManager().handleAuthFailure(iterableApiRequest.authToken, getMappedErrorCodeForMessage(jsonResponse)); - // [F] When autoRetry is enabled and this is an offline task, skip the inline - // retry. The task stays in the DB and IterableTaskRunner will retry it once - // a valid JWT is obtained via the AuthTokenReadyListener callback. - // For online requests or when autoRetry is disabled, use the existing inline retry. - boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); - if (autoRetry && iterableApiRequest.getProcessorType() == IterableApiRequest.ProcessorType.OFFLINE) { - // Schedule a delayed token refresh (respects retry policy). - // Do NOT retry the request inline -- IterableTaskRunner will handle - // the retry after the AuthTokenReadyListener callback fires. - IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); - authManager.setIsLastAuthTokenValid(false); - long retryInterval = authManager.getNextRetryInterval(); - authManager.scheduleAuthTokenRefresh(retryInterval, false, null); - } else { - // Existing behavior: retry request inline after obtaining new token - requestNewAuthTokenAndRetry(iterableApiRequest); - } + handleJwtAuthRetry(iterableApiRequest); } else { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "Invalid API Key"); } @@ -277,6 +261,24 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque return apiResponse; } + /** + * When autoRetry is enabled and this is an offline task, skip the inline retry. + * The task stays in the DB and IterableTaskRunner will retry it once a valid JWT + * is obtained via the AuthTokenReadyListener callback. + * For online requests or when autoRetry is disabled, use the existing inline retry. + */ + private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) { + boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); + if (autoRetry && iterableApiRequest.getProcessorType() == IterableApiRequest.ProcessorType.OFFLINE) { + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + authManager.setIsLastAuthTokenValid(false); + long retryInterval = authManager.getNextRetryInterval(); + authManager.scheduleAuthTokenRefresh(retryInterval, false, null); + } else { + requestNewAuthTokenAndRetry(iterableApiRequest); + } + } + private static String getBaseUrl() { IterableConfig config = IterableApi.getInstance().config; IterableDataRegion dataRegion = config.dataRegion; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java index 7ee1c2363..cfaf8aa0a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java @@ -142,7 +142,7 @@ private void processTasks() { return; } - boolean proceed = processTask(task); + boolean proceed = processTask(task, autoRetry); if (!proceed) { // Only schedule timed retry for non-auth failures. // Auth failures will resume via onAuthTokenReady() callback. @@ -155,7 +155,7 @@ private void processTasks() { } @WorkerThread - private boolean processTask(@NonNull IterableTask task) { + private boolean processTask(@NonNull IterableTask task, boolean autoRetry) { isPausedForAuth = false; if (task.taskType == IterableTaskType.API) { @@ -173,8 +173,6 @@ private boolean processTask(@NonNull IterableTask task) { healthMonitor.onDBError(); } - boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); - if (response != null) { if (response.success) { result = TaskResult.SUCCESS; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java index dc5060a93..2995cf6a0 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java @@ -48,7 +48,20 @@ class OfflineRequestProcessor implements RequestProcessor { try { IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); } catch (Exception e) { - IterableLogger.d("OfflineRequestProcessor", "AuthManager not available yet for listener registration"); + IterableLogger.w("OfflineRequestProcessor", "Failed to register auth token listener. " + + "Auto-retry on JWT failure will not work until AuthManager is available."); + } + } + + /** + * Unregisters the auth token listener to prevent stale listener accumulation + * when the processor is replaced (e.g., when offline mode is toggled). + */ + void dispose() { + try { + IterableApi.getInstance().getAuthManager().removeAuthTokenReadyListener(taskRunner); + } catch (Exception e) { + IterableLogger.w("OfflineRequestProcessor", "Failed to unregister auth token listener on dispose."); } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java index 83e996589..cfba7f3de 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java @@ -316,17 +316,18 @@ public void testAutoRetryEnabled_ProcessingResumesOnAuthTokenReady() throws Exce RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNull(recordedRequest); - // Now simulate auth token becoming ready (UNKNOWN state, ready to make requests) - IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(false); // Reset state - // Manually set auth state to UNKNOWN (simulating new token obtained) - server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + // Register our test taskRunner as a listener so it gets notified + IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); - // Calling onAuthTokenReady should trigger processing + // Simulate auth token becoming ready: INVALID → VALID via setIsLastAuthTokenValid(true). + // This transitions auth state and notifies listeners (including taskRunner). + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); - taskRunner.onAuthTokenReady(); + + IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(true); runHandlerTasks(taskRunner); - // Now request should be made + // Now request should be made since auth is valid again recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNotNull(recordedRequest); assertEquals("/api/test", recordedRequest.getPath()); @@ -446,6 +447,113 @@ public void testAuthManagerListenerRegistration() { assertEquals(IterableAuthManager.AuthState.VALID, authManager.getAuthState()); } + @Test + public void testAutoRetryEnabled_UsesCurrentLiveAuthToken() throws Exception { + initApiWithAutoRetry(true); + + // Create a task with a stale auth token stored in the DB + IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", "stale_token_from_db", null, null); + IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // Verify that fromJSON with authTokenOverride replaces the stale token. + // We verify by checking the deserialized request object directly. + JSONObject taskJson = request.toJSONObject(); + IterableApiRequest deserializedRequest = IterableApiRequest.fromJSON(taskJson, "fresh_live_token", null, null); + assertEquals("fromJSON should use authTokenOverride instead of stored token", + "fresh_live_token", deserializedRequest.authToken); + + // Also verify that without override, the original stale token is used + IterableApiRequest deserializedWithoutOverride = IterableApiRequest.fromJSON(taskJson, null, null, null); + assertEquals("fromJSON without override should use stored token", + "stale_token_from_db", deserializedWithoutOverride.authToken); + } + + @Test + public void testAutoRetryEnabled_MultipleTasksInQueue_PausesAfterFirstJwtFailure() throws Exception { + initApiWithAutoRetry(true); + + // Create 3 tasks in the queue + IterableApiRequest request1 = new IterableApiRequest("apiKey", "api/test1", new JSONObject(), "POST", null, null, null); + IterableApiRequest request2 = new IterableApiRequest("apiKey", "api/test2", new JSONObject(), "POST", null, null, null); + IterableApiRequest request3 = new IterableApiRequest("apiKey", "api/test3", new JSONObject(), "POST", null, null, null); + + IterableTask task1 = new IterableTask("task1", IterableTaskType.API, request1.toJSONObject().toString()); + IterableTask task2 = new IterableTask("task2", IterableTaskType.API, request2.toJSONObject().toString()); + IterableTask task3 = new IterableTask("task3", IterableTaskType.API, request3.toJSONObject().toString()); + + // Return task1 first, then task2, then task3 + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task1).thenReturn(task2).thenReturn(task3).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // First task gets a 401 JWT error + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(createJwt401ResponseBody())); + // Enqueue success responses for task2 and task3 (should NOT be reached) + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + // Only one request should have been made (task1) + RecordedRequest recordedRequest1 = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest1); + assertEquals("/api/test1", recordedRequest1.getPath()); + + // Task2 and task3 should NOT have been attempted + RecordedRequest recordedRequest2 = server.takeRequest(1, TimeUnit.SECONDS); + assertNull("Processing should pause after first JWT failure — task2 should not be attempted", recordedRequest2); + + // No tasks should be deleted (task1 retained for retry, task2/task3 never processed) + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + + // Auth state should be INVALID + assertEquals(IterableAuthManager.AuthState.INVALID, IterableApi.getInstance().getAuthManager().getAuthState()); + } + + @Test + public void testAuthTokenReadyListener_NotifiedOnStateTransitionFromInvalid() { + initApiWithAutoRetry(true); + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + + // Use a mock listener to verify notification + IterableAuthManager.AuthTokenReadyListener mockListener = mock(IterableAuthManager.AuthTokenReadyListener.class); + authManager.addAuthTokenReadyListener(mockListener); + + // UNKNOWN → INVALID: no notification expected + authManager.setAuthTokenInvalid(); + verify(mockListener, never()).onAuthTokenReady(); + + // INVALID → VALID (via setIsLastAuthTokenValid): notification expected + authManager.setIsLastAuthTokenValid(true); + verify(mockListener, times(1)).onAuthTokenReady(); + + // VALID → INVALID: no notification expected + authManager.setAuthTokenInvalid(); + verify(mockListener, times(1)).onAuthTokenReady(); // still just 1 + + // INVALID → INVALID: no notification expected + authManager.setAuthTokenInvalid(); + verify(mockListener, times(1)).onAuthTokenReady(); // still just 1 + + // INVALID → UNKNOWN (simulating handleAuthTokenSuccess path via setIsLastAuthTokenValid(false) + // then a new token obtained): this doesn't trigger because setIsLastAuthTokenValid(false) doesn't change auth state. + // The actual INVALID→UNKNOWN transition happens inside handleAuthTokenSuccess when authHandler returns a token. + // We can verify INVALID → VALID again as the primary production path. + authManager.setIsLastAuthTokenValid(true); + verify(mockListener, times(2)).onAuthTokenReady(); + + // Cleanup + authManager.removeAuthTokenReadyListener(mockListener); + } + // endregion private void runHandlerTasks(IterableTaskRunner taskRunner) throws InterruptedException { From 69cb854601385f1b8ae96e2f3333fd70e8112df4 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 12 Mar 2026 15:48:37 +0000 Subject: [PATCH 6/7] fix flaky tests for auth retry --- .../IterablePushNotificationUtil.java | 4 ++++ .../java/com/iterable/iterableapi/BaseTest.java | 9 +++++++++ .../iterableapi/IterableActivityMonitorTest.java | 7 ------- .../iterable/iterableapi/IterableApiTest.java | 4 ++-- .../iterableapi/IterableInAppManagerTest.java | 2 -- .../iterableapi/IterableTaskRunnerTest.java | 16 +++++++++------- 6 files changed, 24 insertions(+), 18 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java index b28b54511..1e4ff3ab6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterablePushNotificationUtil.java @@ -14,6 +14,10 @@ class IterablePushNotificationUtil { private static PendingAction pendingAction = null; private static final String TAG = "IterablePushNotificationUtil"; + static void clearPendingAction() { + pendingAction = null; + } + static boolean processPendingAction(Context context) { boolean handled = false; if (pendingAction != null) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/BaseTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/BaseTest.java index 27f8f44ae..9f66e61f6 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/BaseTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/BaseTest.java @@ -6,6 +6,7 @@ import com.iterable.iterableapi.unit.TestRunner; +import org.junit.After; import org.junit.Rule; import org.junit.rules.TestWatcher; import org.junit.runner.Description; @@ -22,6 +23,14 @@ public abstract class BaseTest { @Rule public AsyncTaskRule asyncTaskRule = new AsyncTaskRule(); + @After + public void baseTestTearDown() { + IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); + IterableActivityMonitor.instance = new IterableActivityMonitor(); + IterablePushNotificationUtil.clearPendingAction(); + IterableApi.sharedInstance = new IterableApi(); + } + protected IterableUtilImpl getIterableUtilSpy() { return utilsRule.iterableUtilSpy; } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableActivityMonitorTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableActivityMonitorTest.java index 05f4be969..d772dc3c0 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableActivityMonitorTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableActivityMonitorTest.java @@ -2,7 +2,6 @@ import android.app.Activity; -import org.junit.After; import org.junit.Before; import org.junit.Test; import org.robolectric.Robolectric; @@ -22,12 +21,6 @@ public void setUp() { IterableActivityMonitor.getInstance().registerLifecycleCallbacks(getContext()); } - @After - public void tearDown() { - IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); - IterableActivityMonitor.instance = new IterableActivityMonitor(); - } - @Test public void testOneActivityStarted() { Robolectric.buildActivity(Activity.class).create().start().resume(); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java index b128af40c..8d873993a 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiTest.java @@ -143,8 +143,8 @@ public void testAttributionInfoPersistence() throws Exception { assertEquals(attributionInfo.templateId, storedAttributionInfo.templateId); assertEquals(attributionInfo.messageId, storedAttributionInfo.messageId); - // 24 hours, expired, attributionInfo should be null - doReturn(System.currentTimeMillis() + 3600 * 24 * 1000).when(utilsRule.iterableUtilSpy).currentTimeMillis(); + // Just past 24 hours, expired, attributionInfo should be null + doReturn(System.currentTimeMillis() + 3600 * 24 * 1000 + 1).when(utilsRule.iterableUtilSpy).currentTimeMillis(); storedAttributionInfo = IterableApi.getInstance().getAttributionInfo(); assertNull(storedAttributionInfo); } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java index e18614061..7dcabe729 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableInAppManagerTest.java @@ -88,8 +88,6 @@ public IterableConfig.Builder run(IterableConfig.Builder builder) { public void tearDown() throws IOException { server.shutdown(); server = null; - IterableActivityMonitor.getInstance().unregisterLifecycleCallbacks(getContext()); - IterableActivityMonitor.instance = new IterableActivityMonitor(); } @Ignore("Ignoring due to stalling") diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java index cfba7f3de..ef21af7e3 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java @@ -196,13 +196,15 @@ private IterableAuthHandler initApiWithAutoRetry(boolean autoRetryEnabled) { .putBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, autoRetryEnabled) .apply(); - IterableTestUtils.createIterableApiNew(new IterableTestUtils.ConfigBuilderExtender() { - @Override - public IterableConfig.Builder run(IterableConfig.Builder builder) { - return builder - .setAuthHandler(mockAuthHandler); - } - }); + // Initialize directly without calling setEmail to avoid triggering an async + // auth flow. The null token from the mock handler would race with the test, + // and the resulting syncInApp() call would send unexpected requests to the + // mock server, breaking assertions that check for no requests. + IterableConfig config = new IterableConfig.Builder() + .setAutoPushRegistration(false) + .setAuthHandler(mockAuthHandler) + .build(); + IterableApi.initialize(context, IterableTestUtils.apiKey, config); return mockAuthHandler; } From 14a83b8a1e854cb7c3d705fbde310121f008b8de Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 12 Mar 2026 15:49:48 +0000 Subject: [PATCH 7/7] Unauthenticated endpoints do not require jwt to work (#1001) --- .../ApiEndpointClassification.java | 27 +++ .../com/iterable/iterableapi/IterableApi.java | 1 + .../iterable/iterableapi/IterableTask.java | 4 + .../iterableapi/IterableTaskRunner.java | 38 ++-- .../iterableapi/IterableTaskStorage.java | 28 +++ .../iterableapi/OfflineRequestProcessor.java | 4 +- .../ApiEndpointClassificationTest.java | 58 ++++++ .../iterableapi/IterableTaskRunnerTest.java | 186 ++++++++++++------ 8 files changed, 275 insertions(+), 71 deletions(-) create mode 100644 iterableapi/src/main/java/com/iterable/iterableapi/ApiEndpointClassification.java create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/ApiEndpointClassificationTest.java diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/ApiEndpointClassification.java b/iterableapi/src/main/java/com/iterable/iterableapi/ApiEndpointClassification.java new file mode 100644 index 000000000..ac4021e59 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/ApiEndpointClassification.java @@ -0,0 +1,27 @@ +package com.iterable.iterableapi; + +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +class ApiEndpointClassification { + + private static final Set DEFAULT_UNAUTHENTICATED = new HashSet<>(Arrays.asList( + IterableConstants.ENDPOINT_DISABLE_DEVICE, + IterableConstants.ENDPOINT_GET_REMOTE_CONFIGURATION, + IterableConstants.ENDPOINT_MERGE_USER, + IterableConstants.ENDPOINT_CRITERIA_LIST, + IterableConstants.ENDPOINT_TRACK_UNKNOWN_SESSION, + IterableConstants.ENDPOINT_TRACK_CONSENT + )); + + private volatile Set unauthenticatedPaths = new HashSet<>(DEFAULT_UNAUTHENTICATED); + + boolean requiresJwt(String path) { + return !unauthenticatedPaths.contains(path); + } + + void updateFromRemoteConfig(Set paths) { + this.unauthenticatedPaths = new HashSet<>(paths); + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index d2fc5e441..c6a676e83 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -48,6 +48,7 @@ public class IterableApi { private IterableHelper.FailureHandler _setUserFailureCallbackHandler; IterableApiClient apiClient = new IterableApiClient(new IterableApiAuthProvider()); + final ApiEndpointClassification apiEndpointClassification = new ApiEndpointClassification(); private static final UnknownUserMerge unknownUserMerge = new UnknownUserMerge(); private @Nullable UnknownUserManager unknownUserManager; private @Nullable IterableInAppManager inAppManager; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTask.java index c886d7926..8ef02bb40 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTask.java @@ -58,6 +58,10 @@ class IterableTask { this.taskType = taskType; } + boolean requiresJwt(ApiEndpointClassification classification) { + return classification.requiresJwt(this.name); + } + } enum IterableTaskType { diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java index cfaf8aa0a..d0de48009 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java @@ -20,6 +20,7 @@ class IterableTaskRunner implements IterableTaskStorage.TaskCreatedListener, Han private IterableActivityMonitor activityMonitor; private IterableNetworkConnectivityManager networkConnectivityManager; private HealthMonitor healthMonitor; + private ApiEndpointClassification classification; private static final int RETRY_INTERVAL_SECONDS = 60; @@ -45,11 +46,13 @@ interface TaskCompletedListener { IterableTaskRunner(IterableTaskStorage taskStorage, IterableActivityMonitor activityMonitor, IterableNetworkConnectivityManager networkConnectivityManager, - HealthMonitor healthMonitor) { + HealthMonitor healthMonitor, + ApiEndpointClassification classification) { this.taskStorage = taskStorage; this.activityMonitor = activityMonitor; this.networkConnectivityManager = networkConnectivityManager; this.healthMonitor = healthMonitor; + this.classification = classification; networkThread.start(); handler = new Handler(networkThread.getLooper(), this); taskStorage.addTaskCreatedListener(this); @@ -57,6 +60,14 @@ interface TaskCompletedListener { activityMonitor.addCallback(this); } + // Preserved for backward compatibility with existing tests + IterableTaskRunner(IterableTaskStorage taskStorage, + IterableActivityMonitor activityMonitor, + IterableNetworkConnectivityManager networkConnectivityManager, + HealthMonitor healthMonitor) { + this(taskStorage, activityMonitor, networkConnectivityManager, healthMonitor, new ApiEndpointClassification()); + } + void addTaskCompletedListener(TaskCompletedListener listener) { taskCompletedListeners.add(listener); } @@ -130,13 +141,7 @@ private void processTasks() { boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); while (networkConnectivityManager.isConnected()) { - // [F] When autoRetry is enabled, also check that auth token is ready before processing - if (autoRetry && !IterableApi.getInstance().getAuthManager().isAuthTokenReady()) { - IterableLogger.d(TAG, "Auth token not ready, pausing task processing"); - return; - } - - IterableTask task = taskStorage.getNextScheduledTask(); + IterableTask task = getNextActionableTask(autoRetry); if (task == null) { return; @@ -154,10 +159,21 @@ private void processTasks() { } } + private IterableTask getNextActionableTask(boolean autoRetry) { + boolean authBlocked = isPausedForAuth || + (autoRetry && !IterableApi.getInstance().getAuthManager().isAuthTokenReady()); + if (!authBlocked) { + return taskStorage.getNextScheduledTask(); + } + return taskStorage.getNextScheduledTaskNotRequiringJwt(classification); + } + + void setIsPausedForAuth(boolean paused) { + this.isPausedForAuth = paused; + } + @WorkerThread private boolean processTask(@NonNull IterableTask task, boolean autoRetry) { - isPausedForAuth = false; - if (task.taskType == IterableTaskType.API) { IterableApiResponse response = null; TaskResult result = TaskResult.FAILURE; @@ -177,7 +193,7 @@ private boolean processTask(@NonNull IterableTask task, boolean autoRetry) { if (response.success) { result = TaskResult.SUCCESS; } else { - // [F] If autoRetry is enabled and response is a 401 JWT error, + // If autoRetry is enabled and response is a 401 JWT error, // retain the task and pause processing until a valid JWT is obtained. if (autoRetry && isJwtFailure(response)) { IterableLogger.d(TAG, "JWT auth failure on task " + task.id + ". Retaining task and pausing processing."); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java index 0cce48b73..aa8f50211 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskStorage.java @@ -287,6 +287,34 @@ IterableTask getNextScheduledTask() { return task; } + /** + * Returns the next scheduled task that does not require JWT authentication. + * Iterates tasks ordered by scheduledAt and returns the first one classified + * as unauthenticated by the given classification. + * + * @param classification the endpoint classification to check against + * @return next unauthenticated {@link IterableTask}, or null if none found + */ + @Nullable + IterableTask getNextScheduledTaskNotRequiringJwt(ApiEndpointClassification classification) { + if (!isDatabaseReady()) { + return null; + } + Cursor cursor = database.rawQuery("select * from OfflineTask order by scheduled", null); + IterableTask task = null; + if (cursor.moveToFirst()) { + do { + IterableTask candidate = createTaskFromCursor(cursor); + if (!candidate.requiresJwt(classification)) { + task = candidate; + break; + } + } while (cursor.moveToNext()); + } + cursor.close(); + return task; + } + /** * Deletes all the entries from the OfflineTask table. */ diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java index 2995cf6a0..40df7cfbd 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java @@ -38,10 +38,12 @@ class OfflineRequestProcessor implements RequestProcessor { IterableNetworkConnectivityManager networkConnectivityManager = IterableNetworkConnectivityManager.sharedInstance(context); taskStorage = IterableTaskStorage.sharedInstance(context); healthMonitor = new HealthMonitor(taskStorage); + ApiEndpointClassification classification = IterableApi.getInstance().apiEndpointClassification; taskRunner = new IterableTaskRunner(taskStorage, IterableActivityMonitor.getInstance(), networkConnectivityManager, - healthMonitor); + healthMonitor, + classification); taskScheduler = new TaskScheduler(taskStorage, taskRunner); // Register task runner as auth token ready listener for JWT auto-retry support diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/ApiEndpointClassificationTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/ApiEndpointClassificationTest.java new file mode 100644 index 000000000..6036be5fd --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/ApiEndpointClassificationTest.java @@ -0,0 +1,58 @@ +package com.iterable.iterableapi; + +import org.junit.Before; +import org.junit.Test; + +import java.util.Arrays; +import java.util.HashSet; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class ApiEndpointClassificationTest { + + private ApiEndpointClassification classification; + + @Before + public void setUp() { + classification = new ApiEndpointClassification(); + } + + @Test + public void testDefaultUnauthenticatedEndpoints() { + // THIS IS IMPORTANT SO IF WE CHANGE IT FOR TESTING WE WILL HAVE THIS FAILING + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_DISABLE_DEVICE)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_GET_REMOTE_CONFIGURATION)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_MERGE_USER)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_CRITERIA_LIST)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_TRACK_UNKNOWN_SESSION)); + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_TRACK_CONSENT)); + } + + @Test + public void testUnknownEndpointRequiresJwt() { + assertTrue(classification.requiresJwt("unknown/endpoint")); + } + + @Test + public void testUpdateFromRemoteConfigOverridesDefaults() { + // Override: now only "events/track" is unauthenticated + classification.updateFromRemoteConfig( + new HashSet<>(Arrays.asList(IterableConstants.ENDPOINT_TRACK)) + ); + + assertFalse(classification.requiresJwt(IterableConstants.ENDPOINT_TRACK)); + // Previously unauthenticated endpoints now require JWT + assertTrue(classification.requiresJwt(IterableConstants.ENDPOINT_DISABLE_DEVICE)); + assertTrue(classification.requiresJwt(IterableConstants.ENDPOINT_MERGE_USER)); + } + + @Test + public void testIterableTaskRequiresJwtDelegation() { + IterableTask authTask = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, "{}"); + IterableTask unauthTask = new IterableTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, "{}"); + + assertTrue(authTask.requiresJwt(classification)); + assertFalse(unauthTask.requiresJwt(classification)); + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java index ef21af7e3..98ac5dda4 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableTaskRunnerTest.java @@ -27,13 +27,13 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.clearInvocations; +import static org.mockito.Mockito.doReturn; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import static org.mockito.Mockito.when; -import static org.mockito.Mockito.doReturn; import static org.robolectric.Shadows.shadowOf; @RunWith(TestRunner.class) @@ -172,9 +172,6 @@ public void testIfNetworkCheckedBeforeProcessingTask() throws Exception { // region Auto-Retry on JWT Failure Tests - /** - * Helper to create a JWT 401 error response body matching IterableRequestTask's JWT error codes. - */ private String createJwt401ResponseBody() throws Exception { JSONObject body = new JSONObject(); body.put("code", "InvalidJwtPayload"); @@ -182,9 +179,6 @@ private String createJwt401ResponseBody() throws Exception { return body.toString(); } - /** - * Helper to initialize IterableApi with autoRetry enabled and a mock auth handler. - */ private IterableAuthHandler initApiWithAutoRetry(boolean autoRetryEnabled) { IterableApi.sharedInstance = new IterableApi(); final IterableAuthHandler mockAuthHandler = mock(IterableAuthHandler.class); @@ -219,7 +213,6 @@ public void testAutoRetryEnabled_JwtFailure_TaskRetainedInDB() throws Exception when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); - // Server returns 401 with JWT error code server.enqueue(new MockResponse() .setResponseCode(401) .setBody(createJwt401ResponseBody())); @@ -233,14 +226,11 @@ public void testAutoRetryEnabled_JwtFailure_TaskRetainedInDB() throws Exception RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNotNull(recordedRequest); - // Task should NOT be deleted from DB when autoRetry is enabled verify(mockTaskStorage, never()).deleteTask(any(String.class)); - // Completion listener should be called with RETRY result shadowOf(getMainLooper()).idle(); verify(taskCompletedListener).onTaskCompleted(any(String.class), eq(IterableTaskRunner.TaskResult.RETRY), any(IterableApiResponse.class)); - // Auth state should be INVALID assertEquals(IterableAuthManager.AuthState.INVALID, IterableApi.getInstance().getAuthManager().getAuthState()); } @@ -255,7 +245,6 @@ public void testAutoRetryDisabled_JwtFailure_TaskDeletedFromDB() throws Exceptio when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); - // Server returns 401 with JWT error code server.enqueue(new MockResponse() .setResponseCode(401) .setBody(createJwt401ResponseBody())); @@ -266,7 +255,6 @@ public void testAutoRetryDisabled_JwtFailure_TaskDeletedFromDB() throws Exceptio RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNotNull(recordedRequest); - // Task should be deleted from DB when autoRetry is disabled (existing behavior) shadowOf(getMainLooper()).idle(); verify(mockTaskStorage).deleteTask(any(String.class)); } @@ -275,7 +263,6 @@ public void testAutoRetryDisabled_JwtFailure_TaskDeletedFromDB() throws Exceptio public void testAutoRetryEnabled_ProcessingPausesWhenAuthInvalid() throws Exception { initApiWithAutoRetry(true); - // Mark auth as invalid IterableApi.getInstance().getAuthManager().setAuthTokenInvalid(); IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); @@ -289,11 +276,9 @@ public void testAutoRetryEnabled_ProcessingPausesWhenAuthInvalid() throws Except taskRunner.onTaskCreated(null); runHandlerTasks(taskRunner); - // No request should be made because auth is invalid RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNull(recordedRequest); - // Task should NOT be deleted since processing was paused verify(mockTaskStorage, never()).deleteTask(any(String.class)); } @@ -301,7 +286,6 @@ public void testAutoRetryEnabled_ProcessingPausesWhenAuthInvalid() throws Except public void testAutoRetryEnabled_ProcessingResumesOnAuthTokenReady() throws Exception { initApiWithAutoRetry(true); - // Mark auth as invalid first IterableApi.getInstance().getAuthManager().setAuthTokenInvalid(); IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", null, null, null); @@ -311,30 +295,24 @@ public void testAutoRetryEnabled_ProcessingResumesOnAuthTokenReady() throws Exce when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); - // First attempt: auth is invalid, should not process taskRunner.onTaskCreated(null); runHandlerTasks(taskRunner); RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNull(recordedRequest); - // Register our test taskRunner as a listener so it gets notified IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); - // Simulate auth token becoming ready: INVALID → VALID via setIsLastAuthTokenValid(true). - // This transitions auth state and notifies listeners (including taskRunner). server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(true); runHandlerTasks(taskRunner); - // Now request should be made since auth is valid again recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNotNull(recordedRequest); assertEquals("/api/test", recordedRequest.getPath()); - // Task should be deleted after successful execution verify(mockTaskStorage).deleteTask(any(String.class)); } @@ -359,7 +337,6 @@ public void testAutoRetryEnabled_SuccessfulRequest_TaskDeleted() throws Exceptio RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNotNull(recordedRequest); - // Task should be deleted on success even with autoRetry enabled verify(mockTaskStorage).deleteTask(any(String.class)); shadowOf(getMainLooper()).idle(); @@ -377,9 +354,6 @@ public void testAutoRetryEnabled_Any401_TaskRetainedInDB() throws Exception { when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); - // Server returns 401 without JWT-specific error code. - // In offline context, the API key is valid (task was queued with it), - // so any 401 is treated as a JWT auth issue and the task is retained. JSONObject body401 = new JSONObject(); body401.put("code", "InvalidApiKey"); body401.put("msg", "Invalid API key"); @@ -393,7 +367,6 @@ public void testAutoRetryEnabled_Any401_TaskRetainedInDB() throws Exception { RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNotNull(recordedRequest); - // Any 401 should retain the task when autoRetry is enabled (offline tasks have valid API keys) shadowOf(getMainLooper()).idle(); verify(mockTaskStorage, never()).deleteTask(any(String.class)); } @@ -409,7 +382,6 @@ public void testAutoRetryEnabled_Non401Error_TaskDeletedNormally() throws Except when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); - // Server returns 400 (not 401) - should be treated as a normal failure JSONObject body400 = new JSONObject(); body400.put("msg", "Bad request"); server.enqueue(new MockResponse() @@ -422,7 +394,6 @@ public void testAutoRetryEnabled_Non401Error_TaskDeletedNormally() throws Except RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); assertNotNull(recordedRequest); - // Non-401 errors should delete the task as a FAILURE shadowOf(getMainLooper()).idle(); verify(mockTaskStorage).deleteTask(any(String.class)); } @@ -432,18 +403,14 @@ public void testAuthManagerListenerRegistration() { initApiWithAutoRetry(true); IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); - // Register the task runner as a listener authManager.addAuthTokenReadyListener(taskRunner); - // Auth should be ready initially (UNKNOWN state) assertTrue(authManager.isAuthTokenReady()); - // Mark invalid authManager.setAuthTokenInvalid(); assertFalse(authManager.isAuthTokenReady()); assertEquals(IterableAuthManager.AuthState.INVALID, authManager.getAuthState()); - // Mark valid authManager.setIsLastAuthTokenValid(true); assertTrue(authManager.isAuthTokenReady()); assertEquals(IterableAuthManager.AuthState.VALID, authManager.getAuthState()); @@ -453,7 +420,6 @@ public void testAuthManagerListenerRegistration() { public void testAutoRetryEnabled_UsesCurrentLiveAuthToken() throws Exception { initApiWithAutoRetry(true); - // Create a task with a stale auth token stored in the DB IterableApiRequest request = new IterableApiRequest("apiKey", "api/test", new JSONObject(), "POST", "stale_token_from_db", null, null); IterableTask task = new IterableTask("testTask", IterableTaskType.API, request.toJSONObject().toString()); when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); @@ -461,14 +427,11 @@ public void testAutoRetryEnabled_UsesCurrentLiveAuthToken() throws Exception { when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); - // Verify that fromJSON with authTokenOverride replaces the stale token. - // We verify by checking the deserialized request object directly. JSONObject taskJson = request.toJSONObject(); IterableApiRequest deserializedRequest = IterableApiRequest.fromJSON(taskJson, "fresh_live_token", null, null); assertEquals("fromJSON should use authTokenOverride instead of stored token", "fresh_live_token", deserializedRequest.authToken); - // Also verify that without override, the original stale token is used IterableApiRequest deserializedWithoutOverride = IterableApiRequest.fromJSON(taskJson, null, null, null); assertEquals("fromJSON without override should use stored token", "stale_token_from_db", deserializedWithoutOverride.authToken); @@ -478,7 +441,6 @@ public void testAutoRetryEnabled_UsesCurrentLiveAuthToken() throws Exception { public void testAutoRetryEnabled_MultipleTasksInQueue_PausesAfterFirstJwtFailure() throws Exception { initApiWithAutoRetry(true); - // Create 3 tasks in the queue IterableApiRequest request1 = new IterableApiRequest("apiKey", "api/test1", new JSONObject(), "POST", null, null, null); IterableApiRequest request2 = new IterableApiRequest("apiKey", "api/test2", new JSONObject(), "POST", null, null, null); IterableApiRequest request3 = new IterableApiRequest("apiKey", "api/test3", new JSONObject(), "POST", null, null, null); @@ -487,36 +449,29 @@ public void testAutoRetryEnabled_MultipleTasksInQueue_PausesAfterFirstJwtFailure IterableTask task2 = new IterableTask("task2", IterableTaskType.API, request2.toJSONObject().toString()); IterableTask task3 = new IterableTask("task3", IterableTaskType.API, request3.toJSONObject().toString()); - // Return task1 first, then task2, then task3 when(mockTaskStorage.getNextScheduledTask()).thenReturn(task1).thenReturn(task2).thenReturn(task3).thenReturn(null); when(mockActivityMonitor.isInForeground()).thenReturn(true); when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); - // First task gets a 401 JWT error server.enqueue(new MockResponse() .setResponseCode(401) .setBody(createJwt401ResponseBody())); - // Enqueue success responses for task2 and task3 (should NOT be reached) server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); taskRunner.onTaskCreated(null); runHandlerTasks(taskRunner); - // Only one request should have been made (task1) RecordedRequest recordedRequest1 = server.takeRequest(1, TimeUnit.SECONDS); assertNotNull(recordedRequest1); assertEquals("/api/test1", recordedRequest1.getPath()); - // Task2 and task3 should NOT have been attempted RecordedRequest recordedRequest2 = server.takeRequest(1, TimeUnit.SECONDS); - assertNull("Processing should pause after first JWT failure — task2 should not be attempted", recordedRequest2); + assertNull("Processing should pause after first JWT failure", recordedRequest2); - // No tasks should be deleted (task1 retained for retry, task2/task3 never processed) verify(mockTaskStorage, never()).deleteTask(any(String.class)); - // Auth state should be INVALID assertEquals(IterableAuthManager.AuthState.INVALID, IterableApi.getInstance().getAuthManager().getAuthState()); } @@ -525,39 +480,152 @@ public void testAuthTokenReadyListener_NotifiedOnStateTransitionFromInvalid() { initApiWithAutoRetry(true); IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); - // Use a mock listener to verify notification IterableAuthManager.AuthTokenReadyListener mockListener = mock(IterableAuthManager.AuthTokenReadyListener.class); authManager.addAuthTokenReadyListener(mockListener); - // UNKNOWN → INVALID: no notification expected authManager.setAuthTokenInvalid(); verify(mockListener, never()).onAuthTokenReady(); - // INVALID → VALID (via setIsLastAuthTokenValid): notification expected authManager.setIsLastAuthTokenValid(true); verify(mockListener, times(1)).onAuthTokenReady(); - // VALID → INVALID: no notification expected authManager.setAuthTokenInvalid(); - verify(mockListener, times(1)).onAuthTokenReady(); // still just 1 + verify(mockListener, times(1)).onAuthTokenReady(); - // INVALID → INVALID: no notification expected authManager.setAuthTokenInvalid(); - verify(mockListener, times(1)).onAuthTokenReady(); // still just 1 + verify(mockListener, times(1)).onAuthTokenReady(); - // INVALID → UNKNOWN (simulating handleAuthTokenSuccess path via setIsLastAuthTokenValid(false) - // then a new token obtained): this doesn't trigger because setIsLastAuthTokenValid(false) doesn't change auth state. - // The actual INVALID→UNKNOWN transition happens inside handleAuthTokenSuccess when authHandler returns a token. - // We can verify INVALID → VALID again as the primary production path. authManager.setIsLastAuthTokenValid(true); verify(mockListener, times(2)).onAuthTokenReady(); - // Cleanup authManager.removeAuthTokenReadyListener(mockListener); } // endregion + // region Unauthenticated API Bypass Tests + + @Test + public void testUnauthenticatedTaskExecutesDuringAuthPause() throws Exception { + ApiEndpointClassification classification = new ApiEndpointClassification(); + IterableTaskRunner runner = new IterableTaskRunner(mockTaskStorage, mockActivityMonitor, mockNetworkConnectivityManager, mockHealthMonitor, classification); + + IterableApiRequest request = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_DISABLE_DEVICE, new JSONObject(), "POST", null, null, null); + IterableTask unauthTask = new IterableTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, request.toJSONObject().toString()); + + when(mockTaskStorage.getNextScheduledTaskNotRequiringJwt(classification)).thenReturn(unauthTask).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + runner.setIsPausedForAuth(true); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, recordedRequest.getPath()); + verify(mockTaskStorage).deleteTask(unauthTask.id); + } + + @Test + public void testAuthRequiredTaskStaysBlockedDuringAuthPause() throws Exception { + ApiEndpointClassification classification = new ApiEndpointClassification(); + IterableTaskRunner runner = new IterableTaskRunner(mockTaskStorage, mockActivityMonitor, mockNetworkConnectivityManager, mockHealthMonitor, classification); + + when(mockTaskStorage.getNextScheduledTaskNotRequiringJwt(classification)).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + runner.setIsPausedForAuth(true); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(recordedRequest); + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + } + + @Test + public void testQueueIntegrityAfterAuthPausedProcessing() throws Exception { + ApiEndpointClassification classification = new ApiEndpointClassification(); + IterableTaskRunner runner = new IterableTaskRunner(mockTaskStorage, mockActivityMonitor, mockNetworkConnectivityManager, mockHealthMonitor, classification); + + IterableApiRequest trackRequestA = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_TRACK, new JSONObject("{\"eventName\":\"A\"}"), "POST", null, null, null); + IterableTask trackTaskA = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequestA.toJSONObject().toString()); + + IterableApiRequest disableRequest = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_DISABLE_DEVICE, new JSONObject(), "POST", null, null, null); + IterableTask disableTask = new IterableTask(IterableConstants.ENDPOINT_DISABLE_DEVICE, IterableTaskType.API, disableRequest.toJSONObject().toString()); + + IterableApiRequest trackRequestB = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_TRACK, new JSONObject("{\"eventName\":\"B\"}"), "POST", null, null, null); + IterableTask trackTaskB = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequestB.toJSONObject().toString()); + + IterableApiRequest trackRequestC = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_TRACK, new JSONObject("{\"eventName\":\"C\"}"), "POST", null, null, null); + IterableTask trackTaskC = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequestC.toJSONObject().toString()); + + when(mockTaskStorage.getNextScheduledTaskNotRequiringJwt(classification)).thenReturn(disableTask).thenReturn(null); + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + runner.setIsPausedForAuth(true); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + verify(mockTaskStorage).deleteTask(disableTask.id); + verify(mockTaskStorage, never()).deleteTask(trackTaskA.id); + verify(mockTaskStorage, never()).deleteTask(trackTaskB.id); + verify(mockTaskStorage, never()).deleteTask(trackTaskC.id); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/" + IterableConstants.ENDPOINT_DISABLE_DEVICE, recordedRequest.getPath()); + + RecordedRequest secondRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(secondRequest); + } + + @Test + public void testAuthRequiredTasksResumeAfterAuthReady() throws Exception { + ApiEndpointClassification classification = new ApiEndpointClassification(); + IterableTaskRunner runner = new IterableTaskRunner(mockTaskStorage, mockActivityMonitor, mockNetworkConnectivityManager, mockHealthMonitor, classification); + + IterableApiRequest trackRequest = new IterableApiRequest("apiKey", IterableConstants.ENDPOINT_TRACK, new JSONObject(), "POST", null, null, null); + IterableTask trackTask = new IterableTask(IterableConstants.ENDPOINT_TRACK, IterableTaskType.API, trackRequest.toJSONObject().toString()); + + when(mockActivityMonitor.isInForeground()).thenReturn(true); + when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); + when(mockHealthMonitor.canProcess()).thenReturn(true); + + // Phase 1: Auth paused, no unauthenticated tasks available + when(mockTaskStorage.getNextScheduledTaskNotRequiringJwt(classification)).thenReturn(null); + runner.setIsPausedForAuth(true); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + + // Phase 2: Auth ready, track task should now process + when(mockTaskStorage.getNextScheduledTask()).thenReturn(trackTask).thenReturn(null); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + runner.setIsPausedForAuth(false); + runner.onTaskCreated(null); + runHandlerTasks(runner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/" + IterableConstants.ENDPOINT_TRACK, recordedRequest.getPath()); + verify(mockTaskStorage).deleteTask(trackTask.id); + } + + // endregion + private void runHandlerTasks(IterableTaskRunner taskRunner) throws InterruptedException { shadowOf(taskRunner.handler.getLooper()).idle(); }