@@ -66,7 +66,7 @@ class ConnectivityManager {
6666 private final TaskExecutor taskExecutor ;
6767 private final boolean backgroundUpdatingDisabled ;
6868 private final List <WeakReference <LDStatusListener >> statusListeners = new ArrayList <>();
69- private final Debounce pollDebouncer = new Debounce ();
69+ private final Debounce pollDebouncer = new Debounce (); // FDv1 only
7070 private final AtomicBoolean forcedOffline = new AtomicBoolean ();
7171 private final AtomicBoolean started = new AtomicBoolean ();
7272 private final AtomicBoolean closed = new AtomicBoolean ();
@@ -79,6 +79,8 @@ class ConnectivityManager {
7979 private final ModeResolutionTable modeResolutionTable ;
8080 private volatile ConnectionMode currentFDv2Mode ;
8181 private final AutomaticModeSwitchingConfig autoModeSwitchingConfig ;
82+ private long debounceMs = StateDebounceManager .DEFAULT_DEBOUNCE_MS ;
83+ private volatile StateDebounceManager stateDebounceManager ; // FDv2 only
8284
8385 // The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource.
8486 // This has two purposes: 1. to decouple the data source implementation from the details of how
@@ -170,21 +172,49 @@ public void shutDown() {
170172 ? ((FDv2DataSourceBuilder ) dataSourceFactory ).getResolutionTable ()
171173 : null ;
172174
175+ if (useFDv2ModeResolution ) {
176+ this .stateDebounceManager = createDebounceManager ();
177+ }
178+
173179 connectivityChangeListener = networkAvailable -> {
174- if (useFDv2ModeResolution && !autoModeSwitchingConfig .isNetwork ()) {
180+ if (useFDv2ModeResolution ) {
181+ // Event processor state updated immediately so analytics events reflect reality.
175182 updateEventProcessor (forcedOffline .get (), platformState .isNetworkAvailable (), platformState .isForeground ());
176- return ;
183+ if (!autoModeSwitchingConfig .isNetwork ()) {
184+ return ;
185+ }
186+ // CONNMODE 3.5.1: route through debounce window instead of immediate rebuild
187+ StateDebounceManager dm = stateDebounceManager ;
188+ if (dm != null ) {
189+ dm .setNetworkAvailable (networkAvailable );
190+ }
191+ } else {
192+ // FDv1 path: handleModeStateChange updates event processor internally
193+ handleModeStateChange ();
177194 }
178- handleModeStateChange ();
179195 };
180196 platformState .addConnectivityChangeListener (connectivityChangeListener );
181197
182198 foregroundListener = foreground -> {
183- if (useFDv2ModeResolution && !autoModeSwitchingConfig .isLifecycle ()) {
199+ if (useFDv2ModeResolution ) {
200+ // Event processor state updated immediately so analytics events reflect reality.
184201 updateEventProcessor (forcedOffline .get (), platformState .isNetworkAvailable (), platformState .isForeground ());
185- return ;
202+ if (!autoModeSwitchingConfig .isLifecycle ()) {
203+ return ;
204+ }
205+ // CONNMODE 3.3.1: flush pending events before transitioning to background
206+ if (!foreground ) {
207+ eventProcessor .flush ();
208+ }
209+ // CONNMODE 3.5.1: route through debounce window
210+ StateDebounceManager dm = stateDebounceManager ;
211+ if (dm != null ) {
212+ dm .setForeground (foreground );
213+ }
214+ } else {
215+ // FDv1 path: handleModeStateChange updates event processor internally
216+ handleModeStateChange ();
186217 }
187- handleModeStateChange ();
188218 };
189219 platformState .addForegroundChangeListener (foregroundListener );
190220 }
@@ -193,6 +223,10 @@ public void shutDown() {
193223 * Switches the {@link ConnectivityManager} to begin fetching/receiving information
194224 * relevant to the context provided. This is likely to result in the teardown of existing
195225 * connections, but the timing of that is not guaranteed.
226+ * <p>
227+ * CONNMODE 3.5.6: identify does NOT participate in debounce. The debounce manager is
228+ * destroyed and recreated so that any pending debounced state change is discarded and
229+ * the new context starts with a clean timer.
196230 *
197231 * @param context to swtich to
198232 * @param onCompletion callback that indicates when the switching is done
@@ -204,6 +238,15 @@ void switchToContext(@NonNull LDContext context, @NonNull Callback<Void> onCompl
204238 if (oldContext == context || oldContext .equals (context )) {
205239 onCompletion .onSuccess (null );
206240 } else {
241+ // CONNMODE 3.5.6: identify bypasses debounce — close and recreate the manager
242+ if (useFDv2ModeResolution ) {
243+ StateDebounceManager oldDm = stateDebounceManager ;
244+ if (oldDm != null ) {
245+ oldDm .close ();
246+ }
247+ stateDebounceManager = createDebounceManager ();
248+ }
249+
207250 ModeState state = snapshotModeState ();
208251 if (dataSource == null || dataSource .needsRefresh (!state .isForeground (), context )) {
209252 updateEventProcessor (forcedOffline .get (), state .isNetworkAvailable (), state .isForeground ());
@@ -536,6 +579,11 @@ void shutDown() {
536579 if (closed .getAndSet (true )) {
537580 return ;
538581 }
582+ StateDebounceManager dm = stateDebounceManager ;
583+ if (dm != null ) {
584+ dm .close ();
585+ stateDebounceManager = null ;
586+ }
539587 DataSource oldDataSource = currentDataSource .getAndSet (null );
540588 if (oldDataSource != null ) {
541589 oldDataSource .stop (LDUtil .noOpCallback ());
@@ -570,13 +618,64 @@ private void updateEventProcessor(boolean forceOffline, boolean networkAvailable
570618 * Unified handler for all platform/configuration state changes (foreground, connectivity,
571619 * force-offline). Snapshots the current state once, updates the event processor, then
572620 * routes to the appropriate data source update path.
621+ * <p>
622+ * FDv1 only — FDv2 state changes are routed through {@link StateDebounceManager} and
623+ * reconciled via {@link #handleDebouncedModeStateChange()}.
573624 */
574625 private synchronized void handleModeStateChange () {
575626 ModeState state = snapshotModeState ();
576627 updateEventProcessor (forcedOffline .get (), state .isNetworkAvailable (), state .isForeground ());
577628 updateDataSource (false , state , LDUtil .noOpCallback ());
578629 }
579630
631+ /**
632+ * Creates a new {@link StateDebounceManager} initialized with the current platform state.
633+ * Called once during construction (for FDv2) and again on each identify to discard pending
634+ * debounced changes (CONNMODE 3.5.6).
635+ */
636+ /**
637+ * Sets the debounce window duration and recreates the debounce manager. Package-private
638+ * to allow tests to use a shorter debounce window for deterministic timing.
639+ */
640+ void setDebounceMs (long ms ) {
641+ this .debounceMs = ms ;
642+ if (useFDv2ModeResolution ) {
643+ StateDebounceManager oldDm = stateDebounceManager ;
644+ if (oldDm != null ) {
645+ oldDm .close ();
646+ }
647+ stateDebounceManager = createDebounceManager ();
648+ }
649+ }
650+
651+ private StateDebounceManager createDebounceManager () {
652+ return new StateDebounceManager (
653+ platformState .isNetworkAvailable (),
654+ platformState .isForeground (),
655+ taskExecutor ,
656+ debounceMs ,
657+ this ::handleDebouncedModeStateChange
658+ );
659+ }
660+
661+ /**
662+ * Reconciliation callback invoked by the {@link StateDebounceManager} when the debounce
663+ * timer fires (CONNMODE 3.5.3). Reads the latest accumulated state from the debounce
664+ * manager and triggers a data source update if the resolved mode has changed.
665+ */
666+ private void handleDebouncedModeStateChange () {
667+ StateDebounceManager dm = stateDebounceManager ;
668+ if (dm == null ) {
669+ return ;
670+ }
671+ ModeState state = new ModeState (
672+ dm .isForeground (),
673+ dm .isNetworkAvailable (),
674+ backgroundUpdatingDisabled
675+ );
676+ updateDataSource (false , state , LDUtil .noOpCallback ());
677+ }
678+
580679 private ModeState snapshotModeState () {
581680 return new ModeState (
582681 platformState .isForeground (),
0 commit comments