diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 31bf1264b..ffc04eaf6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -131,7 +131,7 @@ Context getMainActivityContext() { @NonNull IterableAuthManager getAuthManager() { if (authManager == null) { - authManager = new IterableAuthManager(this, config.authHandler, config.expiringAuthTokenRefreshPeriod); + authManager = new IterableAuthManager(this, config.authHandler, config.retryPolicy, config.expiringAuthTokenRefreshPeriod); } return authManager; } @@ -344,7 +344,7 @@ private void logoutPreviousUser() { getInAppManager().reset(); getEmbeddedManager().reset(); - getAuthManager().clearRefreshTimer(); + getAuthManager().reset(); apiClient.onLogout(); } @@ -355,6 +355,7 @@ private void onLogin(@Nullable String authToken) { return; } + getAuthManager().pauseAuthRetries(false); if (authToken != null) { setAuthToken(authToken); } else { @@ -457,7 +458,7 @@ private void retrieveEmailAndUserId() { getAuthManager().queueExpirationRefresh(_authToken); } else { IterableLogger.d(TAG, "Auth token found as null. Scheduling token refresh in 10 seconds..."); - getAuthManager().scheduleAuthTokenRefresh(10000); + getAuthManager().scheduleAuthTokenRefresh(authManager.getNextRetryInterval(), true, null); } } } @@ -697,6 +698,17 @@ public IterableAttributionInfo getAttributionInfo() { ); } + /** + * // This method gets called from developer end only. + * @param pauseRetry to pause/unpause auth retries + */ + public void pauseAuthRetries(boolean pauseRetry) { + getAuthManager().pauseAuthRetries(pauseRetry); + if (!pauseRetry) { // request new auth token as soon as unpause + getAuthManager().requestNewAuthToken(false); + } + } + public void setEmail(@Nullable String email) { setEmail(email, null, null, null); } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java index 562e86bd7..87e51f1cd 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -1,7 +1,6 @@ package com.iterable.iterableapi; import android.util.Base64; - import androidx.annotation.VisibleForTesting; import org.json.JSONException; @@ -20,23 +19,46 @@ public class IterableAuthManager { private final IterableApi api; private final IterableAuthHandler authHandler; private final long expiringAuthTokenRefreshPeriod; - private final long scheduledRefreshPeriod = 10000; @VisibleForTesting Timer timer; private boolean hasFailedPriorAuth; private boolean pendingAuth; private boolean requiresAuthRefresh; + RetryPolicy authRetryPolicy; + boolean pauseAuthRetry; + int retryCount; + private boolean isLastAuthTokenValid; + private boolean isTimerScheduled; private final ExecutorService executor = Executors.newSingleThreadExecutor(); - IterableAuthManager(IterableApi api, IterableAuthHandler authHandler, long expiringAuthTokenRefreshPeriod) { + IterableAuthManager(IterableApi api, IterableAuthHandler authHandler, RetryPolicy authRetryPolicy, long expiringAuthTokenRefreshPeriod) { this.api = api; this.authHandler = authHandler; + this.authRetryPolicy = authRetryPolicy; this.expiringAuthTokenRefreshPeriod = expiringAuthTokenRefreshPeriod; } public synchronized void requestNewAuthToken(boolean hasFailedPriorAuth) { - requestNewAuthToken(hasFailedPriorAuth, null); + requestNewAuthToken(hasFailedPriorAuth, null, true); + } + + public void pauseAuthRetries(boolean pauseRetry) { + pauseAuthRetry = pauseRetry; + resetRetryCount(); + } + + void reset() { + clearRefreshTimer(); + setIsLastAuthTokenValid(false); + } + + void setIsLastAuthTokenValid(boolean isValid) { + isLastAuthTokenValid = isValid; + } + + void resetRetryCount() { + retryCount = 0; } private void handleSuccessForAuthToken(String authToken, IterableHelper.SuccessHandler successCallback) { @@ -51,7 +73,12 @@ private void handleSuccessForAuthToken(String authToken, IterableHelper.SuccessH public synchronized void requestNewAuthToken( boolean hasFailedPriorAuth, - final IterableHelper.SuccessHandler successCallback) { + final IterableHelper.SuccessHandler successCallback, + boolean shouldIgnoreRetryPolicy) { + if ((!shouldIgnoreRetryPolicy && pauseAuthRetry) || (retryCount >= authRetryPolicy.maxRetry && !shouldIgnoreRetryPolicy)) { + return; + } + if (authHandler != null) { if (!pendingAuth) { if (!(this.hasFailedPriorAuth && hasFailedPriorAuth)) { @@ -62,9 +89,17 @@ public synchronized void requestNewAuthToken( @Override public void run() { try { + if (isLastAuthTokenValid && !shouldIgnoreRetryPolicy) { + // if some JWT retry had valid token it will not fetch the auth token again from developer function + handleAuthTokenSuccess(IterableApi.getInstance().getAuthToken(), successCallback); + return; + } final String authToken = authHandler.onAuthTokenRequested(); + pendingAuth = false; + retryCount++; handleAuthTokenSuccess(authToken, successCallback); } catch (final Exception e) { + retryCount++; handleAuthTokenFailure(e); } } @@ -89,12 +124,11 @@ private void handleAuthTokenSuccess(String authToken, IterableHelper.SuccessHand } else { IterableLogger.w(TAG, "Auth token received as null. Calling the handler in 10 seconds"); //TODO: Make this time configurable and in sync with SDK initialization flow for auth null scenario - scheduleAuthTokenRefresh(scheduledRefreshPeriod); + scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); authHandler.onTokenRegistrationFailed(new Throwable("Auth token null")); return; } IterableApi.getInstance().setAuthToken(authToken); - pendingAuth = false; reSyncAuth(); authHandler.onTokenRegistrationSuccessful(authToken); } @@ -103,7 +137,7 @@ private void handleAuthTokenFailure(Throwable throwable) { IterableLogger.e(TAG, "Error while requesting Auth Token", throwable); authHandler.onTokenRegistrationFailed(throwable); pendingAuth = false; - reSyncAuth(); + scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); } public void queueExpirationRefresh(String encodedJWT) { @@ -112,7 +146,7 @@ public void queueExpirationRefresh(String encodedJWT) { long expirationTimeSeconds = decodedExpiration(encodedJWT); long triggerExpirationRefreshTime = expirationTimeSeconds * 1000L - expiringAuthTokenRefreshPeriod - IterableUtil.currentTimeMillis(); if (triggerExpirationRefreshTime > 0) { - scheduleAuthTokenRefresh(triggerExpirationRefreshTime); + scheduleAuthTokenRefresh(triggerExpirationRefreshTime, true, null); } else { IterableLogger.w(TAG, "The expiringAuthTokenRefreshPeriod has already passed for the current JWT"); } @@ -120,7 +154,7 @@ public void queueExpirationRefresh(String encodedJWT) { IterableLogger.e(TAG, "Error while parsing JWT for the expiration", e); authHandler.onTokenRegistrationFailed(new Throwable("Auth token decode failure. Scheduling auth token refresh in 10 seconds...")); //TODO: Sync with configured time duration once feature is available. - scheduleAuthTokenRefresh(scheduledRefreshPeriod); + scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); } } @@ -131,28 +165,57 @@ void resetFailedAuth() { void reSyncAuth() { if (requiresAuthRefresh) { requiresAuthRefresh = false; - requestNewAuthToken(false); + scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); } } - void scheduleAuthTokenRefresh(long timeDuration) { - timer = new Timer(true); + long getNextRetryInterval() { + long nextRetryInterval = authRetryPolicy.retryInterval; + if (authRetryPolicy.retryBackoff == RetryPolicy.Type.EXPONENTIAL) { + nextRetryInterval *= Math.pow(IterableConstants.EXPONENTIAL_FACTOR, retryCount - 1); // Exponential backoff + } + + return nextRetryInterval; + } + + void scheduleAuthTokenRefresh(long timeDuration, boolean isScheduledRefresh, final IterableHelper.SuccessHandler successCallback) { + if ((pauseAuthRetry && !isScheduledRefresh) || isTimerScheduled) { + // we only stop schedule token refresh if it is called from retry (in case of failure). The normal auth token refresh schedule would work + return; + } + if (timer == null) { + timer = new Timer(true); + } try { timer.schedule(new TimerTask() { @Override public void run() { if (api.getEmail() != null || api.getUserId() != null) { - api.getAuthManager().requestNewAuthToken(false); + api.getAuthManager().requestNewAuthToken(false, successCallback, isScheduledRefresh); } else { IterableLogger.w(TAG, "Email or userId is not available. Skipping token refresh"); } + isTimerScheduled = false; } }, timeDuration); + isTimerScheduled = true; } catch (Exception e) { IterableLogger.e(TAG, "timer exception: " + timer, e); } } + private String getEmailOrUserId() { + String email = api.getEmail(); + String userId = api.getUserId(); + + if (email != null) { + return email; + } else if (userId != null) { + return userId; + } + return null; + } + private static long decodedExpiration(String encodedJWT) throws Exception { long exp = 0; String[] split = encodedJWT.split("\\."); diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 14f370c45..598e9a216 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -65,6 +65,11 @@ public class IterableConfig { */ final long expiringAuthTokenRefreshPeriod; + /** + * Retry policy for JWT Refresh. + */ + final RetryPolicy retryPolicy; + /** * By default, the SDK allows navigation/calls to URLs with the `https` protocol (e.g. deep links or external links) * If you'd like to allow other protocols like `http`, `tel`, etc., add them to the `allowedProtocols` array @@ -100,6 +105,7 @@ private IterableConfig(Builder builder) { inAppDisplayInterval = builder.inAppDisplayInterval; authHandler = builder.authHandler; expiringAuthTokenRefreshPeriod = builder.expiringAuthTokenRefreshPeriod; + retryPolicy = builder.retryPolicy; allowedProtocols = builder.allowedProtocols; dataRegion = builder.dataRegion; useInMemoryStorageForInApps = builder.useInMemoryStorageForInApps; @@ -118,6 +124,7 @@ public static class Builder { private double inAppDisplayInterval = 30.0; private IterableAuthHandler authHandler; private long expiringAuthTokenRefreshPeriod = 60000L; + private RetryPolicy retryPolicy = new RetryPolicy(10, 6L, RetryPolicy.Type.LINEAR); private String[] allowedProtocols = new String[0]; private IterableDataRegion dataRegion = IterableDataRegion.US; private boolean useInMemoryStorageForInApps = false; @@ -224,6 +231,16 @@ public Builder setAuthHandler(@NonNull IterableAuthHandler authHandler) { return this; } + /** + * Set retry policy for JWT Refresh + * @param retryPolicy + */ + @NonNull + public Builder setAuthRetryPolicy(@NonNull RetryPolicy retryPolicy) { + this.retryPolicy = retryPolicy; + return this; + } + /** * Set a custom period before an auth token expires to automatically retrieve a new token * @param period in seconds diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java index f61b03315..43e4bd70f 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConstants.java @@ -260,6 +260,8 @@ public final class IterableConstants { public static final int ITERABLE_IN_APP_ANIMATION_DURATION = 500; public static final int ITERABLE_IN_APP_BACKGROUND_ANIMATION_DURATION = 300; + public static final int EXPONENTIAL_FACTOR = 2; + public static final double ITERABLE_IN_APP_PRIORITY_LEVEL_LOW = 400.0; public static final double ITERABLE_IN_APP_PRIORITY_LEVEL_MEDIUM = 300.0; public static final double ITERABLE_IN_APP_PRIORITY_LEVEL_HIGH = 200.0; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index f011ba273..430853d77 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -1,5 +1,8 @@ package com.iterable.iterableapi; +import static com.iterable.iterableapi.IterableConstants.ENDPOINT_DISABLE_DEVICE; +import static com.iterable.iterableapi.IterableConstants.ENDPOINT_GET_REMOTE_CONFIGURATION; + import android.net.Uri; import android.os.AsyncTask; import android.os.Handler; @@ -22,6 +25,7 @@ import java.net.URL; import java.util.Date; import java.util.Iterator; +import java.util.Objects; /** * Async task to handle sending data to the Iterable server @@ -38,9 +42,9 @@ class IterableRequestTask extends AsyncTask 0) { iterableApiRequest = params[0]; } - return executeApiRequest(iterableApiRequest); } - public void setShouldRetryWhileJwtInvalid(boolean shouldRetryWhileJwtInvalid) { - this.shouldRetryWhileJwtInvalid = shouldRetryWhileJwtInvalid; - } - - private void retryRequestWithNewAuthToken(String newAuthToken) { + private static void retryRequestWithNewAuthToken(String newAuthToken, IterableApiRequest iterableApiRequest) { IterableApiRequest request = new IterableApiRequest( iterableApiRequest.apiKey, iterableApiRequest.resourcePath, @@ -71,7 +70,6 @@ private void retryRequestWithNewAuthToken(String newAuthToken) { newAuthToken, iterableApiRequest.legacyCallback); IterableRequestTask requestTask = new IterableRequestTask(); - requestTask.setShouldRetryWhileJwtInvalid(false); requestTask.execute(request); } @@ -190,8 +188,10 @@ static IterableApiResponse executeApiRequest(IterableApiRequest iterableApiReque // Handle HTTP status codes if (responseCode == 401) { - if (matchesErrorCode(jsonResponse, ERROR_CODE_INVALID_JWT_PAYLOAD)) { + if (matchesJWTErrorCodes(jsonResponse)) { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "JWT Authorization header error"); + // We handle the JWT Retry for both online and offline here rather than handling online request in onPostExecute + requestNewAuthTokenAndRetry(iterableApiRequest); } else { apiResponse = IterableApiResponse.failure(responseCode, requestResult, jsonResponse, "Invalid API Key"); } @@ -265,6 +265,10 @@ private static boolean matchesErrorCode(JSONObject jsonResponse, String errorCod } } + private static boolean matchesJWTErrorCodes(JSONObject jsonResponse) { + return matchesErrorCode(jsonResponse, ERROR_CODE_INVALID_JWT_PAYLOAD) || matchesErrorCode(jsonResponse, ERROR_CODE_MISSING_JWT_PAYLOAD) || matchesErrorCode(jsonResponse, ERROR_CODE_JWT_USER_IDENTIFIERS_MISMATCHED); + } + private static void logError(IterableApiRequest iterableApiRequest, String baseUrl, Exception e) { IterableLogger.e(TAG, "<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<<\n" + "Exception occurred for : " + baseUrl + iterableApiRequest.resourcePath); @@ -329,27 +333,30 @@ public void run() { } private void handleSuccessResponse(IterableApiResponse response) { - IterableApi.getInstance().getAuthManager().resetFailedAuth(); + if (!Objects.equals(iterableApiRequest.resourcePath, ENDPOINT_GET_REMOTE_CONFIGURATION) && !Objects.equals(iterableApiRequest.resourcePath, ENDPOINT_DISABLE_DEVICE)) { + IterableApi.getInstance().getAuthManager().resetFailedAuth(); + IterableApi.getInstance().getAuthManager().pauseAuthRetries(false); + IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(true); + } + if (iterableApiRequest.successCallback != null) { iterableApiRequest.successCallback.onSuccess(response.responseJson); } } private void handleErrorResponse(IterableApiResponse response) { - if (matchesErrorCode(response.responseJson, ERROR_CODE_INVALID_JWT_PAYLOAD) && shouldRetryWhileJwtInvalid) { - requestNewAuthTokenAndRetry(response); - } - if (iterableApiRequest.failureCallback != null) { iterableApiRequest.failureCallback.onFailure(response.errorMessage, response.responseJson); } } - private void requestNewAuthTokenAndRetry(IterableApiResponse response) { - IterableApi.getInstance().getAuthManager().requestNewAuthToken(false, data -> { + private static void requestNewAuthTokenAndRetry(IterableApiRequest iterableApiRequest) { + IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(false); + long retryInterval = IterableApi.getInstance().getAuthManager().getNextRetryInterval(); + IterableApi.getInstance().getAuthManager().scheduleAuthTokenRefresh(retryInterval, false, data -> { try { String newAuthToken = data.getString("newAuthToken"); - retryRequestWithNewAuthToken(newAuthToken); + retryRequestWithNewAuthToken(newAuthToken, iterableApiRequest); } catch (JSONException e) { e.printStackTrace(); } @@ -489,4 +496,4 @@ static IterableApiResponse success(int responseCode, String body, @NonNull JSONO static IterableApiResponse failure(int responseCode, String body, @Nullable JSONObject json, String errorMessage) { return new IterableApiResponse(false, responseCode, body, json, errorMessage); } -} \ No newline at end of file +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/RetryPolicy.java b/iterableapi/src/main/java/com/iterable/iterableapi/RetryPolicy.java new file mode 100644 index 000000000..d57266d24 --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/RetryPolicy.java @@ -0,0 +1,30 @@ +package com.iterable.iterableapi; + + +public class RetryPolicy { + + /** + * Number of consecutive JWT refresh retries the SDK should attempt before disabling JWT refresh attempts altogether. + */ + int maxRetry; + + /** + * Configurable duration between JWT refresh retries. Starting point for the retry backoff. + */ + long retryInterval; + + /** + * Linear or Exponential. Determines the backoff pattern to apply between retry attempts. + */ + RetryPolicy.Type retryBackoff; + public enum Type { + LINEAR, + EXPONENTIAL + } + public RetryPolicy(int maxRetry, long retryInterval, RetryPolicy.Type retryBackoff) { + this.maxRetry = maxRetry; + this.retryInterval = retryInterval * 1000L; + this.retryBackoff = retryBackoff; + } +} +