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(); }