diff --git a/CHANGELOG.md b/CHANGELOG.md index 73056aeef..0feb8cb95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index fd7b1871a..09ccc0dc2 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 9244c1d7e..1b2503117 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -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; @@ -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); } @@ -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, @@ -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 } 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 7fff52fbc..c841de743 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