diff --git a/CHANGELOG.md b/CHANGELOG.md index 3d40ce8f5..23b7b1b79 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 refresh scheduling that could leave overlapping timers active and repeatedly call `IterableAuthHandler.onAuthTokenRequested()`. Refresh scheduling now has a single task owner, rejects stale or duplicate tasks, and logs each schedule, skip, fire, cancellation, and error with its refresh reason. ## [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..fd7b1871a 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -627,7 +627,7 @@ private void retrieveEmailAndUserId() { getAuthManager().queueExpirationRefresh(_authToken); } else { IterableLogger.d(TAG, "Auth token found as null. Rescheduling auth token refresh"); - getAuthManager().scheduleAuthTokenRefresh(authManager.getNextRetryInterval(), true, null); + getAuthManager().scheduleAuthTokenRefresh(authManager.getNextRetryInterval(), IterableAuthRefreshReason.STORED_TOKEN_MISSING, null); } } } @@ -1997,4 +1997,4 @@ public void trackEmbeddedSession(@NonNull IterableEmbeddedSession session) { //endregion -} \ No newline at end of file +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java index 3a960e54a..9244c1d7e 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthManager.java @@ -31,6 +31,13 @@ enum AuthState { UNKNOWN } + private enum RefreshCancellationReason { + AUTH_RESET, + TOKEN_REPLACED, + EXPLICIT_CLEAR, + APP_BACKGROUNDED + } + /** * Listener interface for components that need to react when a new auth token is ready. */ @@ -44,6 +51,10 @@ interface AuthTokenReadyListener { private final IterableActivityMonitor activityMonitor; @VisibleForTesting Timer timer; + @VisibleForTesting + volatile TimerTask scheduledRefreshTask; + @VisibleForTesting + volatile IterableAuthRefreshReason scheduledRefreshReason; private boolean hasFailedPriorAuth; private boolean pendingAuth; private boolean requiresAuthRefresh; @@ -51,7 +62,6 @@ interface AuthTokenReadyListener { boolean pauseAuthRetry; int retryCount; private boolean isLastAuthTokenValid; - private volatile boolean isTimerScheduled; private volatile boolean isInForeground = true; // Assume foreground initially private volatile AuthState authState = AuthState.UNKNOWN; @@ -130,7 +140,7 @@ public void pauseAuthRetries(boolean pauseRetry) { } void reset() { - clearRefreshTimer(); + clearRefreshTimer(RefreshCancellationReason.AUTH_RESET); setIsLastAuthTokenValid(false); } @@ -222,7 +232,11 @@ private void handleAuthTokenSuccess(String authToken, IterableHelper.SuccessHand } else { handleAuthFailure(authToken, AuthFailureReason.AUTH_TOKEN_NULL); IterableApi.getInstance().setAuthToken(authToken); - scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); + scheduleAuthTokenRefresh( + getNextRetryInterval(), + IterableAuthRefreshReason.AUTH_HANDLER_RETRY, + null + ); return; } reSyncAuth(); @@ -234,32 +248,50 @@ private void handleAuthTokenFailure(Throwable throwable) { IterableLogger.e(TAG, "Error while requesting Auth Token", throwable); handleAuthFailure(null, AuthFailureReason.AUTH_TOKEN_GENERATION_ERROR); pendingAuth = false; - scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); + scheduleAuthTokenRefresh( + getNextRetryInterval(), + IterableAuthRefreshReason.AUTH_HANDLER_RETRY, + null + ); } public void queueExpirationRefresh(@Nullable String encodedJWT) { - clearRefreshTimer(); + clearRefreshTimer(RefreshCancellationReason.TOKEN_REPLACED); try { if (encodedJWT == null) { IterableLogger.d(TAG, "JWT is null. Scheduling token refresh"); - if (!isTimerScheduled) { - scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); - } + scheduleAuthTokenRefresh( + getNextRetryInterval(), + IterableAuthRefreshReason.TOKEN_MISSING, + null + ); return; } long expirationTimeSeconds = decodedExpiration(encodedJWT); long triggerExpirationRefreshTime = expirationTimeSeconds * 1000L - expiringAuthTokenRefreshPeriod - IterableUtil.currentTimeMillis(); if (triggerExpirationRefreshTime > 0) { - scheduleAuthTokenRefresh(triggerExpirationRefreshTime, true, null); + scheduleAuthTokenRefresh( + triggerExpirationRefreshTime, + IterableAuthRefreshReason.TOKEN_EXPIRING, + null + ); } else { - scheduleAuthTokenRefresh(getNextRetryInterval(), true, null); + scheduleAuthTokenRefresh( + getNextRetryInterval(), + IterableAuthRefreshReason.TOKEN_EXPIRED, + null + ); } } catch (Exception e) { IterableLogger.e(TAG, "Error while parsing JWT for the expiration", e); isLastAuthTokenValid = false; handleAuthFailure(encodedJWT, AuthFailureReason.AUTH_TOKEN_PAYLOAD_INVALID); - scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); + scheduleAuthTokenRefresh( + getNextRetryInterval(), + IterableAuthRefreshReason.TOKEN_INVALID, + null + ); } } @@ -270,7 +302,11 @@ void resetFailedAuth() { void reSyncAuth() { if (requiresAuthRefresh) { requiresAuthRefresh = false; - scheduleAuthTokenRefresh(getNextRetryInterval(), false, null); + scheduleAuthTokenRefresh( + getNextRetryInterval(), + IterableAuthRefreshReason.DEFERRED_REFRESH, + null + ); } } @@ -291,30 +327,120 @@ long getNextRetryInterval() { return nextRetryInterval; } - 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 + synchronized void scheduleAuthTokenRefresh( + long timeDuration, + IterableAuthRefreshReason reason, + final IterableHelper.SuccessHandler successCallback + ) { + if (pauseAuthRetry && !reason.ignoresRetryPolicy()) { + IterableLogger.d( + TAG, + "auth_refresh action=skip reason=" + + reason + + " cause=retry_paused" + ); + return; + } + if (scheduledRefreshTask != null) { + IterableLogger.d( + TAG, + "auth_refresh action=skip reason=" + + reason + + " cause=already_scheduled pending_reason=" + + scheduledRefreshReason + ); return; } if (timer == null) { timer = new Timer(true); } - try { - timer.schedule(new TimerTask() { - @Override - public void run() { - if (api.getEmail() != null || api.getUserId() != null) { - api.getAuthManager().requestNewAuthToken(false, successCallback, isScheduledRefresh); - } else { - IterableLogger.w(TAG, "Email or userId is not available. Skipping token refresh"); - } - isTimerScheduled = false; + final TimerTask refreshTask = new TimerTask() { + @Override + public void run() { + if (!isCurrentRefreshTask(this, reason)) { + return; + } + + if (api.getEmail() != null || api.getUserId() != null) { + dispatchRefreshTask(this, reason, successCallback); + } else { + releaseRefreshTask(this); + IterableLogger.w( + TAG, + "auth_refresh action=skip reason=" + + reason + + " cause=identity_missing" + ); } - }, timeDuration); - isTimerScheduled = true; + } + }; + + try { + scheduledRefreshTask = refreshTask; + scheduledRefreshReason = reason; + timer.schedule(refreshTask, timeDuration); + IterableLogger.d( + TAG, + "auth_refresh action=schedule reason=" + + reason + + " delay_ms=" + + timeDuration + ); } catch (Exception e) { - IterableLogger.e(TAG, "timer exception: " + timer, e); + releaseRefreshTask(refreshTask); + if (timer != null) { + timer.cancel(); + timer = null; + } + IterableLogger.e( + TAG, + "auth_refresh action=error reason=" + + reason + + " cause=schedule_failed", + e + ); + } + } + + private synchronized boolean isCurrentRefreshTask( + TimerTask task, + IterableAuthRefreshReason reason + ) { + if (scheduledRefreshTask != task) { + IterableLogger.d( + TAG, + "auth_refresh action=ignore reason=" + + reason + + " cause=stale_task" + ); + return false; + } + return true; + } + + private synchronized void dispatchRefreshTask( + TimerTask task, + IterableAuthRefreshReason reason, + IterableHelper.SuccessHandler successCallback + ) { + if (!isCurrentRefreshTask(task, reason)) { + return; + } + scheduledRefreshTask = null; + scheduledRefreshReason = null; + IterableLogger.d(TAG, "auth_refresh action=fire reason=" + reason); + api.getAuthManager().requestNewAuthToken( + false, + successCallback, + reason.ignoresRetryPolicy() + ); + } + + private synchronized void releaseRefreshTask(TimerTask task) { + if (scheduledRefreshTask == task) { + scheduledRefreshTask = null; + scheduledRefreshReason = null; } } @@ -363,10 +489,28 @@ private void checkAndHandleAuthRefresh() { } void clearRefreshTimer() { + clearRefreshTimer(RefreshCancellationReason.EXPLICIT_CLEAR); + } + + private synchronized void clearRefreshTimer(RefreshCancellationReason reason) { + IterableAuthRefreshReason cancelledReason = scheduledRefreshReason; + if (scheduledRefreshTask != null) { + scheduledRefreshTask.cancel(); + } if (timer != null) { timer.cancel(); timer = null; - isTimerScheduled = false; + } + scheduledRefreshTask = null; + scheduledRefreshReason = null; + if (cancelledReason != null) { + IterableLogger.d( + TAG, + "auth_refresh action=cancel reason=" + + cancelledReason + + " cause=" + + reason + ); } } @@ -386,10 +530,9 @@ public void onSwitchToBackground() { try { IterableLogger.d(TAG, "App switched to background - disabling auth token requests"); isInForeground = false; - clearRefreshTimer(); + clearRefreshTimer(RefreshCancellationReason.APP_BACKGROUNDED); } catch (Exception e) { IterableLogger.e(TAG, "Error while switching to background", e); } } } - diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthRefreshReason.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthRefreshReason.java new file mode 100644 index 000000000..91d20f9fa --- /dev/null +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableAuthRefreshReason.java @@ -0,0 +1,28 @@ +package com.iterable.iterableapi; + +/** + * Explains why the SDK scheduled an auth token refresh. + * + * The reason also defines whether the refresh is part of the normal token lifecycle or a retry + * that must respect the configured retry pause and maximum. + */ +enum IterableAuthRefreshReason { + TOKEN_EXPIRING(true), + TOKEN_EXPIRED(true), + STORED_TOKEN_MISSING(true), + TOKEN_MISSING(false), + TOKEN_INVALID(false), + AUTH_HANDLER_RETRY(false), + DEFERRED_REFRESH(false), + JWT_401(false); + + private final boolean ignoresRetryPolicy; + + IterableAuthRefreshReason(boolean ignoresRetryPolicy) { + this.ignoresRetryPolicy = ignoresRetryPolicy; + } + + boolean ignoresRetryPolicy() { + return ignoresRetryPolicy; + } +} diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt index 1a8235b09..7fff52fbc 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableKeychain.kt @@ -16,7 +16,7 @@ class IterableKeychain { private const val PLAINTEXT_SUFFIX = "_plaintext" private const val CRYPTO_OPERATION_TIMEOUT_MS = 500L private const val KEY_ENCRYPTION_ENABLED = "iterable-encryption-enabled" - + private val cryptoExecutor = Executors.newSingleThreadExecutor() } @@ -116,7 +116,7 @@ class IterableKeychain { } else if (hasPlainText) { return sharedPrefs.getString(key, null) } - + val encryptedValue = sharedPrefs.getString(key, null) ?: return null return try { encryptor?.let { runWithTimeout { it.decrypt(encryptedValue) } } diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java index 33ecddd3f..8c66c885d 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableRequestTask.java @@ -273,7 +273,11 @@ private static void handleJwtAuthRetry(IterableApiRequest iterableApiRequest) { IterableAuthManager authManager = IterableApi.getInstance().getAuthManager(); authManager.setIsLastAuthTokenValid(false); long retryInterval = authManager.getNextRetryInterval(); - authManager.scheduleAuthTokenRefresh(retryInterval, false, null); + authManager.scheduleAuthTokenRefresh( + retryInterval, + IterableAuthRefreshReason.JWT_401, + null + ); } else { requestNewAuthTokenAndRetry(iterableApiRequest); } @@ -427,14 +431,18 @@ private void handleErrorResponse(IterableApiResponse response) { private static void requestNewAuthTokenAndRetry(IterableApiRequest iterableApiRequest) { IterableApi.getInstance().getAuthManager().setIsLastAuthTokenValid(false); long retryInterval = IterableApi.getInstance().getAuthManager().getNextRetryInterval(); - IterableApi.getInstance().getAuthManager().scheduleAuthTokenRefresh(retryInterval, false, data -> { - try { - String newAuthToken = data.getString("newAuthToken"); - retryRequestWithNewAuthToken(newAuthToken, iterableApiRequest); - } catch (JSONException e) { - e.printStackTrace(); - } - }); + IterableApi.getInstance().getAuthManager().scheduleAuthTokenRefresh( + retryInterval, + IterableAuthRefreshReason.JWT_401, + data -> { + try { + String newAuthToken = data.getString("newAuthToken"); + retryRequestWithNewAuthToken(newAuthToken, iterableApiRequest); + } catch (JSONException e) { + e.printStackTrace(); + } + } + ); } protected void setRetryCount(int count) { diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableAuthRefreshOwnershipTest.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableAuthRefreshOwnershipTest.java new file mode 100644 index 000000000..b795e4f29 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableAuthRefreshOwnershipTest.java @@ -0,0 +1,339 @@ +package com.iterable.iterableapi; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Timer; +import java.util.TimerTask; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CyclicBarrier; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertSame; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +public class IterableAuthRefreshOwnershipTest extends BaseTest { + private IterableApi api; + private RecordingAuthManager authManager; + + @Before + public void setUp() { + api = mock(IterableApi.class); + when(api.getEmail()).thenReturn("user@example.com"); + authManager = new RecordingAuthManager(api); + when(api.getAuthManager()).thenReturn(authManager); + } + + @After + public void tearDown() { + authManager.clearRefreshTimer(); + } + + @Test + public void concurrentSchedulingCreatesOneRefreshTask() throws Exception { + RetainingTimer timer = new RetainingTimer(); + authManager.timer = timer; + int threadCount = 8; + CyclicBarrier barrier = new CyclicBarrier(threadCount); + ExecutorService callers = Executors.newFixedThreadPool(threadCount); + List> futures = new ArrayList<>(); + + try { + for (int i = 0; i < threadCount; i++) { + futures.add( + callers.submit( + () -> { + barrier.await(); + authManager.scheduleAuthTokenRefresh( + 60_000, + IterableAuthRefreshReason.TOKEN_EXPIRING, + null + ); + return null; + } + ) + ); + } + for (Future future : futures) { + future.get(); + } + } finally { + callers.shutdownNow(); + } + + assertEquals(1, timer.taskCount()); + assertSame(timer.lastTask(), authManager.scheduledRefreshTask); + assertEquals( + IterableAuthRefreshReason.TOKEN_EXPIRING, + authManager.scheduledRefreshReason + ); + } + + @Test + public void staleTaskCannotRunOrClearItsReplacement() { + RetainingTimer firstTimer = new RetainingTimer(); + authManager.timer = firstTimer; + authManager.scheduleAuthTokenRefresh( + 1000, + IterableAuthRefreshReason.AUTH_HANDLER_RETRY, + null + ); + TimerTask staleTask = firstTimer.lastTask(); + + authManager.clearRefreshTimer(); + RetainingTimer replacementTimer = new RetainingTimer(); + authManager.timer = replacementTimer; + authManager.scheduleAuthTokenRefresh( + 2000, + IterableAuthRefreshReason.TOKEN_EXPIRING, + null + ); + TimerTask replacementTask = replacementTimer.lastTask(); + + staleTask.run(); + + assertSame(replacementTask, authManager.scheduledRefreshTask); + assertEquals(0, authManager.requestCount.get()); + + replacementTask.run(); + + assertNull(authManager.scheduledRefreshTask); + assertNull(authManager.scheduledRefreshReason); + assertEquals(1, authManager.requestCount.get()); + } + + @Test + public void replacingTokenWhileRefreshChecksIdentityCancelsOldRefresh() throws Exception { + CountDownLatch identityCheckStarted = new CountDownLatch(1); + CountDownLatch allowOldTokenRefreshToContinue = new CountDownLatch(1); + pauseFirstIdentityCheckUntil( + identityCheckStarted, + allowOldTokenRefreshToContinue + ); + + // The old token's expiration refresh starts checking for an identified user. + TimerTask oldTokenRefresh = scheduleRefresh( + 1000, + IterableAuthRefreshReason.TOKEN_EXPIRING + ); + ExecutorService taskRunner = Executors.newSingleThreadExecutor(); + Future oldTokenRefreshRun = taskRunner.submit(oldTokenRefresh::run); + try { + assertTrue(identityCheckStarted.await(5, TimeUnit.SECONDS)); + + // The app receives a new token and schedules its expiration refresh. + TimerTask newTokenRefresh = replaceRefreshForNewToken(2000); + + // The old token's refresh resumes after the new token has replaced it. + allowOldTokenRefreshToContinue.countDown(); + oldTokenRefreshRun.get(5, TimeUnit.SECONDS); + + // The old refresh must not request another token or disturb the new refresh. + assertEquals(0, authManager.requestCount.get()); + assertSame(newTokenRefresh, authManager.scheduledRefreshTask); + + // The new token's refresh can still dispatch normally. + newTokenRefresh.run(); + + assertEquals(1, authManager.requestCount.get()); + assertNull(authManager.scheduledRefreshTask); + assertNull(authManager.scheduledRefreshReason); + } finally { + allowOldTokenRefreshToContinue.countDown(); + taskRunner.shutdownNow(); + } + } + + @Test + public void duplicateScheduleKeepsOriginalCallbackAndPolicy() { + RetainingTimer timer = new RetainingTimer(); + authManager.timer = timer; + IterableHelper.SuccessHandler firstCallback = + mock(IterableHelper.SuccessHandler.class); + IterableHelper.SuccessHandler secondCallback = + mock(IterableHelper.SuccessHandler.class); + + authManager.scheduleAuthTokenRefresh( + 1000, + IterableAuthRefreshReason.JWT_401, + firstCallback + ); + authManager.scheduleAuthTokenRefresh( + 2000, + IterableAuthRefreshReason.TOKEN_EXPIRING, + secondCallback + ); + + timer.lastTask().run(); + + assertEquals(1, timer.taskCount()); + assertSame(firstCallback, authManager.lastSuccessCallback); + assertFalse(authManager.lastIgnoreRetryPolicy); + } + + @Test + public void schedulingFailureReleasesOwnership() { + authManager.timer = new FailingTimer(); + + authManager.scheduleAuthTokenRefresh( + 1000, + IterableAuthRefreshReason.JWT_401, + null + ); + + assertNull(authManager.timer); + assertNull(authManager.scheduledRefreshTask); + assertNull(authManager.scheduledRefreshReason); + + RetainingTimer replacementTimer = new RetainingTimer(); + authManager.timer = replacementTimer; + authManager.scheduleAuthTokenRefresh( + 2000, + IterableAuthRefreshReason.JWT_401, + null + ); + + assertEquals(1, replacementTimer.taskCount()); + } + + @Test + public void scheduledLifecycleRefreshStillIgnoresPausedRetries() { + RetainingTimer timer = new RetainingTimer(); + authManager.timer = timer; + authManager.pauseAuthRetries(true); + + authManager.scheduleAuthTokenRefresh( + 1000, + IterableAuthRefreshReason.JWT_401, + null + ); + assertEquals(0, timer.taskCount()); + + authManager.scheduleAuthTokenRefresh( + 1000, + IterableAuthRefreshReason.TOKEN_EXPIRING, + null + ); + + assertEquals(1, timer.taskCount()); + assertTrue( + IterableAuthRefreshReason.TOKEN_EXPIRING.ignoresRetryPolicy() + ); + } + + private void pauseFirstIdentityCheckUntil( + CountDownLatch identityCheckStarted, + CountDownLatch resumeIdentityCheck + ) { + AtomicInteger identityCheckCount = new AtomicInteger(); + when(api.getEmail()).thenAnswer(invocation -> { + if (identityCheckCount.getAndIncrement() == 0) { + identityCheckStarted.countDown(); + assertTrue(resumeIdentityCheck.await(5, TimeUnit.SECONDS)); + } + return "user@example.com"; + }); + } + + private TimerTask scheduleRefresh( + long delay, + IterableAuthRefreshReason reason + ) { + RetainingTimer timer = new RetainingTimer(); + authManager.timer = timer; + authManager.scheduleAuthTokenRefresh(delay, reason, null); + return timer.lastTask(); + } + + private TimerTask replaceRefreshForNewToken(long delay) { + // Mirrors queueExpirationRefresh() after a new token is stored. + authManager.clearRefreshTimer(); + return scheduleRefresh( + delay, + IterableAuthRefreshReason.TOKEN_EXPIRING + ); + } + + private static class RecordingAuthManager extends IterableAuthManager { + private final AtomicInteger requestCount = new AtomicInteger(); + private IterableHelper.SuccessHandler lastSuccessCallback; + private boolean lastIgnoreRetryPolicy; + + RecordingAuthManager(IterableApi api) { + super( + api, + mock(IterableAuthHandler.class), + new RetryPolicy(3, 1, RetryPolicy.Type.LINEAR), + 60_000 + ); + } + + @Override + public synchronized void requestNewAuthToken( + boolean hasFailedPriorAuth, + IterableHelper.SuccessHandler successCallback, + boolean shouldIgnoreRetryPolicy + ) { + requestCount.incrementAndGet(); + lastSuccessCallback = successCallback; + lastIgnoreRetryPolicy = shouldIgnoreRetryPolicy; + } + } + + private static class RetainingTimer extends Timer { + private final List tasks = + Collections.synchronizedList(new ArrayList<>()); + + RetainingTimer() { + super(true); + super.cancel(); + } + + @Override + public void schedule(TimerTask task, long delay) { + tasks.add(task); + try { + Thread.sleep(25); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + @Override + public void cancel() { + // Keep tasks available so stale-task behavior can be tested deterministically. + } + + TimerTask lastTask() { + return tasks.get(tasks.size() - 1); + } + + int taskCount() { + return tasks.size(); + } + } + + private static class FailingTimer extends Timer { + FailingTimer() { + super(true); + } + + @Override + public void schedule(TimerTask task, long delay) { + throw new IllegalStateException("timer rejected task"); + } + } +} diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt index 53fc5944b..ae35197cc 100644 --- a/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableKeychainTest.kt @@ -422,4 +422,4 @@ class IterableKeychainTest { // - Existing encrypted data that can't be decrypted returns null (graceful degradation) // - No crashes occur during the failure scenario } -} \ No newline at end of file +}