diff --git a/CHANGELOG.md b/CHANGELOG.md index 58db8d6d5..ce6a05274 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## Unreleased - Replaced the deprecated `AsyncTask`-based push notification handling with `WorkManager` for improved reliability and compatibility with modern Android versions. No action is required. +- Fixed lost event tracking and missed API calls with an auto-retry feature for JWT token failures. ## [3.6.6] ### Fixed 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 319968d6b..c6a676e83 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -43,10 +43,12 @@ public class IterableApi { private IterableNotificationData _notificationData; private String _deviceId; private boolean _firstForegroundHandled; + private boolean _autoRetryOnJwtFailure; private IterableHelper.SuccessHandler _setUserSuccessCallbackHandler; 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; @@ -104,6 +106,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 (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 +204,15 @@ 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); + + sharedInstance._autoRetryOnJwtFailure = sharedPref.getBoolean(IterableConstants.SHARED_PREFS_AUTO_RETRY_KEY, false); + } + + /** + * 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/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/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/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/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/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index f052da780..33ecddd3f 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,20 @@ 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); + + handleJwtAuthRetry(iterableApiRequest); } else { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "Invalid API Key"); } @@ -246,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; @@ -498,13 +531,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/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 d27e7102d..90b2d2dc2 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableTaskRunner.java @@ -14,12 +14,13 @@ 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; private IterableNetworkConnectivityManager networkConnectivityManager; private HealthMonitor healthMonitor; + private ApiEndpointClassification classification; private static final int RETRY_INTERVAL_SECONDS = 60; @@ -39,14 +40,19 @@ 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, - 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); @@ -54,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); } @@ -87,6 +101,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,28 +138,50 @@ private void processTasks() { return; } + boolean autoRetry = IterableApi.getInstance().isAutoRetryOnJwtFailure(); + while (networkConnectivityManager.isConnected()) { - IterableTask task = taskStorage.getNextScheduledTask(); + IterableTask task = getNextActionableTask(autoRetry); if (task == null) { return; } - boolean proceed = processTask(task); + boolean proceed = processTask(task, autoRetry); if (!proceed) { - scheduleRetry(); + // Only schedule timed retry for non-auth failures. + // Auth failures will resume via onAuthTokenReady() callback. + if (!autoRetry || !isPausedForAuth) { + scheduleRetry(); + } return; } } } + 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) { + private boolean processTask(@NonNull IterableTask task, boolean autoRetry) { 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) { @@ -151,10 +193,20 @@ private boolean processTask(@NonNull IterableTask task) { if (response.success) { result = TaskResult.SUCCESS; } else { - if (isRetriableError(response.errorMessage)) { - result = TaskResult.RETRY; - } else { + // 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 (isPermanentFailure(response)) { result = TaskResult.FAILURE; + } else { + result = TaskResult.RETRY; } } } @@ -181,8 +233,31 @@ JSONObject getTaskDataWithDate(IterableTask task) { return null; } - private boolean isRetriableError(String errorMessage) { - return errorMessage.contains("failed to connect"); + /** + * Returns true for permanent client errors that should NOT be retried. + * 4xx (except 401 JWT handled above, and 429 rate limit) are permanent. + * 5xx, network errors (responseCode 0), timeouts, and connection failures are transient. + */ + private boolean isPermanentFailure(IterableApiResponse response) { + int code = response.responseCode; + if (code == 0) { + // No HTTP status — network-level error (timeout, DNS, connection reset). Transient. + return false; + } + if (code == 429) { + // Rate limit — server asking us to retry later. Transient. + return false; + } + return code >= 400 && code < 500; + } + + /** + * 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 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 e60b08293..ea5de7e45 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/OfflineRequestProcessor.java @@ -30,19 +30,44 @@ class OfflineRequestProcessor implements RequestProcessor { IterableConstants.ENDPOINT_TRACK_INAPP_CLOSE, IterableConstants.ENDPOINT_TRACK_INBOX_SESSION, IterableConstants.ENDPOINT_TRACK_INAPP_DELIVERY, - IterableConstants.ENDPOINT_GET_EMBEDDED_MESSAGES, IterableConstants.ENDPOINT_INAPP_CONSUME, - IterableConstants.ENDPOINT_DISABLE_DEVICE)); + IterableConstants.ENDPOINT_UPDATE_CART, + IterableConstants.ENDPOINT_TRACK_EMBEDDED_RECEIVED, + IterableConstants.ENDPOINT_TRACK_EMBEDDED_CLICK, + IterableConstants.ENDPOINT_TRACK_EMBEDDED_SESSION + )); OfflineRequestProcessor(Context context) { 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 + try { + IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); + } catch (Exception e) { + 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."); + } } @VisibleForTesting 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/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 b9145748d..5559ba72a 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; @@ -16,12 +20,16 @@ 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.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; @@ -29,7 +37,7 @@ 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 +59,7 @@ public void setUp() throws Exception { @After public void tearDown() throws Exception { server.shutdown(); + IterableTestUtils.resetIterableApi(); } @Test @@ -62,6 +71,7 @@ public void testRunOnTaskCreatedMakesApiRequest() throws Exception { when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); when(mockHealthMonitor.canSchedule()).thenReturn(true); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); taskRunner.onTaskCreated(null); runHandlerTasks(taskRunner); @@ -154,6 +164,7 @@ public void testIfNetworkCheckedBeforeProcessingTask() throws Exception { when(mockNetworkConnectivityManager.isConnected()).thenReturn(true); when(mockHealthMonitor.canProcess()).thenReturn(true); when(mockHealthMonitor.canSchedule()).thenReturn(true); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); taskRunner.onTaskCreated(null); runHandlerTasks(taskRunner); @@ -161,6 +172,462 @@ public void testIfNetworkCheckedBeforeProcessingTask() throws Exception { verify(mockNetworkConnectivityManager, times(2)).isConnected(); } + // region Auto-Retry on JWT Failure Tests + + private String createJwt401ResponseBody() throws Exception { + JSONObject body = new JSONObject(); + body.put("code", "InvalidJwtPayload"); + body.put("msg", "jwt token is expired"); + return body.toString(); + } + + private IterableAuthHandler initApiWithAutoRetry(boolean autoRetryEnabled) { + IterableApi.sharedInstance = new IterableApi(); + 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(); + + // 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; + } + + @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.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); + + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + + shadowOf(getMainLooper()).idle(); + verify(taskCompletedListener).onTaskCompleted(any(String.class), eq(IterableTaskRunner.TaskResult.RETRY), any(IterableApiResponse.class)); + + 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.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(createJwt401ResponseBody())); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + + shadowOf(getMainLooper()).idle(); + verify(mockTaskStorage).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_ProcessingPausesWhenAuthInvalid() throws Exception { + initApiWithAutoRetry(true); + + 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); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(recordedRequest); + + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + } + + @Test + public void testAutoRetryEnabled_ProcessingResumesOnAuthTokenReady() throws Exception { + initApiWithAutoRetry(true); + + 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); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNull(recordedRequest); + + IterableApi.getInstance().getAuthManager().addAuthTokenReadyListener(taskRunner); + + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + when(mockTaskStorage.getNextScheduledTask()).thenReturn(task).thenReturn(null); + + IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(true); + runHandlerTasks(taskRunner); + + recordedRequest = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest); + assertEquals("/api/test", recordedRequest.getPath()); + + 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); + + 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); + + 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); + + 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); + + 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); + + shadowOf(getMainLooper()).idle(); + verify(mockTaskStorage).deleteTask(any(String.class)); + } + + @Test + public void testAuthManagerListenerRegistration() { + initApiWithAutoRetry(true); + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + + authManager.addAuthTokenReadyListener(taskRunner); + + assertTrue(authManager.isAuthTokenReady()); + + authManager.setAuthTokenInvalid(); + assertFalse(authManager.isAuthTokenReady()); + assertEquals(IterableAuthManager.AuthState.INVALID, authManager.getAuthState()); + + authManager.setIsLastAuthTokenValid(true); + assertTrue(authManager.isAuthTokenReady()); + assertEquals(IterableAuthManager.AuthState.VALID, authManager.getAuthState()); + } + + @Test + public void testAutoRetryEnabled_UsesCurrentLiveAuthToken() throws Exception { + initApiWithAutoRetry(true); + + 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); + + 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); + + 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); + + 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()); + + 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); + + server.enqueue(new MockResponse() + .setResponseCode(401) + .setBody(createJwt401ResponseBody())); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + server.enqueue(new MockResponse().setResponseCode(200).setBody("{}")); + + taskRunner.onTaskCreated(null); + runHandlerTasks(taskRunner); + + RecordedRequest recordedRequest1 = server.takeRequest(1, TimeUnit.SECONDS); + assertNotNull(recordedRequest1); + assertEquals("/api/test1", recordedRequest1.getPath()); + + RecordedRequest recordedRequest2 = server.takeRequest(1, TimeUnit.SECONDS); + assertNull("Processing should pause after first JWT failure", recordedRequest2); + + verify(mockTaskStorage, never()).deleteTask(any(String.class)); + + assertEquals(IterableAuthManager.AuthState.INVALID, IterableApi.getInstance().getAuthManager().getAuthState()); + } + + @Test + public void testAuthTokenReadyListener_NotifiedOnStateTransitionFromInvalid() { + initApiWithAutoRetry(true); + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + + IterableAuthManager.AuthTokenReadyListener mockListener = mock(IterableAuthManager.AuthTokenReadyListener.class); + authManager.addAuthTokenReadyListener(mockListener); + + authManager.setAuthTokenInvalid(); + verify(mockListener, never()).onAuthTokenReady(); + + authManager.setIsLastAuthTokenValid(true); + verify(mockListener, times(1)).onAuthTokenReady(); + + authManager.setAuthTokenInvalid(); + verify(mockListener, times(1)).onAuthTokenReady(); + + authManager.setAuthTokenInvalid(); + verify(mockListener, times(1)).onAuthTokenReady(); + + authManager.setIsLastAuthTokenValid(true); + verify(mockListener, times(2)).onAuthTokenReady(); + + 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(); }