Skip to content
Open
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,19 @@ All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).

## [Unreleased]
### Added
- `IterableConfig.Builder.setExpiringAuthTokenRefreshPeriod(double)` accepts fractional seconds, matching the iOS, React Native and Flutter SDKs. Previously Android only accepted whole seconds, so a value like `0.5` behaved differently here than on other platforms. The existing `Long` overload is deprecated but still works, so no code changes are required.

### Fixed
- Fixed a race in JWT auth token refresh scheduling that could leave multiple overlapping refresh timers running. When the refresh timer, an app foreground, and a 401 retry raced to schedule a refresh, the non-atomic timer guard let each create its own timer; the orphaned timers could not be cancelled and each kept requesting new auth tokens, inflating the number of `IterableAuthHandler.onAuthTokenRequested()` calls (and backend JWT generation) over time. Scheduling and clearing of the refresh timer are now synchronized so only one refresh timer is ever active.
- Fixed the keychain treating a transient crypto timeout as a permanent decryption failure. A slow AndroidKeyStore operation that exceeded the 500 ms timeout would wipe the stored email, userId, and auth token and disable encryption, forcing the user to re-authenticate (and request a new auth token) on the next launch. Crypto timeouts are now handled as transient without wiping credentials or disabling encryption for the device: a read that times out returns no value for that call (the stored ciphertext is left intact for the next attempt), and a write that times out stores that one value unencrypted (as the non-encrypted fallback already did) rather than clearing everything. The timed-out crypto operation is also cancelled so it no longer blocks subsequent reads/writes.
- `setExpiringAuthTokenRefreshPeriod` now validates its input instead of silently producing a broken refresh schedule. Previously a negative value was converted to a negative millisecond period and then *subtracted* when computing the refresh time, scheduling the refresh after the token had already expired; a very large value overflowed to a negative period with the same effect; and `null` threw a `NullPointerException` on unboxing. Invalid values (`null`, `NaN`, negatives) are now logged and ignored, leaving the period at whatever it was before the call β€” the 60 second default unless an earlier call set something else. Values above ~10 years are clamped to that ceiling rather than ignored. Zero remains valid and means the token is refreshed only once it has expired.

### Changed
- Clarified that `setExpiringAuthTokenRefreshPeriod` takes **seconds**, with a default of 60. The unit and default are unchanged and match every other Iterable SDK.

### Deprecated
- `IterableConfig.Builder.setExpiringAuthTokenRefreshPeriod(Long)` β€” use the `double` overload instead, which accepts fractional seconds. The `Long` overload delegates to it and remains fully supported.

## [3.10.0]
### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,7 @@ Context getMainActivityContext() {
@NonNull
IterableAuthManager getAuthManager() {
if (authManager == null) {
authManager = new IterableAuthManager(this, config.authHandler, config.retryPolicy, config.expiringAuthTokenRefreshPeriod);
authManager = new IterableAuthManager(this, config.authHandler, config.retryPolicy, config.expiringAuthTokenRefreshPeriodMillis);
}
return authManager;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ interface AuthTokenReadyListener {

private final IterableApi api;
private final IterableAuthHandler authHandler;
private final long expiringAuthTokenRefreshPeriod;
private final long expiringAuthTokenRefreshPeriodMillis;
private final IterableActivityMonitor activityMonitor;
@VisibleForTesting
Timer timer;
Expand All @@ -59,11 +59,11 @@ interface AuthTokenReadyListener {

private final ExecutorService executor = Executors.newSingleThreadExecutor();

IterableAuthManager(IterableApi api, IterableAuthHandler authHandler, RetryPolicy authRetryPolicy, long expiringAuthTokenRefreshPeriod) {
IterableAuthManager(IterableApi api, IterableAuthHandler authHandler, RetryPolicy authRetryPolicy, long expiringAuthTokenRefreshPeriodMillis) {
this.api = api;
this.authHandler = authHandler;
this.authRetryPolicy = authRetryPolicy;
this.expiringAuthTokenRefreshPeriod = expiringAuthTokenRefreshPeriod;
this.expiringAuthTokenRefreshPeriodMillis = expiringAuthTokenRefreshPeriodMillis;
this.activityMonitor = IterableActivityMonitor.getInstance();
this.activityMonitor.addCallback(this);
}
Expand Down Expand Up @@ -249,7 +249,7 @@ public void queueExpirationRefresh(@Nullable String encodedJWT) {
}

long expirationTimeSeconds = decodedExpiration(encodedJWT);
long triggerExpirationRefreshTime = expirationTimeSeconds * 1000L - expiringAuthTokenRefreshPeriod - IterableUtil.currentTimeMillis();
long triggerExpirationRefreshTime = expirationTimeSeconds * 1000L - expiringAuthTokenRefreshPeriodMillis - IterableUtil.currentTimeMillis();
if (triggerExpirationRefreshTime > 0) {
scheduleAuthTokenRefresh(triggerExpirationRefreshTime, true, null);
} else {
Expand Down Expand Up @@ -283,15 +283,15 @@ void handleAuthFailure(String authToken, AuthFailureReason failureReason) {


long getNextRetryInterval() {
long nextRetryInterval = authRetryPolicy.retryInterval;
long nextRetryInterval = authRetryPolicy.retryIntervalMillis;
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) {
synchronized 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;
Expand All @@ -301,6 +301,9 @@ void scheduleAuthTokenRefresh(long timeDuration, boolean isScheduledRefresh, fin
}

try {
// Set the flag before scheduling so concurrent callers can't pass the guard above and
// orphan a second timer (SDK-547).
isTimerScheduled = true;
timer.schedule(new TimerTask() {
@Override
public void run() {
Expand All @@ -309,11 +312,13 @@ public void run() {
} else {
IterableLogger.w(TAG, "Email or userId is not available. Skipping token refresh");
}
isTimerScheduled = false;
synchronized (IterableAuthManager.this) {
isTimerScheduled = false;
}
}
}, timeDuration);
isTimerScheduled = true;
} catch (Exception e) {
isTimerScheduled = false;
IterableLogger.e(TAG, "timer exception: " + timer, e);
}
}
Expand Down Expand Up @@ -362,7 +367,7 @@ private void checkAndHandleAuthRefresh() {
}
}

void clearRefreshTimer() {
synchronized void clearRefreshTimer() {
if (timer != null) {
timer.cancel();
timer = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,16 @@
*
*/
public class IterableConfig {
private static final String TAG = "IterableConfig";

static final long DEFAULT_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS = 60L;

/**
* Ceiling for {@link Builder#setExpiringAuthTokenRefreshPeriod(double)}, in seconds (~10 years).
* Keeps the seconds-to-milliseconds conversion from overflowing into a negative value, which
* would schedule refreshes after the token has already expired.
*/
static final long MAX_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS = 315_360_000L;

/**
* Push integration name - used for token registration.
Expand Down Expand Up @@ -67,9 +77,9 @@ public class IterableConfig {
final IterableUnknownUserHandler iterableUnknownUserHandler;

/**
* Duration prior to an auth expiration that a new auth token should be requested.
* Duration in milliseconds prior to an auth expiration that a new auth token should be requested.
*/
final long expiringAuthTokenRefreshPeriod;
final long expiringAuthTokenRefreshPeriodMillis;

/**
* Retry policy for JWT Refresh.
Expand Down Expand Up @@ -173,7 +183,7 @@ private IterableConfig(Builder builder) {
inAppHandler = builder.inAppHandler;
inAppDisplayInterval = builder.inAppDisplayInterval;
authHandler = builder.authHandler;
expiringAuthTokenRefreshPeriod = builder.expiringAuthTokenRefreshPeriod;
expiringAuthTokenRefreshPeriodMillis = builder.expiringAuthTokenRefreshPeriodMillis;
retryPolicy = builder.retryPolicy;
allowedProtocols = builder.allowedProtocols;
dataRegion = builder.dataRegion;
Expand Down Expand Up @@ -202,7 +212,7 @@ public static class Builder {
private IterableInAppHandler inAppHandler = new IterableDefaultInAppHandler();
private double inAppDisplayInterval = 30.0;
private IterableAuthHandler authHandler;
private long expiringAuthTokenRefreshPeriod = 60000L;
private long expiringAuthTokenRefreshPeriodMillis = DEFAULT_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS * 1000L;
private RetryPolicy retryPolicy = new RetryPolicy(10, 6L, RetryPolicy.Type.LINEAR);
private String[] allowedProtocols = new String[0];
private IterableDataRegion dataRegion = IterableDataRegion.US;
Expand Down Expand Up @@ -341,15 +351,63 @@ public Builder setAuthRetryPolicy(@NonNull RetryPolicy retryPolicy) {
}

/**
* Set a custom period before an auth token expires to automatically retrieve a new token
* Set a custom period before an auth token expires to automatically retrieve a new token.
* <p>
* Defaults to 60 seconds. Fractional seconds are supported, matching the iOS, React Native
* and Flutter SDKs.
* <p>
* A token handed to the SDK with less remaining lifetime than this period is already inside
* its refresh window, which causes the SDK to request another token right away. Keep the
* period comfortably below the lifetime of the tokens the auth handler returns.
* <p>
* Invalid values are logged and ignored rather than throwing, leaving the period at whatever
* it was before the call β€” the 60 second default unless an earlier call set something else
* ({@code null}, {@code NaN}, negatives). Values above ~10 years are clamped to that ceiling
* instead of being ignored, since an excessive period still expresses an intent. Zero is
* valid and means the token is refreshed only once it has expired.
*
* @param period in seconds
*/
@NonNull
public Builder setExpiringAuthTokenRefreshPeriod(@NonNull Long period) {
this.expiringAuthTokenRefreshPeriod = period * 1000L;
public Builder setExpiringAuthTokenRefreshPeriod(double period) {
if (Double.isNaN(period)) {
IterableLogger.w(TAG, "expiringAuthTokenRefreshPeriod cannot be NaN, ignoring it and keeping "
+ expiringAuthTokenRefreshPeriodMillis / 1000d + "s");
return this;
}
if (period < 0) {
IterableLogger.w(TAG, "expiringAuthTokenRefreshPeriod cannot be negative (was " + period
+ "s), ignoring it and keeping " + expiringAuthTokenRefreshPeriodMillis / 1000d + "s");
return this;
}
if (period > MAX_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS) {
IterableLogger.w(TAG, "expiringAuthTokenRefreshPeriod of " + period + "s exceeds the maximum, clamping to "
+ MAX_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS + "s");
this.expiringAuthTokenRefreshPeriodMillis = MAX_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS * 1000L;
return this;
}
this.expiringAuthTokenRefreshPeriodMillis = Math.round(period * 1000d);
return this;
}

/**
* Set a custom period before an auth token expires to automatically retrieve a new token.
*
* @param period in seconds
* @deprecated use {@link #setExpiringAuthTokenRefreshPeriod(double)}, which accepts
* fractional seconds like the iOS, React Native and Flutter SDKs.
*/
@Deprecated
@NonNull
public Builder setExpiringAuthTokenRefreshPeriod(@NonNull Long period) {
if (period == null) {
IterableLogger.w(TAG, "expiringAuthTokenRefreshPeriod cannot be null, ignoring it and keeping "
+ expiringAuthTokenRefreshPeriodMillis / 1000d + "s");
return this;
}
return setExpiringAuthTokenRefreshPeriod((double) period);
}

/**
* Set what URLs the SDK should allow to open (in addition to `https`)
* @param allowedProtocols an array/list of protocols (e.g. `http`, `tel`)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import android.content.Context
import android.content.SharedPreferences
import java.util.concurrent.Callable
import java.util.concurrent.Executors
import java.util.concurrent.TimeoutException
import java.util.concurrent.TimeUnit

class IterableKeychain {
Expand Down Expand Up @@ -71,7 +72,15 @@ class IterableKeychain {
}

private fun <T> runWithTimeout(callable: Callable<T>): T {
return cryptoExecutor.submit(callable).get(CRYPTO_OPERATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
val future = cryptoExecutor.submit(callable)
try {
return future.get(CRYPTO_OPERATION_TIMEOUT_MS, TimeUnit.MILLISECONDS)
} catch (e: Exception) {
// Free the single crypto thread so a slow/hung operation doesn't block every subsequent
// read/write behind it. cancel(true) interrupts the task if it's interruptible. (SDK-547)
future.cancel(true)
throw e
}
}

private fun handleDecryptionError(e: Exception? = null) {
Expand Down Expand Up @@ -120,6 +129,13 @@ class IterableKeychain {
val encryptedValue = sharedPrefs.getString(key, null) ?: return null
return try {
encryptor?.let { runWithTimeout { it.decrypt(encryptedValue) } }
} catch (e: TimeoutException) {
// A crypto operation that times out is transient (slow/contended AndroidKeyStore), not a
// corrupt key. Don't wipe stored credentials or disable encryption over it β€” that would
// force a re-login (and a new auth-token request) on every slow launch. Return null for
// this read; the encrypted value stays intact for the next attempt. (SDK-547)
IterableLogger.w(TAG, "Crypto operation timed out; keeping encrypted data for retry.")
null
} catch (e: Exception) {
handleDecryptionError(e)
null
Expand All @@ -145,6 +161,14 @@ class IterableKeychain {
.remove(key + PLAINTEXT_SUFFIX)
.apply()
}
} catch (e: TimeoutException) {
// Transient slow crypto: store this value as plaintext so it isn't lost, but don't wipe
// other credentials or disable encryption globally. Encryption stays on for future
// writes. (SDK-547)
IterableLogger.w(TAG, "Crypto operation timed out on save; storing this value as plaintext.")
editor.putString(key, value)
.putBoolean(key + PLAINTEXT_SUFFIX, true)
.apply()
} catch (e: Exception) {
handleDecryptionError(e)
editor.putString(key, value)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ public class RetryPolicy {
int maxRetry;

/**
* Configurable duration between JWT refresh retries. Starting point for the retry backoff.
* Configurable duration in milliseconds between JWT refresh retries. Starting point for the retry backoff.
*/
long retryInterval;
long retryIntervalMillis;

/**
* Linear or Exponential. Determines the backoff pattern to apply between retry attempts.
Expand All @@ -21,9 +21,12 @@ public enum Type {
LINEAR,
EXPONENTIAL
}
/**
* @param retryInterval in seconds
*/
public RetryPolicy(int maxRetry, long retryInterval, RetryPolicy.Type retryBackoff) {
this.maxRetry = maxRetry;
this.retryInterval = retryInterval * 1000L;
this.retryIntervalMillis = retryInterval * 1000L;
this.retryBackoff = retryBackoff;
}
}
Expand Down
Loading
Loading