From 866845fbc9575e355757c18ecf4ead39c8243f4d Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 23 Jul 2026 16:12:06 +0100 Subject: [PATCH 1/7] [SDK-547] Synchronize JWT auth refresh timer scheduling The refresh timer, app foreground, and 401 retry paths all call scheduleAuthTokenRefresh from different threads. The isTimerScheduled guard was a non-atomic check-then-act: concurrent callers could all pass it and each schedule a TimerTask, and each foreground reschedule could create a new Timer while orphaning the previous one. Orphaned timers could not be cancelled by clearRefreshTimer and kept firing onAuthTokenRequested, inflating backend JWT generation over time. Make scheduleAuthTokenRefresh and clearRefreshTimer synchronized, and set isTimerScheduled before scheduling (reset on failure) so only one refresh timer is ever active. Adds a concurrency test asserting a single timer under 8 racing callers, and a test asserting foregrounding with a valid, far-from-expiry token does not request a new token. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 + .../iterableapi/IterableAuthManager.java | 13 ++- .../iterableapi/IterableApiAuthTests.java | 88 +++++++++++++++++++ 3 files changed, 99 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d40ce8f5..d254f304c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to this project will be documented in this file. This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] +### 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. ## [3.10.0] ### Added diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java index 3a960e54a..73bb9b3af 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -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/test/java/com/iterable/iterableapi/IterableApiAuthTests.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthTests.java index ebc879508..be1361079 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthTests.java +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthTests.java @@ -11,7 +11,10 @@ import java.io.IOException; import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CyclicBarrier; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; @@ -23,8 +26,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; @@ -509,4 +515,86 @@ 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(); + } + + // SDK-547: scheduleAuthTokenRefresh reads isTimerScheduled, schedules a TimerTask, then sets + // isTimerScheduled=true only AFTER the schedule call returns. The read/set isn't atomic, so + // concurrent callers (foreground refresh, 401 retry, an already-firing timer) all observe + // false and each schedule their own timer off a single guard. Overlapping timers each fire + // onAuthTokenRequested and each success reschedules, so JWT request volume compounds. + // + // We drive scheduleAuthTokenRefresh directly rather than requestNewAuthToken: the executor is + // not injectable (see @Ignore'd tests above) and its pendingAuth guard serializes calls, + // which would hide the scheduling race we're targeting. + @Test + public void testConcurrentScheduleAuthTokenRefreshSchedulesOnlyOneTimer() throws Exception { + IterableApi.initialize(getContext(), "apiKey"); + IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); + + final AtomicInteger scheduleCount = new AtomicInteger(0); + // Fake timer that counts schedule() calls and holds briefly, so every racing thread has + // passed the guard before the winner writes isTimerScheduled=true. + Timer countingTimer = new Timer(true) { + @Override + public void schedule(TimerTask task, long delay) { + scheduleCount.incrementAndGet(); + try { + Thread.sleep(100); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }; + authManager.timer = countingTimer; + + final int threadCount = 8; + final CyclicBarrier barrier = new CyclicBarrier(threadCount); + Thread[] threads = new Thread[threadCount]; + for (int i = 0; i < threadCount; i++) { + threads[i] = new Thread(() -> { + try { + barrier.await(); + authManager.scheduleAuthTokenRefresh(60000, true, null); + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + for (Thread t : threads) { + t.start(); + } + for (Thread t : threads) { + t.join(); + } + + countingTimer.cancel(); + + assertEquals("Concurrent scheduling should result in exactly one live timer", + 1, scheduleCount.get()); + } + } From 2198b809e004d5845c9d68d1053f566af94f6923 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 27 Jul 2026 18:57:08 +0100 Subject: [PATCH 2/7] [SDK-547] Don't wipe credentials on transient crypto timeout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit IterableKeychain decrypts/encrypts the stored auth token under a 500ms timeout. A slow AndroidKeyStore operation that exceeded it was caught in the same branch as a genuine decryption failure, which wipes the stored email, userId, and auth token and permanently disables encryption. That forces a re-login and a fresh onAuthTokenRequested on the next launch — on slower devices this can recur, inflating backend JWT generation. Handle TimeoutException separately from real crypto failures: on a timeout, preserve the encrypted data and fall back only for the current read/write (plaintext on save), without wiping credentials or disabling encryption. Genuine decryption errors still wipe as before. Adds a test asserting a crypto timeout does not wipe credentials, disable encryption, or invoke the decryption-failure handler. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 1 + .../iterable/iterableapi/IterableKeychain.kt | 16 +++++++++++++ .../iterableapi/IterableKeychainTest.kt | 24 +++++++++++++++++++ 3 files changed, 41 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d254f304c..a9b52d23c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### 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: the encrypted data is preserved and only the current read/write falls back, without wiping credentials or disabling encryption. ## [3.10.0] ### Added diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt index 1a8235b09..0c29fe8b2 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 { @@ -120,6 +121,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 @@ -145,6 +153,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) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index 53fc5944b..f6258ee13 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -178,6 +178,30 @@ class IterableKeychainTest { assertNull(result) } + @Test + fun testDecryptionTimeoutDoesNotWipeCredentials() { + // SDK-547: a transient crypto timeout (slow AndroidKeyStore) must NOT wipe stored + // credentials or disable encryption — otherwise the app re-logs-in (and requests a new + // auth token) on every slow launch. Simulate a slow decrypt that exceeds the 500ms timeout. + `when`(mockEncryptor.decrypt(any())).thenAnswer { + Thread.sleep(700) + "should_not_be_returned" + } + `when`(mockSharedPrefs.getString(eq("iterable-auth-token"), isNull())) + .thenReturn("any_encrypted_value") + + val result = keychain.getAuthToken() + + // Read returns null for this attempt... + assertNull(result) + // ...but nothing is wiped and the failure handler is NOT invoked (it's transient). + verify(mockEditor, never()).remove("iterable-email") + verify(mockEditor, never()).remove("iterable-user-id") + verify(mockEditor, never()).remove("iterable-auth-token") + verify(mockEditor, never()).putBoolean(eq("iterable-encryption-enabled"), eq(false)) + verify(mockDecryptionFailureHandler, never()).onDecryptionFailed(any()) + } + @Test fun testDecryptionFailureForAllOperations() { // Setup mock to throw runtime exception From f196b97aa718d12cff47371604aafe17b951d6df Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Tue, 28 Jul 2026 09:56:49 +0100 Subject: [PATCH 3/7] [SDK-547] Cancel timed-out crypto op so it can't block later reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up. runWithTimeout ran crypto on a single-thread executor and, on timeout, left the Future running — a slow/hung AndroidKeyStore operation kept occupying the only worker thread, so every subsequent read/write queued behind it and also timed out. Cancel the Future on timeout (interrupting the task if interruptible) to free the thread. Also clarify the CHANGELOG to note that a write timeout stores that one value unencrypted (the existing non-encrypted fallback), rather than implying nothing is written. Adds a test asserting a slow crypto op that times out does not block the next read (fails without the cancel). Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 2 +- .../iterable/iterableapi/IterableKeychain.kt | 10 ++++++- .../iterableapi/IterableKeychainTest.kt | 27 +++++++++++++++++++ 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a9b52d23c..644799d60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ## [Unreleased] ### 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: the encrypted data is preserved and only the current read/write falls back, without wiping credentials or disabling encryption. +- 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. ## [3.10.0] ### Added diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt index 0c29fe8b2..1d5b22ba0 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt @@ -72,7 +72,15 @@ class IterableKeychain { } private fun runWithTimeout(callable: Callable): 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) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index f6258ee13..3d0ccc913 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -202,6 +202,33 @@ class IterableKeychainTest { verify(mockDecryptionFailureHandler, never()).onDecryptionFailed(any()) } + @Test + fun testCryptoTimeoutDoesNotBlockSubsequentReads() { + // SDK-547: crypto runs on a single-thread executor. A slow op that times out must be + // cancelled so it frees the thread and doesn't clog the next read. Here the first decrypt + // is interruptible-sleeping past the 500ms timeout; the second is fast. Without cancelling + // the timed-out task, the second read would queue behind the still-running first one and + // also time out (null). With cancellation it completes normally. + val firstCall = java.util.concurrent.atomic.AtomicBoolean(true) + `when`(mockEncryptor.decrypt(any())).thenAnswer { + if (firstCall.getAndSet(false)) { + Thread.sleep(5000) // slow/hung; will be interrupted by cancel(true) + "slow_value" + } else { + "encrypted_fast".substring("encrypted_".length) // -> "fast" + } + } + `when`(mockSharedPrefs.getString(eq("iterable-auth-token"), isNull())) + .thenReturn("any_encrypted_value") + `when`(mockSharedPrefs.getString(eq("iterable-email"), isNull())) + .thenReturn("encrypted_fast") + + // First read times out -> null (task gets cancelled/interrupted, freeing the thread). + assertNull(keychain.getAuthToken()) + // Second read must NOT be blocked behind the first; it decrypts promptly. + assertEquals("fast", keychain.getEmail()) + } + @Test fun testDecryptionFailureForAllOperations() { // Setup mock to throw runtime exception From ad859febcd354848206f9b61f5a5488ea82718b8 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Fri, 7 Aug 2026 11:35:23 +0100 Subject: [PATCH 4/7] [SDK-566] Accept fractional seconds for expiringAuthTokenRefreshPeriod Android only accepted whole seconds while iOS, React Native and Flutter accept fractional ones, so the same configuration value could behave differently per platform. Add a double overload and deprecate the Long one, which now delegates to it. Existing callers keep compiling. The setter also accepted any value unguarded. Because the period is subtracted when computing the refresh time, a negative value scheduled the refresh after the token had already expired, a very large value overflowed to a negative period with the same effect, and null threw an NPE on unboxing. Values carrying no usable intent (null, NaN, negative) now fall back to the 60s default; an excessive period still expresses an intent, so it is clamped to a ceiling. Logged rather than thrown. Also rename the internal carriers to ...Millis so the seconds-in, milliseconds-stored split is explicit. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 10 ++ .../com/iterable/iterableapi/IterableApi.java | 2 +- .../iterableapi/IterableAuthManager.java | 10 +- .../iterable/iterableapi/IterableConfig.java | 71 +++++++++-- .../com/iterable/iterableapi/RetryPolicy.java | 9 +- .../iterableapi/IterableConfigTest.kt | 115 +++++++++++++++++- 6 files changed, 200 insertions(+), 17 deletions(-) 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..8cbd66e5d 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt @@ -51,4 +51,117 @@ 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 setExpiringAuthTokenRefreshPeriodConvertsSecondsToMillis() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(120.0) + .build() + assertEquals(120_000L, config.expiringAuthTokenRefreshPeriodMillis) + } + + @Test + fun setExpiringAuthTokenRefreshPeriodKeepsFractionalSeconds() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(0.5) + .build() + assertEquals(500L, config.expiringAuthTokenRefreshPeriodMillis) + } + + @Test + fun setExpiringAuthTokenRefreshPeriodKeepsSubSecondPrecisionOnLargerValues() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(90.25) + .build() + assertEquals(90_250L, 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 deprecatedLongOverloadFallsBackToDefaultForMostNegativeValue() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(java.lang.Long.valueOf(Long.MIN_VALUE)) + .build() + assertEquals(60_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) + } + + @Test + fun retryPolicyConvertsRetryIntervalSecondsToMillis() { + val retryPolicy = RetryPolicy(10, 6L, RetryPolicy.Type.LINEAR) + assertEquals(6_000L, retryPolicy.retryIntervalMillis) + } +} From 02ed87c6a253f1d963bb122fadbccc6ba30b5544 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Mon, 10 Aug 2026 14:35:41 +0100 Subject: [PATCH 5/7] [SDK-566] Trim redundant refresh-period config tests Drop four tests that re-covered behaviour already guarded elsewhere or never changed: whole-second conversion (covered via the deprecated Long overload's delegation), 90.25s sub-second precision (covered by 0.5s), Long.MIN_VALUE (delegates to the double path's negative guard), and the RetryPolicy interval conversion, which this branch only renamed. Co-Authored-By: Claude Opus 5 --- .../iterableapi/IterableConfigTest.kt | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt index 8cbd66e5d..9cca2dfa8 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt @@ -58,14 +58,6 @@ class IterableConfigTest { assertEquals(60_000L, config.expiringAuthTokenRefreshPeriodMillis) } - @Test - fun setExpiringAuthTokenRefreshPeriodConvertsSecondsToMillis() { - val config: IterableConfig = IterableConfig.Builder() - .setExpiringAuthTokenRefreshPeriod(120.0) - .build() - assertEquals(120_000L, config.expiringAuthTokenRefreshPeriodMillis) - } - @Test fun setExpiringAuthTokenRefreshPeriodKeepsFractionalSeconds() { val config: IterableConfig = IterableConfig.Builder() @@ -74,14 +66,6 @@ class IterableConfigTest { assertEquals(500L, config.expiringAuthTokenRefreshPeriodMillis) } - @Test - fun setExpiringAuthTokenRefreshPeriodKeepsSubSecondPrecisionOnLargerValues() { - val config: IterableConfig = IterableConfig.Builder() - .setExpiringAuthTokenRefreshPeriod(90.25) - .build() - assertEquals(90_250L, config.expiringAuthTokenRefreshPeriodMillis) - } - @Test fun setExpiringAuthTokenRefreshPeriodAcceptsZero() { val config: IterableConfig = IterableConfig.Builder() @@ -127,15 +111,6 @@ class IterableConfigTest { assertEquals(120_000L, config.expiringAuthTokenRefreshPeriodMillis) } - @Test - @Suppress("DEPRECATION") - fun deprecatedLongOverloadFallsBackToDefaultForMostNegativeValue() { - val config: IterableConfig = IterableConfig.Builder() - .setExpiringAuthTokenRefreshPeriod(java.lang.Long.valueOf(Long.MIN_VALUE)) - .build() - assertEquals(60_000L, config.expiringAuthTokenRefreshPeriodMillis) - } - @Test @Suppress("DEPRECATION") fun deprecatedLongOverloadClampsMaxValueWithoutOverflowing() { @@ -158,10 +133,4 @@ class IterableConfigTest { setter.invoke(builder, null) assertEquals(60_000L, builder.build().expiringAuthTokenRefreshPeriodMillis) } - - @Test - fun retryPolicyConvertsRetryIntervalSecondsToMillis() { - val retryPolicy = RetryPolicy(10, 6L, RetryPolicy.Type.LINEAR) - assertEquals(6_000L, retryPolicy.retryIntervalMillis) - } } From 82682335fc0d41d64e2a2bbd48c0e395a5440014 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 13 Aug 2026 10:06:04 +0100 Subject: [PATCH 6/7] [SDK-566] Describe invalid refresh periods as ignored, not reset The NaN/negative/null branches return without assigning, so the period keeps its prior value rather than falling back to 60s. Setting 30s and then -60s left 30s, not the 60s the javadoc and CHANGELOG claimed. Docs and log messages now say the value is ignored and report what is actually kept. Behaviour is unchanged: ignoring bad input preserves an explicitly configured period instead of discarding it. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 2 +- .../iterable/iterableapi/IterableConfig.java | 19 ++++++++++--------- .../iterableapi/IterableConfigTest.kt | 10 ++++++++++ 3 files changed, 21 insertions(+), 10 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4198ed16d..e99a24315 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). ### 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. +- `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. diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 3793df753..622bc673a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -360,23 +360,24 @@ public Builder setAuthRetryPolicy(@NonNull RetryPolicy retryPolicy) { * 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. + * 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(double period) { if (Double.isNaN(period)) { - IterableLogger.w(TAG, "expiringAuthTokenRefreshPeriod cannot be NaN, using default of " - + DEFAULT_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS + "s"); + 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), using default of " + DEFAULT_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS + "s"); + + "s), ignoring it and keeping " + expiringAuthTokenRefreshPeriodMillis / 1000d + "s"); return this; } if (period > MAX_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS) { @@ -400,8 +401,8 @@ public Builder setExpiringAuthTokenRefreshPeriod(double period) { @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"); + IterableLogger.w(TAG, "expiringAuthTokenRefreshPeriod cannot be null, ignoring it and keeping " + + expiringAuthTokenRefreshPeriodMillis / 1000d + "s"); return this; } return setExpiringAuthTokenRefreshPeriod((double) period); diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt index 9cca2dfa8..646105f1f 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt @@ -82,6 +82,16 @@ class IterableConfigTest { assertEquals(60_000L, config.expiringAuthTokenRefreshPeriodMillis) } + @Test + fun invalidExpiringAuthTokenRefreshPeriodKeepsThePreviouslySetValue() { + val config: IterableConfig = IterableConfig.Builder() + .setExpiringAuthTokenRefreshPeriod(30.0) + .setExpiringAuthTokenRefreshPeriod(-60.0) + .setExpiringAuthTokenRefreshPeriod(Double.NaN) + .build() + assertEquals(30_000L, config.expiringAuthTokenRefreshPeriodMillis) + } + @Test fun oversizedExpiringAuthTokenRefreshPeriodIsClampedWithoutOverflowing() { val config: IterableConfig = IterableConfig.Builder() From 467a669c6a97296b5314577834afda07a9458c56 Mon Sep 17 00:00:00 2001 From: Franco Zalamena Date: Thu, 13 Aug 2026 10:11:55 +0100 Subject: [PATCH 7/7] Update iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java Co-authored-by: Ricardo Silva --- .../src/main/java/com/iterable/iterableapi/IterableConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java index 622bc673a..a7c532d0b 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableConfig.java @@ -13,7 +13,7 @@ public class IterableConfig { static final long DEFAULT_EXPIRING_AUTH_TOKEN_REFRESH_PERIOD_SECONDS = 60L; /** - * Ceiling for {@link Builder#setExpiringAuthTokenRefreshPeriod(Long)}, in seconds (~10 years). + * 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. */