diff --git a/CHANGELOG.md b/CHANGELOG.md index 644799d60..4198ed16d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,9 +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 are now logged and corrected — `null`, `NaN` and negative values fall back to the 60 second default, and values above ~10 years are clamped to that ceiling. 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 73bb9b3af..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 } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index d9e6b2542..3793df753 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(Long)}, 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,62 @@ 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 rather than throwing. Meaningless values fall back to the 60 + * second default ({@code null}, {@code NaN}, negatives); values above ~10 years are clamped + * to that ceiling, 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, using default of " + + DEFAULT_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS + "s"); + return this; + } + if (period < 0) { + IterableLogger.w(TAG, "expiringAuthTokenRefreshPeriod cannot be negative (was " + period + + "s), using default of " + DEFAULT_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS + "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, using default of " + + DEFAULT_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS + "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/RetryPolicy.java b/iterableapi/src/main/java/com/iterable/iterableapi/RetryPolicy.java index d57266d24..100b895c6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/RetryPolicy.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/RetryPolicy.java @@ -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. @@ -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; } } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt index b017c66a6..9cca2dfa8 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt @@ -51,4 +51,86 @@ class IterableConfigTest { val config: IterableConfig = configBuilder.build() assertFalse(config.keychainEncryption) } -} \ No newline at end of file + + @Test + fun defaultExpiringAuthTokenRefreshPeriodIs60Seconds() { + val config: IterableConfig = IterableConfig.Builder().build() + assertEquals(60_000L, config.expiringAuthTokenRefreshPeriodMillis) + } + + @Test + fun setExpiringAuthTokenRefreshPeriodKeepsFractionalSeconds() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(0.5) + .build() + assertEquals(500L, config.expiringAuthTokenRefreshPeriodMillis) + } + + @Test + fun setExpiringAuthTokenRefreshPeriodAcceptsZero() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(0.0) + .build() + assertEquals(0L, config.expiringAuthTokenRefreshPeriodMillis) + } + + @Test + fun negativeExpiringAuthTokenRefreshPeriodFallsBackToDefault() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(-60.0) + .build() + assertEquals(60_000L, config.expiringAuthTokenRefreshPeriodMillis) + } + + @Test + fun oversizedExpiringAuthTokenRefreshPeriodIsClampedWithoutOverflowing() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(Double.MAX_VALUE) + .build() + assertEquals( + IterableConfig.MAX_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS * 1000L, + config.expiringAuthTokenRefreshPeriodMillis + ) + assertTrue(config.expiringAuthTokenRefreshPeriodMillis > 0) + } + + @Test + fun nanExpiringAuthTokenRefreshPeriodFallsBackToDefault() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(Double.NaN) + .build() + assertEquals(60_000L, config.expiringAuthTokenRefreshPeriodMillis) + } + + @Test + @Suppress("DEPRECATION") + fun deprecatedLongOverloadStillConvertsSecondsToMillis() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(java.lang.Long.valueOf(120L)) + .build() + assertEquals(120_000L, config.expiringAuthTokenRefreshPeriodMillis) + } + + @Test + @Suppress("DEPRECATION") + fun deprecatedLongOverloadClampsMaxValueWithoutOverflowing() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(java.lang.Long.valueOf(Long.MAX_VALUE)) + .build() + assertEquals( + IterableConfig.MAX_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS * 1000L, + config.expiringAuthTokenRefreshPeriodMillis + ) + assertTrue(config.expiringAuthTokenRefreshPeriodMillis > 0) + } + + /** Only reachable from Java, where the `@NonNull Long` parameter can still be passed null. */ + @Test + fun nullExpiringAuthTokenRefreshPeriodFallsBackToDefault() { + val builder = IterableConfig.Builder() + val setter = IterableConfig.Builder::class.java + .getMethod("setExpiringAuthTokenRefreshPeriod", java.lang.Long::class.java) + setter.invoke(builder, null) + assertEquals(60_000L, builder.build().expiringAuthTokenRefreshPeriodMillis) + } +}