Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,18 @@ 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 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.1]
### Fixed
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 @@ -47,7 +47,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 @@ -69,11 +69,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 @@ -269,7 +269,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,
Expand Down Expand Up @@ -319,7 +319,7 @@ 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
}
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
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,11 @@
import static junit.framework.Assert.assertEquals;
import static junit.framework.Assert.assertNotNull;
import static junit.framework.Assert.assertNull;
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.verify;
import static org.robolectric.Shadows.shadowOf;
import static org.robolectric.annotation.LooperMode.Mode.PAUSED;

Expand Down Expand Up @@ -509,4 +512,30 @@ public void testAuthTokenRefreshPausesOnBackground() throws Exception {
// Test passes if no exceptions were thrown and lifecycle methods executed successfully
}

// SDK-547: foregrounding the app with a valid token that is far from expiry must NOT request a
// new token. onSwitchToForeground re-evaluates the token (a deliberate Android behavior, since
// java.util.Timer is unreliable across background/Doze) but should only re-arm the expiry timer,
// not call the developer's onAuthTokenRequested. Requesting on every foreground is what inflates
// backend JWT volume relative to iOS.
@Test
public void testForegroundWithValidTokenDoesNotRequestNewToken() throws Exception {
IterableApi.initialize(getContext(), "apiKey");
IterableAuthManager authManager = IterableApi.getInstance().getAuthManager();

// Seed a valid token far from expiry (validJWT exp is year 2062) without going through
// onAuthTokenRequested.
IterableApi.getInstance().setEmail("test@example.com", validJWT);
shadowOf(getMainLooper()).runToEndOfTasks();
assertEquals(validJWT, IterableApi.getInstance().getAuthToken());

// Ignore any handler interactions from setup; we only care about the foreground transition.
clearInvocations(authHandler);

authManager.onSwitchToBackground();
authManager.onSwitchToForeground();
shadowOf(getMainLooper()).runToEndOfTasks();

verify(authHandler, never()).onAuthTokenRequested();
}

}
Loading
Loading