From 087494eb679c5ee87a6113ac3b89b22566ba77dc 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] [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 29b44bd94..05b94315f 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(); }