diff --git a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientDataSystemPlatformTest.java b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientDataSystemPlatformTest.java index c277ad18..e33bc469 100644 --- a/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientDataSystemPlatformTest.java +++ b/launchdarkly-android-client-sdk/src/androidTest/java/com/launchdarkly/sdk/android/LDClientDataSystemPlatformTest.java @@ -52,6 +52,7 @@ public class LDClientDataSystemPlatformTest { private static final String MOBILE_KEY = "test-mobile-key"; private static final LDContext CONTEXT = LDContext.create("context"); + private static final long TEST_DEBOUNCE_MS = 50; /** Matches {@link AndroidPlatformState} debounce (500 ms) plus a small buffer. */ private static final int AFTER_PAUSE_WAIT_MS = 600; @@ -88,6 +89,7 @@ private LDConfig.Builder baseConfig() { return new LDConfig.Builder(AutoEnvAttributes.Disabled) .mobileKey(MOBILE_KEY) .persistentDataStore(store) + .debounceMs(TEST_DEBOUNCE_MS) .diagnosticOptOut(true) .events(Components.noEvents()) .logAdapter(logging.logAdapter) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index ece78a1a..c9c37111 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java @@ -66,7 +66,7 @@ class ConnectivityManager { private final TaskExecutor taskExecutor; private final boolean backgroundUpdatingDisabled; private final List> statusListeners = new ArrayList<>(); - private final Debounce pollDebouncer = new Debounce(); + private final Debounce pollDebouncer = new Debounce(); // FDv1 only private final AtomicBoolean forcedOffline = new AtomicBoolean(); private final AtomicBoolean started = new AtomicBoolean(); private final AtomicBoolean closed = new AtomicBoolean(); @@ -79,6 +79,8 @@ class ConnectivityManager { private final ModeResolutionTable modeResolutionTable; private volatile ConnectionMode currentFDv2Mode; private final AutomaticModeSwitchingConfig autoModeSwitchingConfig; + private final long debounceMs; // visible for testing + private volatile StateDebounceManager stateDebounceManager; // FDv2 only // The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource. // This has two purposes: 1. to decouple the data source implementation from the details of how @@ -161,6 +163,7 @@ public void shutDown() { forcedOffline.set(clientContext.isSetOffline()); LDConfig ldConfig = clientContext.getConfig(); + this.debounceMs = ldConfig.getDebounceMs(); connectionInformation = new ConnectionInformationState(); readStoredConnectionState(); this.backgroundUpdatingDisabled = ldConfig.isDisableBackgroundPolling(); @@ -170,21 +173,49 @@ public void shutDown() { ? ((FDv2DataSourceBuilder) dataSourceFactory).getResolutionTable() : null; + if (useFDv2ModeResolution) { + this.stateDebounceManager = createDebounceManager(); + } + connectivityChangeListener = networkAvailable -> { - if (useFDv2ModeResolution && !autoModeSwitchingConfig.isNetwork()) { + if (useFDv2ModeResolution) { + // Event processor state updated immediately so analytics events reflect reality. updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); - return; + if (!autoModeSwitchingConfig.isNetwork()) { + return; + } + // CONNMODE 3.5.1: route through debounce window instead of immediate rebuild + StateDebounceManager dm = stateDebounceManager; + if (dm != null) { + dm.setNetworkAvailable(networkAvailable); + } + } else { + // FDv1 path: handleModeStateChange updates event processor internally + handleModeStateChange(); } - handleModeStateChange(); }; platformState.addConnectivityChangeListener(connectivityChangeListener); foregroundListener = foreground -> { - if (useFDv2ModeResolution && !autoModeSwitchingConfig.isLifecycle()) { + if (useFDv2ModeResolution) { + // Event processor state updated immediately so analytics events reflect reality. updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); - return; + if (!autoModeSwitchingConfig.isLifecycle()) { + return; + } + // CONNMODE 3.3.1: flush pending events before transitioning to background + if (!foreground) { + eventProcessor.flush(); + } + // CONNMODE 3.5.1: route through debounce window + StateDebounceManager dm = stateDebounceManager; + if (dm != null) { + dm.setForeground(foreground); + } + } else { + // FDv1 path: handleModeStateChange updates event processor internally + handleModeStateChange(); } - handleModeStateChange(); }; platformState.addForegroundChangeListener(foregroundListener); } @@ -193,6 +224,10 @@ public void shutDown() { * Switches the {@link ConnectivityManager} to begin fetching/receiving information * relevant to the context provided. This is likely to result in the teardown of existing * connections, but the timing of that is not guaranteed. + *

+ * CONNMODE 3.5.6: identify does NOT participate in debounce. The debounce manager is + * destroyed and recreated so that any pending debounced state change is discarded and + * the new context starts with a clean timer. * * @param context to swtich to * @param onCompletion callback that indicates when the switching is done @@ -204,6 +239,15 @@ void switchToContext(@NonNull LDContext context, @NonNull Callback onCompl if (oldContext == context || oldContext.equals(context)) { onCompletion.onSuccess(null); } else { + // CONNMODE 3.5.6: identify bypasses debounce — close and recreate the manager + if (useFDv2ModeResolution) { + StateDebounceManager oldDm = stateDebounceManager; + if (oldDm != null) { + oldDm.close(); + } + stateDebounceManager = createDebounceManager(); + } + ModeState state = snapshotModeState(); if (dataSource == null || dataSource.needsRefresh(!state.isForeground(), context)) { updateEventProcessor(forcedOffline.get(), state.isNetworkAvailable(), state.isForeground()); @@ -536,6 +580,11 @@ void shutDown() { if (closed.getAndSet(true)) { return; } + StateDebounceManager dm = stateDebounceManager; + if (dm != null) { + dm.close(); + stateDebounceManager = null; + } DataSource oldDataSource = currentDataSource.getAndSet(null); if (oldDataSource != null) { oldDataSource.stop(LDUtil.noOpCallback()); @@ -550,6 +599,10 @@ void shutDown() { platformState.removeConnectivityChangeListener(connectivityChangeListener); } + // Intentionally bypasses the FDv2 debounce manager. setForceOffline is a legacy + // API that predates FDv2 and must remain immediate for backward compatibility. + // This is safe because resolveMode() short-circuits to OFFLINE when forcedOffline + // is set, so any in-flight debounced callback will resolve to the same mode and no-op. void setForceOffline(boolean forceOffline) { boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline); if (forceOffline != wasForcedOffline) { @@ -570,6 +623,9 @@ private void updateEventProcessor(boolean forceOffline, boolean networkAvailable * Unified handler for all platform/configuration state changes (foreground, connectivity, * force-offline). Snapshots the current state once, updates the event processor, then * routes to the appropriate data source update path. + *

+ * FDv1 only — FDv2 state changes are routed through {@link StateDebounceManager} and + * reconciled via {@link #handleDebouncedModeStateChange()}. */ private synchronized void handleModeStateChange() { ModeState state = snapshotModeState(); @@ -577,6 +633,41 @@ private synchronized void handleModeStateChange() { updateDataSource(false, state, LDUtil.noOpCallback()); } + /** + * Creates a new {@link StateDebounceManager} initialized with the current platform state. + * Called once during construction (for FDv2) and again on each identify to discard pending + * debounced changes (CONNMODE 3.5.6). + */ + private StateDebounceManager createDebounceManager() { + return new StateDebounceManager( + platformState.isNetworkAvailable(), + platformState.isForeground(), + taskExecutor, + debounceMs, + this::handleDebouncedModeStateChange + ); + } + + /** + * Reconciliation callback invoked by the {@link StateDebounceManager} when the debounce + * timer fires (CONNMODE 3.5.3). Reads the latest accumulated state from the debounce + * manager and triggers a data source update if the resolved mode has changed. + */ + private void handleDebouncedModeStateChange() { + StateDebounceManager dm = stateDebounceManager; + if (dm == null) { + return; + } + ModeState state = new ModeState( + dm.isForeground(), + dm.isNetworkAvailable(), + backgroundUpdatingDisabled + ); + // updateEventProcessor() is intentionally not called here — it was already + // called immediately in the listener callbacks, before the debounce started. + updateDataSource(false, state, LDUtil.noOpCallback()); + } + private ModeState snapshotModeState() { return new ModeState( platformState.isForeground(), diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index 12d4a875..015e5da8 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -87,6 +87,7 @@ public class LDConfig { private final String loggerName; private final int maxCachedContexts; private final boolean offline; + private final long debounceMs; private final PersistentDataStore persistentDataStore; // configurable for testing only LDConfig(Map mobileKeys, @@ -105,6 +106,7 @@ public class LDConfig { int maxCachedContexts, boolean generateAnonymousKeys, boolean autoEnvAttributes, + long debounceMs, PersistentDataStore persistentDataStore, LDLogAdapter logAdapter, String loggerName) { @@ -124,6 +126,7 @@ public class LDConfig { this.maxCachedContexts = maxCachedContexts; this.generateAnonymousKeys = generateAnonymousKeys; this.autoEnvAttributes = autoEnvAttributes; + this.debounceMs = debounceMs; this.persistentDataStore = persistentDataStore; this.logAdapter = logAdapter; this.loggerName = loggerName; @@ -166,6 +169,11 @@ AutomaticModeSwitchingConfig getAutomaticModeSwitchingConfig() { return automaticModeSwitchingConfig; } + // visible for testing — allows instrumented tests to use a shorter debounce window + long getDebounceMs() { + return debounceMs; + } + /** * @return true if evaluation reasons are turned on, false otherwise */ @@ -268,6 +276,8 @@ public enum AutoEnvAttributes { private PersistentDataStore persistentDataStore; + private long debounceMs = StateDebounceManager.DEFAULT_DEBOUNCE_MS; + private LDLogAdapter logAdapter = defaultLogAdapter(); private String loggerName = LDPackageConsts.DEFAULT_LOGGER_NAME; private LDLogLevel logLevel = null; @@ -667,6 +677,12 @@ Builder persistentDataStore(PersistentDataStore persistentDataStore) { return this; } + // visible for testing — allows instrumented tests to use a shorter debounce window + Builder debounceMs(long debounceMs) { + this.debounceMs = debounceMs; + return this; + } + /** * Specifies the implementation of logging to use. *

@@ -827,6 +843,7 @@ public LDConfig build() { maxCachedContexts, generateAnonymousKeys, autoEnvAttributes, + debounceMs, persistentDataStore, actualLogAdapter, loggerName); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StateDebounceManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StateDebounceManager.java new file mode 100644 index 00000000..31c4310a --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StateDebounceManager.java @@ -0,0 +1,94 @@ +package com.launchdarkly.sdk.android; + +import java.util.concurrent.ScheduledFuture; + +/** + * Debounces FDv2 state changes (network, lifecycle) into a single + * reconciliation callback (CONNMODE 3.5). Each state change resets a timer; when + * the timer fires, the callback runs with the latest accumulated state. + *

+ * {@code identify()} does NOT participate in debounce (CONNMODE 3.5.6). Callers + * handle this by closing and recreating the manager on identify. + *

+ * FDv1 data sources do not use this class. The existing {@link Debounce} class + * (used by {@code pollDebouncer}) serves a different purpose in the FDv1 path. + */ +final class StateDebounceManager { + + static final long DEFAULT_DEBOUNCE_MS = 1000; + + private final Object lock = new Object(); + private final TaskExecutor taskExecutor; + private final long debounceMs; + private final Runnable onReconcile; + + private volatile boolean networkAvailable; + private volatile boolean foreground; + + private ScheduledFuture pendingTimer; + private volatile boolean closed; + + StateDebounceManager( + boolean initialNetworkAvailable, + boolean initialForeground, + TaskExecutor taskExecutor, + long debounceMs, + Runnable onReconcile + ) { + this.networkAvailable = initialNetworkAvailable; + this.foreground = initialForeground; + this.taskExecutor = taskExecutor; + this.debounceMs = debounceMs; + this.onReconcile = onReconcile; + } + + void setNetworkAvailable(boolean available) { + if (this.networkAvailable == available) { + return; + } + this.networkAvailable = available; + resetTimer(); + } + + void setForeground(boolean fg) { + if (this.foreground == fg) { + return; + } + this.foreground = fg; + resetTimer(); + } + + boolean isNetworkAvailable() { + return networkAvailable; + } + + boolean isForeground() { + return foreground; + } + + void close() { + closed = true; + synchronized (lock) { + if (pendingTimer != null) { + pendingTimer.cancel(false); + pendingTimer = null; + } + } + } + + private void resetTimer() { + if (closed) { + return; + } + synchronized (lock) { + if (pendingTimer != null) { + pendingTimer.cancel(false); + } + pendingTimer = taskExecutor.scheduleTask(() -> { + if (!closed) { + onReconcile.run(); + } + }, debounceMs); + } + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java index 7843785f..850f5c8e 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ConnectivityManagerTest.java @@ -2,6 +2,7 @@ import static com.launchdarkly.sdk.android.TestUtil.requireNoMoreValues; import static com.launchdarkly.sdk.android.TestUtil.requireValue; +import static org.easymock.EasyMock.anyBoolean; import static org.easymock.EasyMock.anyObject; import static org.easymock.EasyMock.expectLastCall; import static org.junit.Assert.assertEquals; @@ -62,6 +63,7 @@ public class ConnectivityManagerTest extends EasyMockSupport { // Instead, we use a mock component and verify that ConnectivityManager is passing the right // parameters to it. + private static final long FDV2_TEST_DEBOUNCE_MS = 50; private static final LDContext CONTEXT = LDContext.create("test-context"); private static final String MOBILE_KEY = "test-mobile-key"; private static final EnvironmentData DATA = new DataSetBuilder() @@ -109,6 +111,7 @@ private static LDConfig defaultTestConfig(boolean setOffline, boolean background .mobileKey(MOBILE_KEY) .offline(setOffline) .disableBackgroundUpdating(backgroundDisabled) + .debounceMs(FDV2_TEST_DEBOUNCE_MS) .build(); } @@ -843,6 +846,16 @@ private void verifyNoMoreDataSourcesWereCreated() { "call to create another data source"); } + /** + * Like {@link #verifyNoMoreDataSourcesWereCreated()}, but waits longer than the FDv2 + * debounce window so we can confirm that even after the debounce timer fires, no data + * source was created. + */ + private void verifyNoMoreDataSourcesWereCreatedAfterDebounce() { + requireNoMoreValues(receivedClientContexts, FDV2_TEST_DEBOUNCE_MS * 4, TimeUnit.MILLISECONDS, + "call to create another data source"); + } + private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } @@ -887,6 +900,7 @@ public void fdv2_foregroundToBackground_rebuildsDataSource() throws Exception { eventProcessor.setInBackground(false); eventProcessor.setOffline(false); eventProcessor.setInBackground(true); + eventProcessor.flush(); // CONNMODE 3.3.1: flush before background transition replayAll(); createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); @@ -907,6 +921,7 @@ public void fdv2_backgroundToForeground_rebuildsDataSource() throws Exception { eventProcessor.setInBackground(false); eventProcessor.setOffline(false); eventProcessor.setInBackground(true); + eventProcessor.flush(); // CONNMODE 3.3.1: flush before background transition eventProcessor.setOffline(false); eventProcessor.setInBackground(false); replayAll(); @@ -942,10 +957,10 @@ public void fdv2_networkLost_rebuildsToOffline() throws Exception { mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + // Data source rebuild is debounced (CONNMODE 3.5.1) verifyDataSourceWasStopped(); - // OFFLINE mode should still build a new data source (with no synchronizers) - requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); - requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); + requireValue(receivedClientContexts, 2, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 2, TimeUnit.SECONDS, "offline data source started"); verifyAll(); } @@ -1095,6 +1110,7 @@ public DataSource build(ClientContext clientContext) { eventProcessor.setInBackground(false); eventProcessor.setOffline(false); eventProcessor.setInBackground(true); + eventProcessor.flush(); // CONNMODE 3.3.1: flush before background transition replayAll(); createTestManager(defaultTestConfig(false, false), builder); @@ -1104,7 +1120,8 @@ public DataSource build(ClientContext clientContext) { // STREAMING and BACKGROUND share the same ModeDefinition object, so 5.3.8 says no rebuild mockPlatformState.setAndNotifyForegroundChangeListeners(false); - verifyNoMoreDataSourcesWereCreated(); + // Wait longer than debounce window to confirm no rebuild occurs + verifyNoMoreDataSourcesWereCreatedAfterDebounce(); verifyNoMoreDataSourcesWereStopped(); verifyAll(); } @@ -1163,6 +1180,7 @@ public DataSource build(ClientContext clientContext) { eventProcessor.setInBackground(false); eventProcessor.setOffline(false); eventProcessor.setInBackground(true); + eventProcessor.flush(); // CONNMODE 3.3.1: flush before background transition replayAll(); createTestManager(defaultTestConfig(false, false), builder); @@ -1173,9 +1191,9 @@ public DataSource build(ClientContext clientContext) { mockPlatformState.setAndNotifyForegroundChangeListeners(false); verifyDataSourceWasStopped(); - assertEquals(Boolean.FALSE, initializerIncluded.poll(1, TimeUnit.SECONDS)); - requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); - requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); + assertEquals(Boolean.FALSE, initializerIncluded.poll(2, TimeUnit.SECONDS)); + requireValue(receivedClientContexts, 2, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 2, TimeUnit.SECONDS, "bg data source started"); verifyAll(); } @@ -1183,6 +1201,7 @@ public DataSource build(ClientContext clientContext) { public void fdv2_lifecycleSwitchingDisabled_doesNotRebuildOnForegroundChange() throws Exception { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) .mobileKey(MOBILE_KEY) + .debounceMs(FDV2_TEST_DEBOUNCE_MS) .dataSystem( Components.dataSystem() .automaticModeSwitching( @@ -1213,6 +1232,7 @@ public void fdv2_lifecycleSwitchingDisabled_doesNotRebuildOnForegroundChange() t public void fdv2_networkSwitchingDisabled_doesNotRebuildOnConnectivityChange() throws Exception { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) .mobileKey(MOBILE_KEY) + .debounceMs(FDV2_TEST_DEBOUNCE_MS) .dataSystem( Components.dataSystem() .automaticModeSwitching( @@ -1243,6 +1263,7 @@ public void fdv2_networkSwitchingDisabled_doesNotRebuildOnConnectivityChange() t public void fdv2_fullyDisabled_lifecycleChangeDoesNotRebuildDataSource() throws Exception { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) .mobileKey(MOBILE_KEY) + .debounceMs(FDV2_TEST_DEBOUNCE_MS) .dataSystem( Components.dataSystem() .automaticModeSwitching(AutomaticModeSwitchingConfig.disabled())) @@ -1269,6 +1290,7 @@ public void fdv2_fullyDisabled_lifecycleChangeDoesNotRebuildDataSource() throws public void fdv2_fullyDisabled_connectivityChangeDoesNotRebuildDataSource() throws Exception { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled) .mobileKey(MOBILE_KEY) + .debounceMs(FDV2_TEST_DEBOUNCE_MS) .dataSystem( Components.dataSystem() .automaticModeSwitching(AutomaticModeSwitchingConfig.disabled())) @@ -1291,6 +1313,136 @@ public void fdv2_fullyDisabled_connectivityChangeDoesNotRebuildDataSource() thro verifyAll(); } + // ==== FDv2 debouncing tests ==== + // + // These tests verify that CONNMODE 3.5.x debouncing behavior is correctly wired + // into ConnectivityManager for FDv2 data sources. + + @Test + public void fdv2_rapidStateChangesCoalesceIntoOneRebuild() throws Exception { + // CONNMODE 3.5.1-3.5.3: rapid state changes should coalesce into a single rebuild + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + verifyAll(); + + // After startup, the rapid connectivity changes call updateEventProcessor in + // parallel threads, so ordering is nondeterministic. Use anyTimes(). + resetAll(); + eventProcessor.setOffline(anyBoolean()); + expectLastCall().anyTimes(); + eventProcessor.setInBackground(anyBoolean()); + expectLastCall().anyTimes(); + replayAll(); + + // Fire multiple rapid connectivity changes — debounce should coalesce them + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + + // Should result in exactly one data source rebuild (to OFFLINE) + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 2, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 2, TimeUnit.SECONDS, "offline data source started"); + verifyNoMoreDataSourcesWereCreatedAfterDebounce(); + } + + @Test + public void fdv2_identifyBypassesDebounce() throws Exception { + // CONNMODE 3.5.6: identify does not participate in debounce + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + // identify should rebuild immediately, not waiting for debounce + LDContext context2 = LDContext.create("context2"); + contextDataManager.switchToContext(context2); + AwaitableCallback done = new AwaitableCallback<>(); + connectivityManager.switchToContext(context2, done); + done.await(); + + verifyDataSourceWasStopped(); + verifyForegroundDataSourceWasCreatedAndStarted(context2); + verifyNoMoreDataSourcesWereCreated(); + verifyAll(); + } + + @Test + public void fdv2_eventsFlushedOnBackgroundTransition() throws Exception { + // CONNMODE 3.3.1: flush pending events before background transition + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + eventProcessor.flush(); // CONNMODE 3.3.1: flush happens before debounce timer is set + replayAll(); + + createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + // The flush should happen immediately (before the debounce fires), + // and the strict mock verifyAll() confirms the expected call sequence + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 2, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 2, TimeUnit.SECONDS, "bg data source started"); + verifyAll(); + } + + @Test + public void fdv2_forceOfflineBypassesDebounce() throws Exception { + // setForceOffline remains immediate per design — not debounced + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + connectivityManager.setForceOffline(true); + + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); + verifyAll(); + } + + @Test + public void fdv2_shutdownClosesDebounceManager() throws Exception { + // After shutDown(), debounced state changes should not trigger any rebuilds + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(defaultTestConfig(false, false), makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + // Set up a pending debounce then shut down + mockPlatformState.setNetworkAvailable(false); + connectivityManager.shutDown(); + + verifyDataSourceWasStopped(); + // No additional data sources should be created despite the pending state change + verifyNoMoreDataSourcesWereCreatedAfterDebounce(); + verifyAll(); + } + private static boolean readIncludeInitializersFlag(FDv2DataSourceBuilder builder) { try { java.lang.reflect.Field f = FDv2DataSourceBuilder.class.getDeclaredField("includeInitializers"); diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StateDebounceManagerTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StateDebounceManagerTest.java new file mode 100644 index 00000000..041afc10 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/StateDebounceManagerTest.java @@ -0,0 +1,189 @@ +package com.launchdarkly.sdk.android; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import java.util.concurrent.CountDownLatch; +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.assertTrue; + +public class StateDebounceManagerTest { + + private static final long TEST_DEBOUNCE_MS = 50; + + private final SimpleTestTaskExecutor taskExecutor = new SimpleTestTaskExecutor(); + + @Before + public void before() { + } + + @After + public void after() { + taskExecutor.close(); + } + + private StateDebounceManager createManager( + boolean networkAvailable, + boolean foreground, + Runnable onReconcile + ) { + return new StateDebounceManager( + networkAvailable, foreground, + taskExecutor, TEST_DEBOUNCE_MS, onReconcile + ); + } + + @Test + public void callbackFiredAfterDebounceWindow() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + StateDebounceManager mgr = createManager(true, true, latch::countDown); + + mgr.setNetworkAvailable(false); + assertTrue("callback should fire within debounce window", + latch.await(TEST_DEBOUNCE_MS * 5, TimeUnit.MILLISECONDS)); + + mgr.close(); + } + + @Test + public void callbackNotFiredBeforeDebounceWindow() throws InterruptedException { + AtomicInteger callCount = new AtomicInteger(0); + StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + + mgr.setNetworkAvailable(false); + Thread.sleep(TEST_DEBOUNCE_MS / 3); + assertEquals("callback should not fire before debounce window", 0, callCount.get()); + + Thread.sleep(TEST_DEBOUNCE_MS * 3); + assertEquals(1, callCount.get()); + + mgr.close(); + } + + @Test + public void duplicateValueIsNoOp() throws InterruptedException { + AtomicInteger callCount = new AtomicInteger(0); + StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + + mgr.setNetworkAvailable(true); + mgr.setForeground(true); + Thread.sleep(TEST_DEBOUNCE_MS * 3); + + assertEquals("no-op changes should not trigger callback", 0, callCount.get()); + + mgr.close(); + } + + @Test + public void timerResetsOnEachEvent() throws InterruptedException { + AtomicInteger callCount = new AtomicInteger(0); + StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + + mgr.setNetworkAvailable(false); + Thread.sleep(TEST_DEBOUNCE_MS / 3); + assertEquals(0, callCount.get()); + + mgr.setForeground(false); + Thread.sleep(TEST_DEBOUNCE_MS / 3); + assertEquals("timer should reset, callback should not fire yet", 0, callCount.get()); + + Thread.sleep(TEST_DEBOUNCE_MS * 3); + assertEquals("callback should fire exactly once after final timer", 1, callCount.get()); + + mgr.close(); + } + + @Test + public void callbackReceivesLatestState() throws InterruptedException { + CountDownLatch latch = new CountDownLatch(1); + + StateDebounceManager mgr = createManager(true, true, latch::countDown); + + mgr.setNetworkAvailable(false); + mgr.setForeground(false); + + assertTrue(latch.await(TEST_DEBOUNCE_MS * 5, TimeUnit.MILLISECONDS)); + assertFalse("should reflect latest network state", mgr.isNetworkAvailable()); + assertFalse("should reflect latest foreground state", mgr.isForeground()); + + mgr.close(); + } + + @Test + public void multipleRapidChangesCoalesceIntoOneCallback() throws InterruptedException { + AtomicInteger callCount = new AtomicInteger(0); + StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + + mgr.setNetworkAvailable(false); + mgr.setNetworkAvailable(true); + mgr.setNetworkAvailable(false); + mgr.setForeground(false); + mgr.setForeground(true); + + Thread.sleep(TEST_DEBOUNCE_MS * 4); + assertEquals("rapid changes should coalesce into one callback", 1, callCount.get()); + assertTrue("final network state should be false", !mgr.isNetworkAvailable()); + assertTrue("final foreground state should be true", mgr.isForeground()); + + mgr.close(); + } + + @Test + public void closePreventsFutureCallbacks() throws InterruptedException { + AtomicInteger callCount = new AtomicInteger(0); + StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + + mgr.setNetworkAvailable(false); + mgr.close(); + + Thread.sleep(TEST_DEBOUNCE_MS * 3); + assertEquals("callback should not fire after close()", 0, callCount.get()); + } + + @Test + public void closeCancelsPendingTimer() throws InterruptedException { + AtomicInteger callCount = new AtomicInteger(0); + StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + + mgr.setNetworkAvailable(false); + Thread.sleep(TEST_DEBOUNCE_MS / 3); + mgr.close(); + + Thread.sleep(TEST_DEBOUNCE_MS * 3); + assertEquals("pending timer should be cancelled on close", 0, callCount.get()); + } + + @Test + public void settersAfterCloseDoNotTriggerCallback() throws InterruptedException { + AtomicInteger callCount = new AtomicInteger(0); + StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + + mgr.close(); + mgr.setNetworkAvailable(false); + mgr.setForeground(false); + + Thread.sleep(TEST_DEBOUNCE_MS * 3); + assertEquals("setters after close should not trigger callback", 0, callCount.get()); + } + + @Test + public void separateEventsProduceSeparateCallbacks() throws InterruptedException { + AtomicInteger callCount = new AtomicInteger(0); + StateDebounceManager mgr = createManager(true, true, callCount::incrementAndGet); + + mgr.setNetworkAvailable(false); + Thread.sleep(TEST_DEBOUNCE_MS * 3); + assertEquals(1, callCount.get()); + + mgr.setNetworkAvailable(true); + Thread.sleep(TEST_DEBOUNCE_MS * 3); + assertEquals(2, callCount.get()); + + mgr.close(); + } +}