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 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) { @@ -120,6 +129,13 @@ class IterableKeychain { val encryptedValue = sharedPrefs.getString(key, null) ?: return null return try { encryptor?.let { runWithTimeout { it.decrypt(encryptedValue) } } + } catch (e: TimeoutException) { + // A crypto operation that times out is transient (slow/contended AndroidKeyStore), not a + // corrupt key. Don't wipe stored credentials or disable encryption over it — that would + // force a re-login (and a new auth-token request) on every slow launch. Return null for + // this read; the encrypted value stays intact for the next attempt. (SDK-547) + IterableLogger.w(TAG, "Crypto operation timed out; keeping encrypted data for retry.") + null } catch (e: Exception) { handleDecryptionError(e) null @@ -145,6 +161,14 @@ class IterableKeychain { .remove(key + PLAINTEXT_SUFFIX) .apply() } + } catch (e: TimeoutException) { + // Transient slow crypto: store this value as plaintext so it isn't lost, but don't wipe + // other credentials or disable encryption globally. Encryption stays on for future + // writes. (SDK-547) + IterableLogger.w(TAG, "Crypto operation timed out on save; storing this value as plaintext.") + editor.putString(key, value) + .putBoolean(key + PLAINTEXT_SUFFIX, true) + .apply() } catch (e: Exception) { handleDecryptionError(e) editor.putString(key, value) 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/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()); + } + } diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt index b017c66a6..646105f1f 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableConfigTest.kt @@ -51,4 +51,96 @@ 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 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() + .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) + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index 53fc5944b..3d0ccc913 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -178,6 +178,57 @@ 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 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