diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d40ce8f5..e99a24315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 95eb354ba..704bda5ce 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -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; } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java index 3a960e54a..18033a587 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -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; @@ -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); } @@ -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 { @@ -283,7 +283,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 } @@ -291,7 +291,7 @@ long getNextRetryInterval() { 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; @@ -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() { @@ -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); } } @@ -362,7 +367,7 @@ private void checkAndHandleAuthRefresh() { } } - void clearRefreshTimer() { + synchronized void clearRefreshTimer() { if (timer != null) { timer.cancel(); timer = null; diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index d9e6b2542..a7c532d0b 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -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. @@ -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. @@ -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; @@ -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; @@ -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. + *
+ * Defaults to 60 seconds. Fractional seconds are supported, matching the iOS, React Native + * and Flutter SDKs. + *
+ * 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. + *
+ * 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`)
diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt
index 1a8235b09..1d5b22ba0 100644
--- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt
+++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt
@@ -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 {
@@ -71,7 +72,15 @@ class IterableKeychain {
}
private fun