From dcec3f141b2ac911a35f908cb3d7401bf2707e15 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 4 Mar 2026 11:52:07 -0800 Subject: [PATCH 01/14] [SDK-1956] Research and proposed development plans for SDK-1956 --- docs/SDK-1956-development-plan.md | 515 ++++++++++++++++++ docs/SDK-1956-research-plan.md | 279 ++++++++++ ...DK-1956-switch-mode-approach-comparison.md | 200 +++++++ 3 files changed, 994 insertions(+) create mode 100644 docs/SDK-1956-development-plan.md create mode 100644 docs/SDK-1956-research-plan.md create mode 100644 docs/SDK-1956-switch-mode-approach-comparison.md diff --git a/docs/SDK-1956-development-plan.md b/docs/SDK-1956-development-plan.md new file mode 100644 index 00000000..b617c711 --- /dev/null +++ b/docs/SDK-1956-development-plan.md @@ -0,0 +1,515 @@ +# SDK-1956 Development Plan — FDv2 Connection Mode Configuration + +## Key Insights from js-core + +Based on Ryan Lamb's recent PRs in [js-core](https://github.com/launchdarkly/js-core), the FDv2 client-side architecture separates into four distinct layers. Each layer is built and tested independently, and the Android implementation should follow the same decomposition. + +### Layer 1: Mode Types and Mode Table (PR [#1135](https://github.com/launchdarkly/js-core/pull/1135), merged) + +Pure configuration types with no behavior: + +- **`FDv2ConnectionMode`** — named mode: `streaming`, `polling`, `offline`, `one-shot`, `background` +- **`DataSourceEntry`** — JS discriminated union (not used in Android; replaced by `ComponentConfigurer`) +- **`ModeDefinition`** — `{ initializers: ComponentConfigurer[], synchronizers: ComponentConfigurer[] }` +- **`MODE_TABLE`** — built-in map of every `FDv2ConnectionMode` → `ModeDefinition` +- **`LDClientDataSystemOptions`** — user-facing config: `initialConnectionMode`, `backgroundConnectionMode`, `automaticModeSwitching` +- **`PlatformDataSystemDefaults`** — per-platform defaults (Android: foreground=streaming, background=background, automaticModeSwitching=true) + +### Layer 2: Mode Resolution (PR [#1146](https://github.com/launchdarkly/js-core/pull/1146), open) + +A pure function + data-driven table that maps platform state → connection mode: + +- **`ModeState`** — input: `{ lifecycle, networkAvailable, foregroundMode, backgroundMode }` +- **`ModeResolutionEntry`** — `{ conditions: Partial, mode: FDv2ConnectionMode | ConfiguredMode }` +- **`ModeResolutionTable`** — ordered list of entries; first match wins +- **`resolveConnectionMode(table, input)`** — evaluates the table, returns a `FDv2ConnectionMode` +- **`MOBILE_TRANSITION_TABLE`** — the Android default: + 1. `{ networkAvailable: false }` → `'offline'` + 2. `{ lifecycle: 'background' }` → configured background mode + 3. `{ lifecycle: 'foreground' }` → configured foreground mode + +The js-core resolver supports **`ConfiguredMode`** indirection: `{ configured: 'foreground' }` resolves to `input.foregroundMode`, `{ configured: 'background' }` resolves to `input.backgroundMode`. **In this PR, we simplify by hardcoding the Android defaults** (foreground=STREAMING, background=BACKGROUND) directly in the resolution table. User-configurable foreground/background mode selection is deferred to a future PR. + +### Layer 3: State Debouncing (PR [#1148](https://github.com/launchdarkly/js-core/pull/1148), open) + +A separate `StateDebounceManager` component that coalesces rapid platform events: + +- Tracks three independent dimensions: `networkState`, `lifecycleState`, `requestedMode` +- Each change resets a 1-second timer (CONNMODE spec 3.5.4) +- When the timer fires, `onReconcile(pendingState)` is called with the final accumulated state +- `identify()` does NOT participate in debouncing (spec 3.5.6) — it bypasses the debouncer +- `close()` cancels pending timers; further calls become no-ops + +### Layer 4: FDv2DataSource Orchestrator (PR [#1141](https://github.com/launchdarkly/js-core/pull/1141), merged) + +The orchestrator in js-core (`createFDv2DataSource`) is structurally similar to Todd's Android `FDv2DataSource`: + +- Takes `initializerFactories` and `synchronizerSlots` (with Available/Blocked state) +- Runs initializers sequentially, then enters the synchronizer loop +- Uses `Conditions` (fallback/recovery timers) to decide when to switch synchronizers +- Receives a `selectorGetter` from the outside — it does NOT manage the selector internally +- Has `start()` and `close()` — currently no `switchMode()` method + +Key observation: The JS orchestrator does **not** have a `switchMode()` method yet. Mode switching will likely be handled by the consumer layer that creates and manages the orchestrator. For Android, we need to decide whether `FDv2DataSource` gets a `switchMode()` method or whether mode switching is handled externally. + +### Supporting PRs (js-core) + +- **Cache Initializer** (PR [#1147](https://github.com/launchdarkly/js-core/pull/1147), draft): Reads cached flags from storage, returns as a `changeSet` without a selector. Orchestrator sees `dataReceived=true` and continues to next initializer. +- **Polling Initializer/Synchronizer** (PR [#1130](https://github.com/launchdarkly/js-core/pull/1130), merged): FDv2 polling using `FDv2ProtocolHandler`, handles 304 Not Modified, recoverable vs terminal errors. +- **Streaming Initializer/Synchronizer** (PR [#1131](https://github.com/launchdarkly/js-core/pull/1131), merged): FDv2 streaming via EventSource, supports one-shot (initializer) and long-lived (synchronizer) modes, ping handling, fallback detection. + +### Layer 5: Android Concrete Initializer/Synchronizer Implementations (PR [#325](https://github.com/launchdarkly/android-client-sdk/pull/325), open) + +Todd's PR adds the concrete Android implementations of `Initializer` and `Synchronizer`. These are the components that our `ComponentConfigurer` factory methods will create: + +- **`FDv2PollingInitializer`** — Single-shot poll. Implements `Initializer`. Dependencies: `FDv2Requestor`, `SelectorSource`, `Executor`, `LDLogger`. Returns `CHANGE_SET` on success, `TERMINAL_ERROR` on failure. +- **`FDv2PollingSynchronizer`** — Recurring poll on `ScheduledExecutorService`. Implements `Synchronizer`. Dependencies: `FDv2Requestor`, `SelectorSource`, `ScheduledExecutorService`, `initialDelayMillis`, `pollIntervalMillis`, `LDLogger`. Results delivered via `LDAsyncQueue`. +- **`FDv2StreamingSynchronizer`** — Long-lived SSE connection via `EventSource`. Implements `Synchronizer`. Dependencies: `HttpProperties`, `streamBaseUri`, `requestPath`, `LDContext`, `useReport`, `evaluationReasons`, `SelectorSource`, optional `FDv2Requestor` (for `ping` events), `initialReconnectDelayMillis`, `DiagnosticStore`, `LDLogger`. +- **`FDv2PollingBase`** — Abstract base for polling. Shared `doPoll()` logic: drives `FDv2ProtocolHandler`, translates changesets via `FDv2ChangeSetTranslator`, maps errors to `TERMINAL_ERROR` (oneShot) vs `INTERRUPTED` (recurring). +- **`FDv2Requestor` / `DefaultFDv2Requestor`** — Interface + OkHttp implementation for FDv2 polling HTTP requests. Supports GET/REPORT, `basis` query param (selector), ETag tracking for 304 Not Modified, payload filters. +- **`FDv2ChangeSetTranslator`** — Converts `FDv2ChangeSet` → `ChangeSet>`. Filters for `flag_eval` kind only. +- **`SelectorSource` / `SelectorSourceFacade`** — Interface + adapter for reading the current `Selector` from `TransactionalDataStore` without coupling to the update sink. +- **`LDAsyncQueue`** — Thread-safe async queue: producers `put()`, consumers `take()` as futures. Used by synchronizers. +- **`LDFutures.anyOf`** — Now generic (``) and returns `LDAwaitFuture`. Used to race result queues against shutdown futures. + +**FDv2DataSource changes in PR #325:** +- Now uses `sharedExecutor.execute()` instead of `new Thread()` for the orchestrator loop. +- Added javadoc to constructors. +- Minor: removed unused `Selector` import from `DataSourceUpdateSinkV2`. + +--- + +## Scope — This PR + +**In scope:** +- `ConnectionMode` enum with 5 built-in modes (closed enum, no custom modes) +- `ModeDefinition` + `DEFAULT_MODE_TABLE` with `ComponentConfigurer` entries +- `ModeState` with platform state only (`foreground`, `networkAvailable`) +- `ModeResolutionTable` with hardcoded Android defaults (foreground→STREAMING, background→BACKGROUND, no network→OFFLINE) +- `switchMode(ConnectionMode)` on `FDv2DataSource` +- ConnectivityManager integration (mode resolution in foreground/network listeners) +- `FDv2DataSourceBuilder` (resolves `ComponentConfigurer` → `DataSourceFactory`) + +**Deferred to future PRs:** +- Custom named connection modes (spec 5.3.5 TBD) +- User-configurable foreground/background mode selection (CONNMODE 2.2.2) — adds `foregroundMode`/`backgroundMode` to `ModeState` and config options to `LDConfig` +- Mode table partial overrides (user overriding initializer/synchronizer lists for a built-in mode) +- State debouncing (`StateDebounceManager`) +- `automaticModeSwitching` config option (granular lifecycle/network toggle) +- Mode switch optimization (spec 5.3.8 TBD — retain active data source if equivalent config). See note below. + +--- + +## FDv2DataSource Is Not Aware of Platform State + +**FDv2DataSource should not subscribe to `PlatformState` or know about foreground/background.** It only knows about named connection modes and their corresponding initializer/synchronizer pipelines. The mapping from platform state → connection mode happens externally. + +--- + +## Architecture + +``` +┌──────────────────────┐ +│ PlatformState │ fires foreground/background + network events +└──────────┬───────────┘ + │ + ▼ +┌──────────────────────────────────────┐ +│ StateDebounceManager (future PR) │ +│ │ +│ • Accumulates network + lifecycle │ +│ changes │ +│ • 1-second debounce window │ +│ • Fires onReconcile(pendingState) │ +│ after quiet period │ +│ • identify() bypasses debouncing │ +└──────────┬───────────────────────────┘ + │ onReconcile(pendingState) + ▼ +┌──────────────────────────────────────┐ +│ Mode Resolution │ +│ │ +│ • Build ModeState(foreground, │ +│ networkAvailable) from platform │ +│ • ModeResolutionTable.MOBILE │ +│ .resolve(modeState) │ +│ • Hardcoded: fg→STREAMING, │ +│ bg→BACKGROUND, no net→OFFLINE │ +└──────────┬───────────────────────────┘ + │ resolved ConnectionMode + ▼ +┌──────────────────────────────────────┐ +│ ConnectivityManager │ +│ │ +│ • Owns the current DataSource │ +│ • If ModeAware: calls │ +│ switchMode(resolvedMode) │ +│ • If FDv1 DataSource: existing │ +│ teardown/rebuild behavior │ +│ • identify() → needsRefresh() → │ +│ full teardown/rebuild │ +└──────────┬───────────────────────────┘ + │ switchMode(BACKGROUND) + ▼ +┌──────────────────────────────────────┐ +│ FDv2DataSource │ +│ │ +│ • Holds the mode table │ +│ (ConnectionMode → ModeDefinition) │ +│ • On switchMode(): stop current │ +│ synchronizers, start new ones │ +│ from the target mode's definition │ +│ • Does NOT re-run initializers │ +│ on mode switch (spec 2.0.1) │ +│ • needsRefresh() returns false for │ +│ background changes, true for │ +│ context changes │ +└──────────────────────────────────────┘ +``` + +### Key Separation of Concerns + +| Concern | Owner | +|---------|-------| +| Detecting platform state (foreground, network) | `PlatformState` / `AndroidPlatformState` | +| Coalescing rapid state changes | `StateDebounceManager` (future PR) | +| Mapping platform state → connection mode | `ModeResolutionTable.MOBILE.resolve()` (hardcoded defaults) | +| Data source lifecycle (start, stop, rebuild on identify) | `ConnectivityManager` | +| Commanding mode switches | `ConnectivityManager` via `ModeAware.switchMode()` | +| Orchestrating initializer/synchronizer pipelines | `FDv2DataSource` | +| Mode definitions (what each mode uses) | `MODE_TABLE` (`Map`) | + +--- + +## New Types to Create + +All types are **package-private** in `com.launchdarkly.sdk.android` (internal to the SDK, no public API changes). + +### 1. `ConnectionMode` (enum) + +```java +enum ConnectionMode { + STREAMING, POLLING, OFFLINE, ONE_SHOT, BACKGROUND +} +``` + +Maps to JS `FDv2ConnectionMode`. Closed enum — custom modes are out of scope for this PR (spec 5.3.5 is TBD and unresolved). + +### 2. `ModeDefinition` + +```java +final class ModeDefinition { + final List> initializers; + final List> synchronizers; +} +``` + +Uses the SDK's existing `ComponentConfigurer` pattern (which takes `ClientContext` at build time) rather than a custom `DataSourceEntry` config type. This eliminates the need for a separate config-to-factory conversion step — the mode table directly holds factory functions. + +Helper factory methods provide readable construction: + +```java +static ComponentConfigurer pollingInitializer() { ... } +static ComponentConfigurer pollingSynchronizer(long intervalMs) { ... } +static ComponentConfigurer streamingSynchronizer() { ... } +static ComponentConfigurer cacheInitializer() { ... } // stubbed for now +``` + +### 3. Default Mode Table + +```java +static final Map DEFAULT_MODE_TABLE = ...; +``` + +Contents match the JS `MODE_TABLE`: + +| Mode | Initializers | Synchronizers | +|------|-------------|---------------| +| STREAMING | cache, polling | streaming, polling | +| POLLING | cache | polling | +| OFFLINE | cache | (none) | +| ONE_SHOT | cache, polling, streaming | (none) | +| BACKGROUND | cache | polling @ 3600s | + +At build time, `FDv2DataSourceBuilder.build(clientContext)` resolves each `ComponentConfigurer` into a `DataSourceFactory` (Todd's zero-arg factory pattern) by partially applying the `ClientContext`: + +```java +DataSourceFactory factory = () -> configurer.build(clientContext); +``` + +This resolution produces a `Map` where `ResolvedModeDefinition` holds `List>` and `List>`. FDv2DataSource only works with the resolved factories — it never sees `ComponentConfigurer` or `ClientContext`. + +### 4. `ModeState` + +```java +final class ModeState { + final boolean foreground; + final boolean networkAvailable; +} +``` + +Represents the current platform state. All fields required, primitive `boolean` types. Built by ConnectivityManager from `PlatformState` events. + +In this PR, `ModeState` only carries platform state. User-configurable foreground/background mode selection (CONNMODE 2.2.2) is deferred to a future PR. When that's added, `foregroundMode` and `backgroundMode` fields will be introduced here and the resolution table entries will reference them via lambdas instead of hardcoded enum values. + +### 5. `ModeResolutionEntry` + +```java +final class ModeResolutionEntry { + final Predicate conditions; // does this entry apply to the given state? + final ConnectionMode mode; // the resolved mode if this entry matches +} +``` + +With hardcoded defaults, the resolver is a simple `ConnectionMode` value rather than a `Function`. When user-configurable mode selection is added later, `mode` can be replaced with a `Function` resolver to support indirection like `state -> state.foregroundMode`. + +### 6. `ModeResolutionTable` + `resolve()` (pure function) + +```java +final class ModeResolutionTable { + static final ModeResolutionTable MOBILE = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry( + state -> !state.networkAvailable, + ConnectionMode.OFFLINE), + new ModeResolutionEntry( + state -> !state.foreground, + ConnectionMode.BACKGROUND), + new ModeResolutionEntry( + state -> state.foreground, + ConnectionMode.STREAMING) + )); + + ConnectionMode resolve(ModeState state) { ... } +} +``` + +`resolve()` iterates entries in order. The first entry whose `conditions` predicate returns `true` wins, and its `mode` value is returned. Pure function — no side effects, no platform awareness. + +**Adaptation note:** This is a Java-idiomatic adaptation of Ryan Lamb's mode resolution code from js-core PR [#1146](https://github.com/launchdarkly/js-core/pull/1146). The js-core version uses `Partial` for conditions (partial object matching) and `ConfiguredMode` indirection for user-configurable modes. In this PR, we simplify: conditions become `Predicate` and modes are hardcoded `ConnectionMode` enum values. The data-driven table structure is preserved so that user-configurable mode selection can be added later by replacing the `ConnectionMode mode` field with a `Function` resolver. + +### 7. `ModeAware` (package-private interface) + +```java +interface ModeAware extends DataSource { + void switchMode(ConnectionMode newMode); +} +``` + +FDv2DataSource implements this. ConnectivityManager checks `instanceof ModeAware` to decide whether to use mode resolution or legacy FDv1 behavior. + +--- + +## Changes to Existing Code + +### `FDv2DataSource` + +1. **Implement `ModeAware`** (which extends `DataSource`). + - `ModeAware` is a marker interface with a single method: `void switchMode(ConnectionMode newMode)`. All logic lives in `FDv2DataSource`. + - Alternative: skip the interface entirely and have ConnectivityManager use `instanceof FDv2DataSource` directly. The interface is a thin abstraction; either approach works. +2. **Add `switchMode(ConnectionMode)` method:** + - Look up the new mode in the mode table to get its `ModeDefinition`. + - Stop current synchronizers (close the active `SourceManager`). + - Create a new `SourceManager` with the new mode's synchronizer factories. + - Signal the background thread to resume the synchronizer loop with new factories. + - Do NOT re-run initializers (spec 2.0.1). +3. **Override `needsRefresh()`:** + - Return `false` when only the background state changed (mode-aware data source handles this via `switchMode`). + - Return `true` when the evaluation context changed (requires full teardown/rebuild). +4. **New constructor** that accepts: + - The resolved mode table (`Map`) — already contains `DataSourceFactory` instances + - The starting `ConnectionMode` + - Keep existing constructors for backward compatibility with Todd's tests. + +### `ConnectivityManager` + +1. **Foreground listener:** After `needsRefresh()` returns false, if the data source is a `ModeAware`, build a `ModeState` from current platform state, resolve the mode via `ModeResolutionTable.MOBILE`, and call `switchMode()` if the mode changed. +2. **Network listener:** Same pattern — resolve mode and call `switchMode()`. +3. **Configuration:** ConnectivityManager needs access to the `ModeResolutionTable` (see open question #1). With hardcoded defaults, no user-configurable mode selection is needed in this PR. + +### No Changes + +- `DataSource` interface (public API) +- `StreamingDataSource`, `PollingDataSource` (FDv1 paths) +- `PlatformState`, `AndroidPlatformState` +- `ClientContext`, `ClientContextImpl` +- `LDConfig` public API + +--- + +## Implementation Order (Small, Incremental Commits) + +The work is decomposed into small commits that each build on the previous one. Each commit should compile and not break existing tests. + +### Commit 1: All new types (new files only, no changes to existing code) + +| File | Description | +|------|-------------| +| `ConnectionMode.java` | Enum: STREAMING, POLLING, OFFLINE, ONE_SHOT, BACKGROUND | +| `ModeDefinition.java` | `List>` + `List>` + DEFAULT_MODE_TABLE + helper factory methods | +| `ModeState.java` | Platform state for mode resolution: `boolean foreground`, `boolean networkAvailable` | +| `ModeResolutionEntry.java` | Predicate (`Predicate`) + hardcoded `ConnectionMode` | +| `ModeResolutionTable.java` | Ordered list + `resolve()` method + MOBILE constant | +| `ModeAware.java` | Package-private interface extending DataSource with `switchMode(ConnectionMode)` | + +Tests: `ModeResolutionTable.resolve()` with various `ModeState` inputs. + +### Commit 2: `ModeAware` implementation on `FDv2DataSource` + +| File | Description | +|------|-------------| +| `FDv2DataSource.java` (modify) | Implement `ModeAware`, override `needsRefresh()`, add stub `switchMode()` | + +Tests: `needsRefresh()` returns false for background-only changes, true for context changes. + +### Commit 3: `switchMode()` implementation + +| File | Description | +|------|-------------| +| `FDv2DataSource.java` (modify) | Full `switchMode()` — stop synchronizers, swap to new mode's factories, resume | + +### Commit 4: `FDv2DataSourceBuilder` + +| File | Description | +|------|-------------| +| `FDv2DataSourceBuilder.java` (new) | `ComponentConfigurer` that builds mode-aware FDv2DataSource | + +The builder resolves `ComponentConfigurer` → `DataSourceFactory` by partially applying the `ClientContext`. This bridges the SDK's `ComponentConfigurer` pattern (used in the mode table) with Todd's `DataSourceFactory` pattern (used inside `FDv2DataSource`). + +### Commit 5: ConnectivityManager mode resolution integration + +| File | Description | +|------|-------------| +| `ConnectivityManager.java` (modify) | Add mode resolution for `ModeAware` instances in foreground/network listeners | + +### Future PR: State debouncing + +| File | Description | +|------|-------------| +| `StateDebounceManager.java` (new) | Android port of js-core's `StateDebounceManager` — sits between PlatformState and mode resolution | + +### Future PR: Mode switch optimization (spec 5.3.8) + +Spec 5.3.8 says the SDK SHOULD retain active data sources when switching modes if the old and new modes have equivalent synchronizer configuration. This avoids unnecessary teardown/rebuild when, for example, a user configures both streaming and background modes to use the same synchronizers. + +**Approach: instance equality (`==`) on factories.** The simplest way to determine if two synchronizers are "equivalent" is to check if they are the *same instance*. This works if the `DEFAULT_MODE_TABLE` (and any future user overrides) shares `ComponentConfigurer` instances across modes where the configuration is identical. At build time, `FDv2DataSourceBuilder` resolves each `ComponentConfigurer` into a `DataSourceFactory`. If two modes reference the same `ComponentConfigurer` instance, they get the same `DataSourceFactory` instance, and `==` comparison identifies them as equivalent. + +**Implication for mode table construction:** factory helper methods (e.g., `pollingInitializer()`, `streamingSynchronizer()`) should return shared static instances for the default configurations. Different configurations (e.g., polling at 30s vs. 3600s) produce different instances, so `==` correctly identifies them as non-equivalent. + +**Implication for `SourceManager`:** Todd is interested in enhancing `SourceManager` to support this optimization. Instead of closing all synchronizers and building new ones on `switchMode()`, `SourceManager` could diff the old and new synchronizer factory lists using `==`, keep running any that are shared, and only tear down removed / start added synchronizers. This change is internal to `SourceManager` and `FDv2DataSource` — it doesn't affect the `ModeAware.switchMode(ConnectionMode)` contract or the mode resolution layer. + +**Current PR:** This PR uses approach B (create a new `SourceManager` on each mode switch — full teardown/rebuild). The instance-equality optimization can be added later without changing any external interfaces. + +--- + +## Branch Dependencies + +Our work builds on two of Todd's branches: + +| Branch | PR | Status | What we depend on | +|--------|-----|--------|-------------------| +| `ta/SDK-1817/composite-src-pt2` | (base) | In progress | `FDv2DataSource`, `SourceManager`, `FDv2DataSourceConditions`, `Initializer`/`Synchronizer` interfaces, `DataSourceFactory`, `FDv2SourceResult`, `DataSourceUpdateSinkV2` | +| `ta/SDK-1835/initializers-synchronizers` | [#325](https://github.com/launchdarkly/android-client-sdk/pull/325) | Open | `FDv2PollingInitializer`, `FDv2PollingSynchronizer`, `FDv2StreamingSynchronizer`, `FDv2Requestor`/`DefaultFDv2Requestor`, `FDv2ChangeSetTranslator`, `SelectorSource`/`SelectorSourceFacade`, `LDAsyncQueue`, `LDFutures.anyOf` | + +**Our branching strategy:** Branch off `ta/SDK-1835/initializers-synchronizers` (which itself targets `ta/SDK-1817/composite-src-pt2`). Our commits (1–6) can be developed independently of PR #325 merging — we only need the types/interfaces, not the running implementations. However, Commit 5 (`FDv2DataSourceBuilder`) will reference the concrete constructors from PR #325 directly. + +--- + +## Open Questions + +### 1. How does ConnectivityManager get the mode resolution table? + +With hardcoded defaults (no user-configurable foreground/background mode selection in this PR), the simplest approach is for ConnectivityManager to use `ModeResolutionTable.MOBILE` directly — it's a static constant. No `ModeResolutionConfig` object or builder interface is needed in this PR. + +When user-configurable mode selection is added in a future PR, ConnectivityManager will need to receive the configured foreground/background modes. At that point, options include a `ModeResolutionProvider` marker interface on the builder, a constructor parameter on ConnectivityManager, or putting config into `ClientContextImpl`. + +### 2. How does the mode table connect to concrete implementations? + +**Answered.** The mode table holds `ComponentConfigurer` and `ComponentConfigurer` entries (the SDK's established factory pattern). At build time, `FDv2DataSourceBuilder.build(clientContext)` resolves each one into a `DataSourceFactory` (Todd's zero-arg factory pattern) by partially applying the `ClientContext`: + +```java +DataSourceFactory factory = () -> configurer.build(clientContext); +``` + +The concrete types created by the factory methods (from Todd's PR #325): + +| Factory method | Creates | +|---------------|---------| +| `pollingInitializer()` | `FDv2PollingInitializer(requestor, selectorSource, executor, logger)` | +| `pollingSynchronizer(intervalMs)` | `FDv2PollingSynchronizer(requestor, selectorSource, scheduledExecutor, initialDelayMs, intervalMs, logger)` | +| `streamingSynchronizer()` | `FDv2StreamingSynchronizer(httpProperties, streamBaseUri, requestPath, context, useReport, evaluationReasons, selectorSource, requestor, initialReconnectDelayMs, diagnosticStore, logger)` | +| `cacheInitializer()` | (stubbed for now; cache initializer not yet implemented on Android) | + +All dependencies come from `ClientContext` at build time. `FDv2DataSource` only works with resolved `DataSourceFactory` instances — it never sees `ComponentConfigurer` or `ClientContext`. + +### 3. Should we add debouncing now? + +**Answer: No.** Debouncing will be added in a subsequent PR/commit. The architecture supports it — a `StateDebounceManager` (modeled after js-core's `StateDebounceManager`) sits between PlatformState listeners and mode resolution. The current implementation will call `switchMode()` directly from the listeners, and debouncing wraps this later. + +### 4. What about `needsRefresh` and network changes? + +ConnectivityManager's network listener currently calls `updateDataSource(false, ...)`, which can stop the data source if the network is unavailable. For FDv2 with mode resolution, network loss → resolve to OFFLINE mode → `switchMode(OFFLINE)`. This means the network listener needs the same `instanceof ModeAware` check as the foreground listener. + +We need to ensure that when mode resolution is active, the existing `updateDataSource` logic for network changes is bypassed for `ModeAware` instances. + +### 5. Should `switchMode()` be synchronous or asynchronous? + +`switchMode()` is called from listener threads (foreground/network events). FDv2DataSource runs its synchronizer loop on a background thread. The call must signal the background thread to swap synchronizers. + +Design: `switchMode()` sets the desired mode atomically and closes the current `SourceManager` (which interrupts the active synchronizer's `next()` future). The background thread detects the mode change, creates a new `SourceManager` with the new mode's factories, and re-enters the synchronizer loop. This is the same pattern as the existing `stop()` method. + +--- + +## Reference Code + +### js-core FDv2 Architecture + +| Component | PR | File | Status | +|-----------|-----|------|--------| +| Mode types + table | [#1135](https://github.com/launchdarkly/js-core/pull/1135) | [`FDv2ConnectionMode.ts`][1], [`DataSourceEntry.ts`][2], [`ModeDefinition.ts`][3], [`ConnectionModeConfig.ts`][4], [`LDClientDataSystemOptions.ts`][5] | Merged | +| Mode resolution | [#1146](https://github.com/launchdarkly/js-core/pull/1146) | [`ModeResolution.ts`][6], [`ModeResolver.ts`][7] | Open | +| State debouncer | [#1148](https://github.com/launchdarkly/js-core/pull/1148) | [`StateDebounceManager.ts`][8] | Open | +| FDv2 orchestrator | [#1141](https://github.com/launchdarkly/js-core/pull/1141) | [`FDv2DataSource.ts`][9], [`SourceManager.ts`][10], [`Conditions.ts`][11] | Merged | +| Polling init/sync | [#1130](https://github.com/launchdarkly/js-core/pull/1130) | `PollingInitializer.ts`, `PollingSynchronizer.ts`, `PollingBase.ts` | Merged | +| Streaming init/sync | [#1131](https://github.com/launchdarkly/js-core/pull/1131) | `StreamingInitializerFDv2.ts`, `StreamingSynchronizerFDv2.ts`, `StreamingFDv2Base.ts` | Merged | +| Cache initializer | [#1147](https://github.com/launchdarkly/js-core/pull/1147) | [`CacheInitializer.ts`][12] | Draft | + +[1]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/api/datasource/FDv2ConnectionMode.ts +[2]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/api/datasource/DataSourceEntry.ts +[3]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/api/datasource/ModeDefinition.ts +[4]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/datasource/ConnectionModeConfig.ts +[5]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/api/datasource/LDClientDataSystemOptions.ts +[6]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/api/datasource/ModeResolution.ts +[7]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/datasource/ModeResolver.ts +[8]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/datasource/StateDebounceManager.ts +[9]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/datasource/fdv2/FDv2DataSource.ts +[10]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/datasource/fdv2/SourceManager.ts +[11]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/datasource/fdv2/Conditions.ts +[12]: /Users/azeisler/code/launchdarkly/js-core/packages/shared/sdk-client/src/datasource/fdv2/CacheInitializer.ts + +### Android SDK — Orchestrator (Todd's branch `ta/SDK-1817/composite-src-pt2`) + +- FDv2DataSource: [`FDv2DataSource.java`](/Users/azeisler/code/launchdarkly/android-client-sdk/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java) +- SourceManager: [`SourceManager.java`](/Users/azeisler/code/launchdarkly/android-client-sdk/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java) +- DataSourceFactory: defined inside `FDv2DataSource.java` as `DataSourceFactory` with `T build()` +- Initializer interface: [`Initializer.java`](/Users/azeisler/code/launchdarkly/android-client-sdk/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Initializer.java) +- Synchronizer interface: [`Synchronizer.java`](/Users/azeisler/code/launchdarkly/android-client-sdk/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/Synchronizer.java) + +### Android SDK — Concrete Initializers/Synchronizers (Todd's branch `ta/SDK-1835/initializers-synchronizers`, PR [#325](https://github.com/launchdarkly/android-client-sdk/pull/325)) + +- FDv2PollingInitializer: `FDv2PollingInitializer.java` — single-shot poll, implements `Initializer` +- FDv2PollingSynchronizer: `FDv2PollingSynchronizer.java` — recurring poll, implements `Synchronizer` +- FDv2StreamingSynchronizer: `FDv2StreamingSynchronizer.java` — SSE stream, implements `Synchronizer` +- FDv2PollingBase: `FDv2PollingBase.java` — shared polling logic (protocol handler + changeset translation) +- FDv2Requestor: `FDv2Requestor.java` — polling HTTP interface +- DefaultFDv2Requestor: `DefaultFDv2Requestor.java` — OkHttp implementation with ETag + selector support +- FDv2ChangeSetTranslator: `FDv2ChangeSetTranslator.java` — `FDv2ChangeSet` → `ChangeSet>` +- SelectorSource: `SelectorSource.java` — interface for current `Selector` +- SelectorSourceFacade: `SelectorSourceFacade.java` — adapts `TransactionalDataStore` to `SelectorSource` +- LDAsyncQueue: defined inside `LDFutures.java` — async producer/consumer queue + +### Android SDK — Existing (main branch) + +- ConnectivityManager: [`ConnectivityManager.java`](/Users/azeisler/code/launchdarkly/android-client-sdk/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java) +- DataSource interface: [`DataSource.java`](/Users/azeisler/code/launchdarkly/android-client-sdk/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSource.java) +- PlatformState: [`PlatformState.java`](/Users/azeisler/code/launchdarkly/android-client-sdk/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PlatformState.java) +- LDClient construction: [`LDClient.java` line 423](/Users/azeisler/code/launchdarkly/android-client-sdk/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java) diff --git a/docs/SDK-1956-research-plan.md b/docs/SDK-1956-research-plan.md new file mode 100644 index 00000000..9a706e97 --- /dev/null +++ b/docs/SDK-1956-research-plan.md @@ -0,0 +1,279 @@ +# SDK-1956: Research Plan — ConnectivityManager + FDv2 Platform State + +**Ticket:** [SDK-1956](https://launchdarkly.atlassian.net/browse/SDK-1956) — Update ConnectivityManager to support platform state driven synchronizer configurations. + +**Porting Plan Part:** 6 — Environment state in FDv2DataSource + +**Goal:** Understand how `ConnectivityManager` works today, how it interacts with `LDClient` and the data source subsystem, and how to integrate it with FDv2's platform-state-driven mode switching — all without breaking the existing FDv1 behavior. + +--- + +## Phase 1: How the Client Interacts with ConnectivityManager + +### The creation chain + +Everything starts in [`LDClient.init()`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java), which creates each `LDClient` instance. The constructor (line 314) creates the `ConnectivityManager`: + +```java +connectivityManager = new ConnectivityManager( + clientContextImpl, + config.dataSource, // ComponentConfigurer — the factory + eventProcessor, + contextDataManager, + environmentStore +); +``` + +The key parameter is `config.dataSource` — a `ComponentConfigurer` (factory). If the user didn't call `config.dataSource(...)`, the default is `Components.streamingDataSource()` (set in [`LDConfig.Builder.build()`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java) at line 519). + +### LDClient's calls into ConnectivityManager + +There are exactly **8 call sites** from `LDClient` to `ConnectivityManager`. These are the "upstream" touchpoints: + +| LDClient method | ConnectivityManager call | Purpose | +|---|---|---| +| `init()` (static) | `startUp(callback)` | Begin data source on SDK initialization | +| `identifyInternal()` | `switchToContext(context, callback)` | Context change (new user) | +| `closeInternal()` | `shutDown()` | Stop everything | +| `setOfflineInternal()` | `setForceOffline(true)` | Go offline | +| `setOnlineStatusInternal()` | `setForceOffline(false)` | Go online | +| `isInitialized()` | `isForcedOffline()` + `isInitialized()` | Check initialization state | +| `getConnectionInformation()` | `getConnectionInformation()` | Public connection info | +| `register/unregisterStatusListener` | `register/unregisterStatusListener(...)` | Connection status callbacks | + +**Key takeaway:** `LDClient` treats `ConnectivityManager` as a black box. It doesn't know about streaming vs. polling or FDv1 vs. FDv2. Changes to `ConnectivityManager`'s internals won't affect `LDClient` at all. + +### Configuration that reaches ConnectivityManager + +Two `LDConfig` settings flow into `ConnectivityManager`: + +1. **`config.isOffline()`** → `forcedOffline` (user explicitly offline) +2. **`config.isDisableBackgroundPolling()`** → `backgroundUpdatingDisabled` (kill data source in background) + +The data source builder itself (streaming vs. polling config, poll intervals, etc.) is opaque to `ConnectivityManager` — it only sees the `ComponentConfigurer` factory. + +### Files to read + +- [`LDClient.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDClient.java) — lines 100–270 (init flow), 314–321 (constructor), 375–386 (identify) +- [`LDConfig.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java) — lines 65, 72, 79, 229, 519 (data source and background config) + +--- + +## Phase 2: ConnectivityManager Internals + +### The core decision engine: `updateDataSource()` + +This is the single most important method to understand. All state transitions flow through it (lines 184–260 of [`ConnectivityManager.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java)). Here's the decision tree: + +``` +updateDataSource(mustReinitializeDataSource, onCompletion) +│ +├─ Read current state: +│ forcedOffline? platformState.isNetworkAvailable()? platformState.isForeground()? +│ +├─ IF forcedOffline → STOP data source, set SET_OFFLINE, DON'T start new +├─ ELIF no network → STOP data source, set OFFLINE, DON'T start new +├─ ELIF background + backgroundUpdatingDisabled → STOP, set BACKGROUND_DISABLED, DON'T start +├─ ELSE → +│ shouldStopExistingDataSource = mustReinitializeDataSource +│ shouldStartDataSourceIfStopped = true +│ +├─ IF shouldStopExistingDataSource → stop current, clear reference +└─ IF shouldStartDataSourceIfStopped AND no current data source → + build new DataSource via factory, start it +``` + +### Three triggers call `updateDataSource()` + +1. **Network change** (line 147): `updateDataSource(false, ...)` — never forces a rebuild; just stops/starts based on connectivity. + +2. **Foreground/background change** (line 152): First asks the current data source `needsRefresh(!foreground, currentContext)`. Only calls `updateDataSource(true, ...)` if the data source says it needs refreshing. + +3. **Context switch** via `switchToContext()` (line 170): Same `needsRefresh` check, then `updateDataSource(true, ...)` if needed. + +### How the StreamingDataSourceBuilder chooses between modes + +This is a critical detail — the *builder* makes the streaming-vs-polling decision, not `ConnectivityManager`. See [`ComponentsImpl.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java) lines 326–348: + +```java +// StreamingDataSourceBuilderImpl.build(): +if (clientContext.isInBackground() && !streamEvenInBackground) { + // In background: delegate to polling builder + return Components.pollingDataSource() + .backgroundPollIntervalMillis(backgroundPollIntervalMillis) + .pollIntervalMillis(backgroundPollIntervalMillis) + .build(clientContext); +} +// In foreground: create StreamingDataSource +return new StreamingDataSource(...); +``` + +So the FDv1 flow for foreground→background is: +1. `AndroidPlatformState` fires `onForegroundChanged(false)` +2. ConnectivityManager's listener calls `StreamingDataSource.needsRefresh(true, ctx)` → returns `true` (unless `streamEvenInBackground`) +3. `updateDataSource(true, ...)` stops the streaming data source +4. Calls `StreamingDataSourceBuilderImpl.build()` with `inBackground=true` → creates a **PollingDataSource** instead +5. Starts the new PollingDataSource + +**The entire streaming↔polling switch for FDv1 happens through tear-down and rebuild.** This is exactly what FDv2 wants to avoid. + +### The `needsRefresh` contract + +The [`DataSource.needsRefresh()`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSource.java) default returns `true` (always rebuild). [`StreamingDataSource`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java) overrides it (lines 258–262): + +```java +public boolean needsRefresh(boolean newInBackground, LDContext newEvaluationContext) { + return !newEvaluationContext.equals(context) || + (newInBackground && !streamEvenInBackground); +} +``` + +The current `FDv2DataSource` on Todd's branch (`origin/ta/SDK-1817/composite-src-pt2`) does **not** override `needsRefresh`, so it inherits the default `return true` — meaning ConnectivityManager tears it down on every state change. **This is the gap your ticket fills.** + +### Files to read + +- [`ConnectivityManager.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java) — entire file (~420 lines), focus on lines 123–160 (constructor + listeners), 184–260 (`updateDataSource`), 170–183 (`switchToContext`) +- [`ComponentsImpl.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java) — lines 252–299 (polling builder), 326–348 (streaming builder) +- [`StreamingDataSource.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java) — lines 258–262 (`needsRefresh`) +- [`PlatformState.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PlatformState.java) — entire file (~30 lines, interface only) +- [`AndroidPlatformState.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/AndroidPlatformState.java) — skim for understanding of foreground/network detection + +--- + +## Phase 3: Integration Options for FDv2 + +### The fundamental difference + +In FDv1, `ConnectivityManager` handles mode switching by **destroying and recreating** the data source. The builder decides what type to create based on `clientContext.isInBackground()`. + +In FDv2 (per the CSFDV2 spec and porting plan Part 6), `FDv2DataSource` should handle mode switching **internally** — swapping between synchronizer configurations (e.g., streaming→background polling) without being torn down. ConnectivityManager should only rebuild on **context changes**. + +### What needs to change (at a high level) + +1. **`FDv2DataSource.needsRefresh()`** — Override to return `false` for foreground/background-only changes and `true` for context changes. This prevents ConnectivityManager from tearing it down on lifecycle transitions. + +2. **`FDv2DataSource` must subscribe to `PlatformState`** — It needs to receive foreground/background and network events directly, so it can internally switch its synchronizer configuration (e.g., foreground mode uses streaming sync, background mode uses polling @ 1hr). + +3. **ConnectivityManager doesn't need to change much** — The `needsRefresh` contract already supports this. If `FDv2DataSource.needsRefresh()` returns `false` for background changes, ConnectivityManager will keep it alive. The existing `updateDataSource` logic handles the "no network → stop" and "forced offline → stop" cases generically, which is correct for FDv2 too. + +4. **Named connection modes** (from the CSFDV2 spec) — The mode table (`streaming`, `polling`, `offline`, `one-shot`, `background`) maps each mode name to a set of initializers and synchronizers. The `FDv2DataSource` needs a way to receive "switch to mode X" signals and reconfigure itself accordingly. + +### What you can work on while Todd works on the network layer + +Your ticket is about the *upstream* side — making `ConnectivityManager` and the data source lifecycle work with platform-state-driven mode switching. Concretely: + +- **Passing `PlatformState` to `FDv2DataSource`** so it can self-manage mode transitions +- **The `needsRefresh()` override** on `FDv2DataSource` +- **The debouncing mechanism** (CSFDV2 spec says 1-second debounce window for network/lifecycle/mode events) +- **How `ConnectivityManager` creates `FDv2DataSource`** — what configuration it passes, and ensuring it doesn't interfere with FDv2's self-management +- **Ensuring FDv1 behavior is unchanged** — all the existing `StreamingDataSource`/`PollingDataSource` paths must work exactly as before + +### Key constraint: no breaking changes + +The good news is that all the code you'd be modifying is internal: + +- `ConnectivityManager` is package-private +- `FDv2DataSource` is package-private (not yet wired into production) +- `DataSource.needsRefresh()` is already a `default` method — adding overrides doesn't break anything +- `PlatformState` is an internal interface +- `ClientContextImpl` is internal + +The public API surface (`LDClient`, `LDConfig`, `Components`, `ConnectionInformation`) doesn't need to change for Part 6. + +### Reference code to study + +When ready to go deeper, these are the closest analogues in other SDKs: + +- **js-core** [`CompositeDataSource.ts`](../../js-core/packages/shared/common/src/datasource/CompositeDataSource.ts) — the orchestrator that handles mode switching +- **js-core PR #1135** — [Ryan's mode table types and configuration schema](https://github.com/launchdarkly/js-core/pull/1135) +- **java-core** [`FDv2DataSource.java`](../../java-core/lib/sdk/server/src/main/java/com/launchdarkly/sdk/server/FDv2DataSource.java) — server-side orchestrator (simpler, no foreground/background) + +--- + +## Suggested reading order + +1. [`PlatformState.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/PlatformState.java) — tiny interface, sets the stage +2. [`DataSource.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/subsystems/DataSource.java) — the interface, especially `needsRefresh` Javadoc +3. [`ConnectivityManager.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java) — the heart of the matter, read top to bottom +4. [`StreamingDataSource.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StreamingDataSource.java) lines 258–262 and [`ComponentsImpl.java`](../launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ComponentsImpl.java) lines 326–348 — how FDv1 mode switching works today +5. `FDv2DataSource.java` on Todd's branch (`git show origin/ta/SDK-1817/composite-src-pt2:launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java`) — the orchestrator that needs platform state awareness +6. [`.cursor/rules/fdv2-client-side-spec.mdc`](../.cursor/rules/fdv2-client-side-spec.mdc) — the target behavior from the CSFDV2 spec + +--- + +## Key classes at a glance + +| Class | Visibility | Role | Changes needed? | +|---|---|---|---| +| `LDClient` | **public** | Entry point, delegates to ConnectivityManager | No | +| `LDConfig` | **public** | User config, holds `ComponentConfigurer` | No (for Part 6) | +| `Components` | **public** | Factory methods (`streamingDataSource()`, etc.) | No (for Part 6) | +| `ConnectivityManager` | package-private | Data source lifecycle, platform state reactions | Minor — ensure FDv2 path works with `needsRefresh=false` | +| `PlatformState` | package-private | Interface for foreground/network state | No | +| `AndroidPlatformState` | package-private | Android implementation of PlatformState | No | +| `DataSource` | **public interface** | `start()`, `stop()`, `needsRefresh()` | No | +| `StreamingDataSource` | package-private | FDv1 streaming | No | +| `PollingDataSource` | package-private | FDv1 polling | No | +| `FDv2DataSource` | package-private | FDv2 orchestrator (Todd's branch) | Yes — `needsRefresh()` override, PlatformState subscription, mode switching | +| `ClientContextImpl` | package-private | Internal context with PlatformState | Possibly — may need to pass PlatformState to FDv2DataSource | +| `ConnectionInformation` | **public** | Connection status reporting | No | + +--- + +## ConnectivityManager field reference + +For quick reference, these are the fields in `ConnectivityManager` (lines 50–74): + +| Field | Type | Purpose | +|---|---|---| +| `baseClientContext` | `ClientContext` | Base client context | +| `platformState` | `PlatformState` | Foreground/background and network state | +| `dataSourceFactory` | `ComponentConfigurer` | Builds `DataSource` instances | +| `dataSourceUpdateSink` | `DataSourceUpdateSink` | Sink for flag updates and status | +| `connectionInformation` | `ConnectionInformationState` | Connection mode and last success/failure | +| `environmentStore` | `PerEnvironmentData` | Per-environment store | +| `eventProcessor` | `EventProcessor` | Event processor | +| `foregroundListener` | `ForegroundChangeListener` | Foreground/background listener | +| `connectivityChangeListener` | `ConnectivityChangeListener` | Network listener | +| `taskExecutor` | `TaskExecutor` | Task scheduling | +| `backgroundUpdatingDisabled` | `boolean` | From `config.isDisableBackgroundPolling()` | +| `statusListeners` | `List>` | Status listeners | +| `forcedOffline` | `AtomicBoolean` | User-set offline | +| `started` | `AtomicBoolean` | Whether `startUp()` has run | +| `closed` | `AtomicBoolean` | Whether `shutDown()` has run | +| `currentDataSource` | `AtomicReference` | Active data source | +| `currentContext` | `AtomicReference` | Current evaluation context | +| `previouslyInBackground` | `AtomicReference` | Previous foreground/background state | +| `initialized` | `volatile boolean` | Whether initial data load completed | + +--- + +## FDv2DataSource field reference (Todd's branch) + +| Field | Type | Purpose | +|---|---|---| +| `evaluationContext` | `LDContext` | Context for evaluations | +| `dataSourceUpdateSink` | `DataSourceUpdateSinkV2` | Applies change sets and status | +| `sourceManager` | `SourceManager` | Manages initializer/synchronizer list | +| `fallbackTimeoutSeconds` | `long` | Default 120s — INTERRUPTED → try next sync | +| `recoveryTimeoutSeconds` | `long` | Default 300s — non-prime → retry prime | +| `sharedExecutor` | `ScheduledExecutorService` | For condition timers | +| `started`, `startCompleted`, `stopped` | `AtomicBoolean` | Lifecycle state | + +**Notable absence:** No `PlatformState`, no foreground/background awareness, no mode table. These are the things your ticket adds. + +--- + +## Type location reference (updated March 2026) + +FDv2 shared types have been moved from local Android SDK code to `launchdarkly-java-sdk-internal:1.9.0` in the `com.launchdarkly.sdk.fdv2` package: + +| Type | Package | Notes | +|---|---|---| +| `ChangeSet` | `com.launchdarkly.sdk.fdv2` | Now generic; Android uses `ChangeSet>` | +| `ChangeSetType` | `com.launchdarkly.sdk.fdv2` | Enum: `Full`, `Partial`, `None` | +| `Selector` | `com.launchdarkly.sdk.fdv2` | Was `com.launchdarkly.sdk.internal.fdv2.sources.Selector` | +| `SourceResultType` | `com.launchdarkly.sdk.fdv2` | Enum: `CHANGE_SET`, `STATUS` | +| `SourceSignal` | `com.launchdarkly.sdk.fdv2` | Enum: `INTERRUPTED`, `TERMINAL_ERROR`, `SHUTDOWN`, `GOODBYE` | +| `FDv2SourceResult` | `com.launchdarkly.sdk.android.subsystems` | Remains local; wraps the shared enums | +| `DataSourceState` | `com.launchdarkly.sdk.android.subsystems` | Local enum: `INITIALIZING`, `VALID`, `INTERRUPTED`, `OFF` | diff --git a/docs/SDK-1956-switch-mode-approach-comparison.md b/docs/SDK-1956-switch-mode-approach-comparison.md new file mode 100644 index 00000000..69005b1e --- /dev/null +++ b/docs/SDK-1956-switch-mode-approach-comparison.md @@ -0,0 +1,200 @@ +# SDK-1956: `switchMode()` Approach Comparison + +## Background + +`FDv2DataSource` needs a mechanism to change its active synchronizers at runtime without re-running initializers. This document compares two approaches for the `switchMode()` method and proposes a hybrid. + +### Spec Requirement: CONNMODE 2.0.1 + +> When switching modes after initialization is complete, the SDK **MUST** only transition to the new mode's synchronizer list. The SDK **MUST NOT** re-run the initializer chain. +> +> Initializers run during initial startup and when `identify` is called with a new context — they are responsible for obtaining a first full data set. Once the SDK is initialized for a context, mode transitions only change which synchronizers are active. For example, switching from `"streaming"` to `"background"` stops the streaming and polling synchronizers and starts the background polling synchronizer; it does not re-run the cache or polling initializers. + +This requirement means a simple teardown/rebuild of `FDv2DataSource` on mode change is not viable — it would re-run initializers, violating the spec. + +### Spec Requirement: CONNMODE 2.0.2 + +> When `identify` is called with a new context, the SDK **MUST** run the initializer chain of the currently active mode for that new context, followed by activating its synchronizers. + +This is the *only* case where initializers re-run after first startup. The current mode is retained across `identify` calls. + +--- + +## Approach 1: `switchMode(ConnectionMode)` — pass the enum + +FDv2DataSource holds the full mode table internally (`Map`). When it receives `switchMode(BACKGROUND)`, it looks up the definition in its own table and swaps synchronizers. + +**Construction:** +```java +FDv2DataSource( + evaluationContext, + modeTable, // Map + startingMode, // ConnectionMode.STREAMING + dataSourceUpdateSink, + sharedExecutor, + logger +) +``` + +The builder resolves all `ComponentConfigurer` → `DataSourceFactory` conversions upfront (by applying `ClientContext`) and hands the complete resolved table to FDv2DataSource. + +## Approach 2: `switchMode(ResolvedModeDefinition)` — pass the definition + +FDv2DataSource has no mode table. It receives "here are your new synchronizer factories" each time. The mode table lives externally — in ConnectivityManager or a resolver object. + +**Construction:** +```java +FDv2DataSource( + evaluationContext, + initialInitializers, // List> + initialSynchronizers, // List> + dataSourceUpdateSink, + sharedExecutor, + logger +) +``` + +This is essentially Todd's existing constructor signature. `switchMode()` just receives new factory lists. + +--- + +## Comparison + +| Dimension | Approach 1 (enum) | Approach 2 (definition) | +|-----------|-------------------|------------------------| +| **API clarity** | Very clean — one enum value, no internals exposed | Caller must construct/lookup ModeDefinition objects | +| **Encapsulation** | ConnectivityManager only knows about named modes; FDv2 internals stay inside FDv2DataSource | FDv2 concepts (factory lists, initializer/synchronizer structure) leak into ConnectivityManager or a resolver | +| **Spec alignment** | Matches the spec's model — modes are named concepts that the data system resolves (CSFDV2 Section 5.3) | Flattens the spec's layered abstraction; the caller does both resolution and lookup | +| **CONNMODE 2.0.1 compliance** | FDv2DataSource internally enforces "no re-run initializers" — it knows the difference between mode switch and startup | Caller must ensure that only synchronizer factories are passed, not initializers — the constraint is externalized and unenforceable by FDv2DataSource | +| **CONNMODE 2.0.2 (identify)** | On identify, FDv2DataSource can re-run the current mode's initializers because it holds the full mode table with both initializer and synchronizer entries | Caller must pass both initializers and synchronizers for the current mode during identify — FDv2DataSource can't do this on its own | +| **Logging/diagnostics** | Easy: "switching to BACKGROUND" | Harder to log meaningfully — need to track mode name separately | +| **Validation** | FDv2DataSource validates the mode exists in its table at call time | No validation possible — takes whatever it's given | +| **Current mode tracking** | FDv2DataSource knows its current `ConnectionMode` — useful for `needsRefresh()` decisions, diagnostics, CONNMODE 3.5.3 no-op check ("if desired == actual, no action") | FDv2DataSource has no concept of "what mode am I in" | +| **Backward compatibility** | New constructor — Todd's tests need a compatibility constructor | Constructor matches Todd's existing signature closely | +| **Flexibility** | Constrained to the predefined mode table; custom ad-hoc definitions can't be passed | Fully flexible — any factory list can be passed at any time | +| **Who holds the mode table** | FDv2DataSource (natural home — it's the component that uses the definitions) | Must live externally — ConnectivityManager or a separate resolver, adding coordination | + +### Memory + +Not meaningfully different. The mode table holds 5 entries, each containing a few lambda references (factories that close over shared dependencies like `HttpProperties`, `SelectorSource`). The actual `Initializer`/`Synchronizer` objects are only created when `build()` is called on the factory. Both approaches hold the same total objects — the question is just where they live. + +### Benefits specific to the enum + +1. **No-op detection:** CONNMODE 3.5.3 says "take the minimal set of actions to reconcile the current actual state with the desired state." With an enum, `switchMode()` can trivially check `if (newMode == currentMode) return;`. With a definition, you'd need reference equality or a separate mode tracker. + +2. **`needsRefresh()` awareness:** FDv2DataSource can inspect its current mode to make decisions. For example, knowing it's in `OFFLINE` mode is different from knowing it has zero synchronizers — the former is semantic, the latter is incidental. + +3. **Identify support (CONNMODE 2.0.2):** When `identify()` triggers a data system restart, FDv2DataSource needs to know *which mode* to restart with (run that mode's initializers for the new context). With the enum and mode table, it has this information. With Approach 2, the caller must supply both initializer and synchronizer lists. + +4. **Diagnostics:** Connection status reporting, debug logging, and future telemetry all benefit from knowing the current named mode rather than an opaque list of factories. + +--- + +--- + +## Network Availability and "Pause/Resume" via Mode Switching + +### The spec's language + +CONNMODE 3.2.5 says: + +> Network availability changes **MUST NOT** trigger a mode change. When the network becomes unavailable, active synchronizers and initializers **MUST** be paused (no new connection attempts). When the network becomes available, they **MUST** be resumed. + +This language uses "pause/resume" and explicitly says network loss is NOT a mode change. This raises the question: do we need separate `pause()` / `resume()` methods on `FDv2DataSource`? + +### What js-core actually implements + +**No.** Across all of Ryan's FDv2 branches — including the latest (`rlamb/sdk-1926/connection-mode-switching-definition`) — there is no `pause()` or `resume()` anywhere. The word "pause" only appears in documentation comments, never in code interfaces. + +Network loss is handled entirely through the mode resolution table: + +```typescript +const MOBILE_TRANSITION_TABLE: ModeResolutionTable = [ + { conditions: { networkAvailable: false }, mode: 'offline' }, + { conditions: { lifecycle: 'background' }, mode: { configured: 'background' } }, + { conditions: { lifecycle: 'foreground' }, mode: { configured: 'foreground' } }, +]; +``` + +Network goes down → table resolves to `'offline'`. Network comes back → table re-evaluates based on lifecycle → resolves to the appropriate mode. + +### Why `switchMode(OFFLINE)` ≡ `pause()` + +The OFFLINE mode definition is: +- Initializers: `[cache]` +- Synchronizers: `[]` (none) + +So `switchMode(OFFLINE)` does exactly what "pause" means: +1. Stop all current synchronizers (no more network requests) +2. Do NOT re-run initializers (per CONNMODE 2.0.1) +3. FDv2DataSource remains alive and initialized +4. The current mode is now OFFLINE — tracked by the enum + +And `switchMode(previousMode)` ≡ `resume()`: +1. Start the previous mode's synchronizers +2. Do NOT re-run initializers (per CONNMODE 2.0.1) +3. FDv2DataSource was never torn down, so all initialization state is preserved + +The spec's "pause/resume" language describes the **behavioral outcome**, but the **mechanism** is mode switching. This is exactly what js-core implements. + +### How `start()` / `stop()` differ from this + +FDv2DataSource already has `start()` and `stop()` — these are **full lifecycle** operations, fundamentally different from mode switching: + +| | `start()` | `stop()` | `switchMode(OFFLINE)` | +|--|-----------|----------|----------------------| +| **Purpose** | Full lifecycle startup | Full lifecycle teardown | Pause synchronizers | +| **Initializers** | Runs the full chain | N/A | Does NOT re-run (CONNMODE 2.0.1) | +| **Synchronizers** | Starts loop after init | Closes SourceManager | Stops current, starts OFFLINE's (none) | +| **State after** | Running, initialized | Dead — no recovery | Alive, initialized, paused | +| **Finality** | Called once per lifetime | Terminal — must construct new instance | Reversible — `switchMode(X)` resumes | + +`stop()` sets `stopped = true` — the data source is dead. A new `FDv2DataSource` must be constructed to restart. This is why `ConnectivityManager` does a full teardown/rebuild on context switch. + +`switchMode(OFFLINE)` preserves the data source in a running but idle state. The mode resolution table naturally handles the transition back to an active mode when conditions change. + +### Edge case: background + network loss + +Consider the sequence: app is STREAMING → network drops → app backgrounds. + +With mode switching via the resolution table, the debounced state resolves as: +- `networkAvailable: false` → first match → `OFFLINE` + +When the network returns while still backgrounded: +- `networkAvailable: true, lifecycle: background` → second match → configured background mode (e.g., `BACKGROUND`) + +When the app foregrounds with network: +- `networkAvailable: true, lifecycle: foreground` → third match → configured foreground mode (e.g., `STREAMING`) + +The mode resolution table handles all combinations of network + lifecycle state correctly through a single `switchMode()` call. No separate pause/resume tracking is needed. + +### Conclusion + +Separate `pause()` / `resume()` methods are unnecessary. `switchMode(ConnectionMode)` handles network state changes naturally through the mode resolution table, achieving the behavioral outcome the spec describes as "pause/resume" without requiring a separate API surface. + +--- + +## Recommendation + +**Approach 1 (enum)** is the stronger choice. It aligns with the spec's layered architecture (CSFDV2 5.3 defines modes as named concepts), keeps FDv2 internals encapsulated, enables self-enforcement of CONNMODE 2.0.1 (no initializer re-run), and provides the semantic awareness needed for no-op detection, identify restarts, and diagnostics. It also naturally handles the spec's "pause/resume" requirement for network changes via `switchMode(OFFLINE)` / `switchMode(previousMode)`, without requiring additional API surface. + +--- + +## Spec References + +All spec references are from the draft branch `rlamb/client-side-fdv2` in `launchdarkly/sdk-specs`. + +- **CONNMODE 2.0.1:** Mode switch → synchronizer-only transition; MUST NOT re-run initializers +- **CONNMODE 2.0.2:** Identify → re-run current mode's initializers for new context +- **CONNMODE 3.2.5:** Network availability changes MUST NOT trigger a mode change; synchronizers MUST be paused/resumed +- **CONNMODE 3.5.3:** Debounce resolution → take minimal action to reconcile desired vs actual state +- **CONNMODE 3.5.4:** Debounce window SHOULD be 1 second +- **CSFDV2 5.3:** Named connection mode definitions (streaming, polling, offline, one-shot, background) +- **CSFDV2 6.1.1:** Current mode retained across identify calls + +## js-core References + +- **Mode resolution table:** `rlamb/sdk-1926/connection-mode-switching-definition` branch — `ModeResolver.ts`, `ModeResolution.ts` +- **MOBILE_TRANSITION_TABLE:** Maps `{ networkAvailable: false } → 'offline'` — network loss handled as mode switch, not separate pause +- **FDv2DataSource deletion:** This branch deletes `FDv2DataSource.ts`, `SourceManager.ts`, `Conditions.ts` — Ryan is restructuring the orchestrator around mode resolution +- **No pause/resume API exists** anywhere in js-core's FDv2 datasource code From 56dfb7ffbb6295dd0adde6c31da3f4d65de3b6d6 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Mon, 9 Mar 2026 12:21:46 -0700 Subject: [PATCH 02/14] feat: Add FDv2 connection mode types and mode resolution table Introduces the core types for FDv2 mode resolution (CONNMODE spec): - ConnectionMode: enum for streaming, polling, offline, one-shot, background - ModeDefinition: initializer/synchronizer lists per mode with stubbed configurers - ModeState: platform state snapshot (foreground, networkAvailable) - ModeResolutionEntry: condition + mode pair for resolution table entries - ModeResolutionTable: ordered first-match-wins resolver with MOBILE default table - ModeAware: interface for DataSources that support runtime switchMode() All types are package-private. No changes to existing code. --- .../sdk/android/ConnectionMode.java | 17 ++++ .../launchdarkly/sdk/android/ModeAware.java | 28 ++++++ .../sdk/android/ModeDefinition.java | 85 +++++++++++++++++ .../sdk/android/ModeResolutionEntry.java | 48 ++++++++++ .../sdk/android/ModeResolutionTable.java | 65 +++++++++++++ .../launchdarkly/sdk/android/ModeState.java | 30 ++++++ .../sdk/android/ModeResolutionTableTest.java | 94 +++++++++++++++++++ 7 files changed, 367 insertions(+) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java new file mode 100644 index 00000000..789b4efb --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -0,0 +1,17 @@ +package com.launchdarkly.sdk.android; + +/** + * Named connection modes for the FDv2 data system. Each mode maps to a + * {@link ModeDefinition} that specifies which initializers and synchronizers to run. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + */ +enum ConnectionMode { + STREAMING, + POLLING, + OFFLINE, + ONE_SHOT, + BACKGROUND +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java new file mode 100644 index 00000000..14825583 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java @@ -0,0 +1,28 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.DataSource; + +/** + * A {@link DataSource} that supports runtime connection mode switching. + *

+ * {@link ConnectivityManager} checks {@code instanceof ModeAware} to decide + * whether to use mode resolution (FDv2) or legacy teardown/rebuild behavior (FDv1). + *

+ * Package-private — not part of the public SDK API. + * + * @see ConnectionMode + * @see ModeResolutionTable + */ +interface ModeAware extends DataSource { + + /** + * Switches the data source to the specified connection mode. The implementation + * stops the current synchronizers and starts the new mode's synchronizers without + * re-running initializers (per CONNMODE spec 2.0.1). + * + * @param newMode the target connection mode + */ + void switchMode(@NonNull ConnectionMode newMode); +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java new file mode 100644 index 00000000..70c0202b --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -0,0 +1,85 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.Arrays; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; + +/** + * Defines the initializer and synchronizer pipelines for a {@link ConnectionMode}. + *

+ * Each mode in the {@link #DEFAULT_MODE_TABLE} maps to a {@code ModeDefinition} that + * describes which data source components to create. At build time, + * {@code FDv2DataSourceBuilder} resolves each {@link ComponentConfigurer} into a + * {@link FDv2DataSource.DataSourceFactory} by applying the {@code ClientContext}. + *

+ * The configurers in {@link #DEFAULT_MODE_TABLE} are currently stubbed (return null). + * Real {@link ComponentConfigurer} implementations will be wired in when + * {@code FDv2DataSourceBuilder} is created. + *

+ * Package-private — not part of the public SDK API. + */ +final class ModeDefinition { + + // Stubbed configurer — will be replaced with real ComponentConfigurer implementations + // in FDv2DataSourceBuilder when concrete types are wired up. + private static final ComponentConfigurer STUB_INITIALIZER = clientContext -> null; + private static final ComponentConfigurer STUB_SYNCHRONIZER = clientContext -> null; + + static final Map DEFAULT_MODE_TABLE; + + static { + Map table = new EnumMap<>(ConnectionMode.class); + // Initializer/synchronizer lists per CONNMODE spec and js-core ConnectionModeConfig.ts. + // Stubs will be replaced with real factories (cache, polling, streaming) in FDv2DataSourceBuilder. + table.put(ConnectionMode.STREAMING, new ModeDefinition( + Arrays.asList(STUB_INITIALIZER, STUB_INITIALIZER), // cache, polling + Arrays.asList(STUB_SYNCHRONIZER, STUB_SYNCHRONIZER) // streaming, polling + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.singletonList(STUB_INITIALIZER), // cache + Collections.singletonList(STUB_SYNCHRONIZER) // polling + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + Collections.singletonList(STUB_INITIALIZER), // cache + Collections.>emptyList() + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + Arrays.asList(STUB_INITIALIZER, STUB_INITIALIZER, STUB_INITIALIZER), // cache, polling, streaming + Collections.>emptyList() + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.singletonList(STUB_INITIALIZER), // cache + Collections.singletonList(STUB_SYNCHRONIZER) // polling (LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS) + )); + DEFAULT_MODE_TABLE = Collections.unmodifiableMap(table); + } + + private final List> initializers; + private final List> synchronizers; + + ModeDefinition( + @NonNull List> initializers, + @NonNull List> synchronizers + ) { + this.initializers = Collections.unmodifiableList(initializers); + this.synchronizers = Collections.unmodifiableList(synchronizers); + } + + @NonNull + List> getInitializers() { + return initializers; + } + + @NonNull + List> getSynchronizers() { + return synchronizers; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java new file mode 100644 index 00000000..9b3a1c49 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java @@ -0,0 +1,48 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +/** + * A single entry in a {@link ModeResolutionTable}. Pairs a condition with a + * target {@link ConnectionMode}. If {@link Condition#test(ModeState)} returns + * {@code true} for a given {@link ModeState}, this entry's {@code mode} is the + * resolved result. + *

+ * When user-configurable mode selection is added, {@code mode} can be replaced + * with a resolver function to support indirection (e.g., returning a + * user-configured foreground mode from {@code ModeState}). + *

+ * Package-private — not part of the public SDK API. + */ +final class ModeResolutionEntry { + + /** + * Functional interface for evaluating whether a {@link ModeResolutionEntry} + * matches a given {@link ModeState}. Defined here to avoid a dependency on + * {@code java.util.function.Predicate} (requires API 24+; SDK minimum is 21). + */ + interface Condition { + boolean test(@NonNull ModeState state); + } + + private final Condition conditions; + private final ConnectionMode mode; + + ModeResolutionEntry( + @NonNull Condition conditions, + @NonNull ConnectionMode mode + ) { + this.conditions = conditions; + this.mode = mode; + } + + @NonNull + Condition getConditions() { + return conditions; + } + + @NonNull + ConnectionMode getMode() { + return mode; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java new file mode 100644 index 00000000..f6b1817a --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java @@ -0,0 +1,65 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * An ordered list of {@link ModeResolutionEntry} values that maps a {@link ModeState} + * to a {@link ConnectionMode}. The first entry whose condition matches wins. + *

+ * The {@link #MOBILE} constant defines the Android default resolution table: + *

    + *
  1. No network → {@link ConnectionMode#OFFLINE}
  2. + *
  3. Background → {@link ConnectionMode#BACKGROUND}
  4. + *
  5. Foreground → {@link ConnectionMode#STREAMING}
  6. + *
+ *

+ * Package-private — not part of the public SDK API. + * + * @see ModeState + * @see ModeResolutionEntry + */ +final class ModeResolutionTable { + + static final ModeResolutionTable MOBILE = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry( + state -> !state.isNetworkAvailable(), + ConnectionMode.OFFLINE), + new ModeResolutionEntry( + state -> !state.isForeground(), + ConnectionMode.BACKGROUND), + new ModeResolutionEntry( + state -> true, + ConnectionMode.STREAMING) + )); + + private final List entries; + + ModeResolutionTable(@NonNull List entries) { + this.entries = Collections.unmodifiableList(entries); + } + + /** + * Evaluates the table against the given state and returns the first matching mode. + * + * @param state the current platform state + * @return the resolved {@link ConnectionMode} + * @throws IllegalStateException if no entry matches (should not happen with a + * well-formed table that has a catch-all final entry) + */ + @NonNull + ConnectionMode resolve(@NonNull ModeState state) { + for (ModeResolutionEntry entry : entries) { + if (entry.getConditions().test(state)) { + return entry.getMode(); + } + } + throw new IllegalStateException( + "ModeResolutionTable has no matching entry for state: " + + "foreground=" + state.isForeground() + ", networkAvailable=" + state.isNetworkAvailable() + ); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java new file mode 100644 index 00000000..f3942927 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java @@ -0,0 +1,30 @@ +package com.launchdarkly.sdk.android; + +/** + * Snapshot of platform state used as input to {@link ModeResolutionTable#resolve(ModeState)}. + *

+ * In this initial implementation, {@code ModeState} carries only platform state with + * hardcoded Android defaults for foreground/background modes. When user-configurable + * mode selection is added (CONNMODE 2.2.2), {@code foregroundMode} and + * {@code backgroundMode} fields will be introduced here. + *

+ * Package-private — not part of the public SDK API. + */ +final class ModeState { + + private final boolean foreground; + private final boolean networkAvailable; + + ModeState(boolean foreground, boolean networkAvailable) { + this.foreground = foreground; + this.networkAvailable = networkAvailable; + } + + boolean isForeground() { + return foreground; + } + + boolean isNetworkAvailable() { + return networkAvailable; + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java new file mode 100644 index 00000000..2beec0fc --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -0,0 +1,94 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; + +import org.junit.Test; + +import java.util.Arrays; +import java.util.Collections; + +/** + * Unit tests for {@link ModeResolutionTable} and the {@link ModeResolutionTable#MOBILE} constant. + */ +public class ModeResolutionTableTest { + + // ==== MOBILE table — standard Android resolution ==== + + @Test + public void mobile_foregroundWithNetwork_resolvesToStreaming() { + ModeState state = new ModeState(true, true); + assertEquals(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundWithNetwork_resolvesToBackground() { + ModeState state = new ModeState(false, true); + assertEquals(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_foregroundNoNetwork_resolvesToOffline() { + ModeState state = new ModeState(true, false); + assertEquals(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundNoNetwork_resolvesToOffline() { + ModeState state = new ModeState(false, false); + assertEquals(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + // ==== resolve() — first match wins ==== + + @Test + public void resolve_firstMatchWins_evenIfLaterEntryAlsoMatches() { + ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry(state -> true, ConnectionMode.POLLING), + new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) + )); + assertEquals(ConnectionMode.POLLING, table.resolve(new ModeState(true, true))); + } + + @Test + public void resolve_skipsNonMatchingEntries() { + ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry(state -> false, ConnectionMode.POLLING), + new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) + )); + assertEquals(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true))); + } + + @Test + public void resolve_singleEntry() { + ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( + new ModeResolutionEntry(state -> true, ConnectionMode.OFFLINE) + )); + assertEquals(ConnectionMode.OFFLINE, table.resolve(new ModeState(false, false))); + } + + @Test(expected = IllegalStateException.class) + public void resolve_noMatchingEntry_throws() { + ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( + new ModeResolutionEntry(state -> false, ConnectionMode.OFFLINE) + )); + table.resolve(new ModeState(true, true)); + } + + @Test(expected = IllegalStateException.class) + public void resolve_emptyTable_throws() { + ModeResolutionTable table = new ModeResolutionTable( + Collections.emptyList() + ); + table.resolve(new ModeState(true, true)); + } + + // ==== Network takes priority over lifecycle ==== + + @Test + public void mobile_networkUnavailable_alwaysResolvesToOffline_regardlessOfForeground() { + assertEquals(ConnectionMode.OFFLINE, + ModeResolutionTable.MOBILE.resolve(new ModeState(true, false))); + assertEquals(ConnectionMode.OFFLINE, + ModeResolutionTable.MOBILE.resolve(new ModeState(false, false))); + } +} From 487a97b8bf1e19c913c4f214a6fb26b34c598509 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 10 Mar 2026 12:01:23 -0700 Subject: [PATCH 03/14] feat: Implement switchMode() on FDv2DataSource Add ResolvedModeDefinition and mode-table constructors so FDv2DataSource can look up initializer/synchronizer factories per ConnectionMode. switchMode() tears down the current SourceManager, builds a new one with the target mode's synchronizers (skipping initializers per CONNMODE 2.0.1), and schedules execution on the background executor. runSynchronizers() now takes an explicit SourceManager parameter to prevent a race where the finally block could close a SourceManager swapped in by a concurrent switchMode(). --- .../sdk/android/FDv2DataSource.java | 196 +++++++++++++- .../sdk/android/FDv2DataSourceTest.java | 252 ++++++++++++++++++ 2 files changed, 435 insertions(+), 13 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 82406356..b52b15da 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -7,7 +7,6 @@ import com.launchdarkly.sdk.android.subsystems.Callback; import com.launchdarkly.sdk.android.DataModel; import com.launchdarkly.sdk.fdv2.ChangeSet; -import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.DataSourceState; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; @@ -15,9 +14,10 @@ import com.launchdarkly.sdk.android.subsystems.Synchronizer; import java.util.ArrayList; -import java.util.Map; import java.util.Collections; +import java.util.EnumMap; import java.util.List; +import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -30,7 +30,7 @@ * switch to next synchronizer) and recovery (when on non-prime synchronizer, try * to return to the first after timeout). */ -final class FDv2DataSource implements DataSource { +final class FDv2DataSource implements ModeAware { /** * Factory for creating Initializer or Synchronizer instances. @@ -39,10 +39,38 @@ public interface DataSourceFactory { T build(); } + /** + * A resolved mode definition holding factories that are ready to use (already bound to + * their ClientContext). Produced by FDv2DataSourceBuilder from ComponentConfigurer entries. + */ + static final class ResolvedModeDefinition { + private final List> initializers; + private final List> synchronizers; + + ResolvedModeDefinition( + @NonNull List> initializers, + @NonNull List> synchronizers + ) { + this.initializers = Collections.unmodifiableList(new ArrayList<>(initializers)); + this.synchronizers = Collections.unmodifiableList(new ArrayList<>(synchronizers)); + } + + @NonNull + List> getInitializers() { + return initializers; + } + + @NonNull + List> getSynchronizers() { + return synchronizers; + } + } + private final LDLogger logger; private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; - private final SourceManager sourceManager; + private final Map modeTable; + private volatile SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; private final ScheduledExecutorService sharedExecutor; @@ -101,6 +129,7 @@ public interface DataSourceFactory { this.evaluationContext = evaluationContext; this.dataSourceUpdateSink = dataSourceUpdateSink; this.logger = logger; + this.modeTable = null; List synchronizerFactoriesWithState = new ArrayList<>(); for (DataSourceFactory factory : synchronizers) { synchronizerFactoriesWithState.add(new SynchronizerFactoryWithState(factory)); @@ -111,6 +140,74 @@ public interface DataSourceFactory { this.sharedExecutor = sharedExecutor; } + /** + * Mode-aware convenience constructor using default fallback and recovery timeouts. + * + * @param evaluationContext the context to evaluate flags for + * @param modeTable resolved mode definitions keyed by ConnectionMode + * @param startingMode the initial connection mode + * @param dataSourceUpdateSink sink to apply changesets and status updates to + * @param sharedExecutor executor used for internal background tasks + * @param logger logger + */ + FDv2DataSource( + @NonNull LDContext evaluationContext, + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode, + @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, + @NonNull ScheduledExecutorService sharedExecutor, + @NonNull LDLogger logger + ) { + this(evaluationContext, modeTable, startingMode, dataSourceUpdateSink, sharedExecutor, logger, + FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, + FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); + } + + /** + * Mode-aware constructor. The mode table maps each {@link ConnectionMode} to a + * {@link ResolvedModeDefinition} containing pre-built factories. The starting mode + * determines the initial set of initializers and synchronizers. + * + * @param evaluationContext the context to evaluate flags for + * @param modeTable resolved mode definitions keyed by ConnectionMode + * @param startingMode the initial connection mode + * @param dataSourceUpdateSink sink to apply changesets and status updates to + * @param sharedExecutor executor used for internal background tasks; must have + * at least 2 threads + * @param logger logger + * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back + * @param recoveryTimeoutSeconds seconds before attempting to recover to the primary + * synchronizer + */ + FDv2DataSource( + @NonNull LDContext evaluationContext, + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode, + @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, + @NonNull ScheduledExecutorService sharedExecutor, + @NonNull LDLogger logger, + long fallbackTimeoutSeconds, + long recoveryTimeoutSeconds + ) { + this.evaluationContext = evaluationContext; + this.dataSourceUpdateSink = dataSourceUpdateSink; + this.logger = logger; + this.modeTable = Collections.unmodifiableMap(new EnumMap<>(modeTable)); + this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; + this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; + this.sharedExecutor = sharedExecutor; + + ResolvedModeDefinition startDef = modeTable.get(startingMode); + if (startDef == null) { + throw new IllegalArgumentException("No mode definition for starting mode: " + startingMode); + } + List syncFactories = new ArrayList<>(); + for (DataSourceFactory factory : startDef.getSynchronizers()) { + syncFactories.add(new SynchronizerFactoryWithState(factory)); + } + this.sourceManager = new SourceManager(syncFactories, new ArrayList<>(startDef.getInitializers())); + } + @Override public void start(@NonNull Callback resultCallback) { synchronized (startResultLock) { @@ -159,8 +256,13 @@ public void start(@NonNull Callback resultCallback) { return; } - runSynchronizers(context, dataSourceUpdateSink); - maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); + SourceManager sm = sourceManager; + runSynchronizers(sm, context, dataSourceUpdateSink); + // Only report exhaustion if the SourceManager was NOT replaced by a + // concurrent switchMode() call; a mode switch is not an error. + if (sourceManager == sm) { + maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); + } tryCompleteStart(false, null); } catch (Throwable t) { logger.warn("FDv2DataSource error: {}", t.toString()); @@ -215,6 +317,73 @@ public void stop(@NonNull Callback completionCallback) { completionCallback.onSuccess(null); } + @Override + public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { + // Mode-aware data sources handle foreground/background transitions via switchMode(), + // so only a context change requires a full teardown/rebuild (to re-run initializers). + return !newEvaluationContext.equals(evaluationContext); + } + + /** + * Switches to a new connection mode by tearing down the current synchronizers and + * starting the new mode's synchronizers on the background executor. Initializers are + * NOT re-run (spec CONNMODE 2.0.1). + *

+ * Expected to be called from a single thread (ConnectivityManager's listener). The + * field swap is not atomic; concurrent calls from multiple threads could leave an + * intermediate SourceManager unclosed. + */ + @Override + public void switchMode(@NonNull ConnectionMode newMode) { + if (modeTable == null) { + logger.warn("switchMode({}) called but no mode table configured", newMode); + return; + } + if (stopped.get()) { + return; + } + ResolvedModeDefinition def = modeTable.get(newMode); + if (def == null) { + logger.error("switchMode({}) failed: no definition found", newMode); + return; + } + + // Build new SourceManager with the mode's synchronizer factories. + // Initializers are NOT included — spec 2.0.1: mode switch does not re-run initializers. + List syncFactories = new ArrayList<>(); + for (DataSourceFactory factory : def.getSynchronizers()) { + syncFactories.add(new SynchronizerFactoryWithState(factory)); + } + SourceManager newManager = new SourceManager( + syncFactories, Collections.>emptyList()); + + // Swap the source manager and close the old one to interrupt its active source. + SourceManager oldManager = sourceManager; + sourceManager = newManager; + if (oldManager != null) { + oldManager.close(); + } + + // Run the new mode's synchronizers on the background thread. + LDContext context = evaluationContext; + sharedExecutor.execute(() -> { + try { + if (!newManager.hasAvailableSynchronizers()) { + logger.debug("Mode {} has no synchronizers; data source idle", newMode); + return; + } + runSynchronizers(newManager, context, dataSourceUpdateSink); + // Report exhaustion only if we weren't replaced by another switchMode(). + if (sourceManager == newManager && !stopped.get()) { + maybeReportUnexpectedExhaustion( + "All synchronizers exhausted after mode switch to " + newMode); + } + } catch (Throwable t) { + logger.warn("FDv2DataSource error after mode switch to {}: {}", newMode, t.toString()); + } + }); + } + private void runInitializers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink @@ -295,17 +464,18 @@ private List getConditions(int synchronizerC } private void runSynchronizers( + @NonNull SourceManager sm, @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink ) { try { - Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + Synchronizer synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); while (synchronizer != null) { if (stopped.get()) { return; } - int synchronizerCount = sourceManager.getAvailableSynchronizerCount(); - boolean isPrime = sourceManager.isPrimeSynchronizer(); + int synchronizerCount = sm.getAvailableSynchronizerCount(); + boolean isPrime = sm.isPrimeSynchronizer(); try { boolean running = true; try (FDv2DataSourceConditions.Conditions conditions = @@ -325,7 +495,7 @@ private void runSynchronizers( break; case RECOVERY: logger.debug("The data source is attempting to recover to a higher priority synchronizer."); - sourceManager.resetSourceIndex(); + sm.resetSourceIndex(); break; } running = false; @@ -365,7 +535,7 @@ private void runSynchronizers( case TERMINAL_ERROR: // This synchronizer cannot recover; block it so the outer // loop advances to the next available synchronizer. - sourceManager.blockCurrentSynchronizer(); + sm.blockCurrentSynchronizer(); running = false; sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); break; @@ -391,10 +561,10 @@ private void runSynchronizers( sink.setStatus(DataSourceState.INTERRUPTED, e); return; } - synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); } } finally { - sourceManager.close(); + sm.close(); } } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index ad1e91e3..2d8ba4a9 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -33,6 +33,7 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; +import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1400,6 +1401,257 @@ public void statusTransitionsFromValidToOffWhenAllSynchronizersFail() throws Exc assertNotNull(sink.getLastError()); } + // ============================================================================ + // needsRefresh — ModeAware behavior + // ============================================================================ + + @Test + public void needsRefresh_sameContextDifferentBackground_returnsFalse() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); + + assertFalse(dataSource.needsRefresh(true, CONTEXT)); + assertFalse(dataSource.needsRefresh(false, CONTEXT)); + } + + @Test + public void needsRefresh_differentContext_returnsTrue() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); + + LDContext otherContext = LDContext.create("other-context"); + assertTrue(dataSource.needsRefresh(false, otherContext)); + assertTrue(dataSource.needsRefresh(true, otherContext)); + } + + @Test + public void needsRefresh_differentContextAndBackground_returnsTrue() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); + + LDContext otherContext = LDContext.create("other-context"); + assertTrue(dataSource.needsRefresh(true, otherContext)); + } + + @Test + public void needsRefresh_equalContextInstance_returnsFalse() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + LDContext context = LDContext.create("test-context"); + FDv2DataSource dataSource = new FDv2DataSource( + context, Collections.emptyList(), Collections.emptyList(), + sink, executor, logging.logger); + + LDContext sameValueContext = LDContext.create("test-context"); + assertFalse(dataSource.needsRefresh(false, sameValueContext)); + assertFalse(dataSource.needsRefresh(true, sameValueContext)); + } + + // ============================================================================ + // switchMode — ModeAware behavior + // ============================================================================ + + private FDv2DataSource buildModeAwareDataSource( + MockComponents.MockDataSourceUpdateSink sink, + Map modeTable, + ConnectionMode startingMode) { + return new FDv2DataSource( + CONTEXT, modeTable, startingMode, + sink, executor, logging.logger); + } + + @Test + public void switchMode_activatesNewModeSynchronizer() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + CountDownLatch pollingCreated = new CountDownLatch(1); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> { + pollingCreated.countDown(); + return new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false))); + }) + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + + dataSource.switchMode(ConnectionMode.POLLING); + assertTrue(pollingCreated.await(2, TimeUnit.SECONDS)); + + // Both streaming and polling changesets should have been applied + sink.awaitApplyCount(2, 2, TimeUnit.SECONDS); + assertEquals(2, sink.getApplyCount()); + + stopDataSource(dataSource); + } + + @Test + public void switchMode_doesNotReRunInitializers() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + AtomicInteger initializerBuildCount = new AtomicInteger(0); + CountDownLatch pollingSyncCreated = new CountDownLatch(1); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.singletonList(() -> { + initializerBuildCount.incrementAndGet(); + return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); + }), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> { + pollingSyncCreated.countDown(); + return new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false))); + }) + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + assertEquals(1, initializerBuildCount.get()); + + dataSource.switchMode(ConnectionMode.POLLING); + assertTrue(pollingSyncCreated.await(2, TimeUnit.SECONDS)); + + // Initializer count should still be 1 — mode switch skips initializers (spec 2.0.1) + assertEquals(1, initializerBuildCount.get()); + + stopDataSource(dataSource); + } + + @Test + public void switchMode_toModeWithNoSynchronizers_doesNotCrash() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + modeTable.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.emptyList() + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + + dataSource.switchMode(ConnectionMode.OFFLINE); + Thread.sleep(200); // allow mode switch to complete + + stopDataSource(dataSource); + } + + @Test + public void switchMode_fromOfflineBackToStreaming_resumesSynchronizers() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + AtomicInteger streamingSyncBuildCount = new AtomicInteger(0); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> { + streamingSyncBuildCount.incrementAndGet(); + return new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false))); + }) + )); + modeTable.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.emptyList() + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + assertEquals(1, streamingSyncBuildCount.get()); + + // Switch to offline — no synchronizers + dataSource.switchMode(ConnectionMode.OFFLINE); + Thread.sleep(200); + + // Switch back to streaming — new synchronizer should be created + dataSource.switchMode(ConnectionMode.STREAMING); + Thread.sleep(500); + + // A new streaming sync was created (2 total: one from start, one from mode switch back) + assertEquals(2, streamingSyncBuildCount.get()); + + // Both streaming changesets (initial + resumed) should have been applied + sink.awaitApplyCount(2, 2, TimeUnit.SECONDS); + assertEquals(2, sink.getApplyCount()); + + stopDataSource(dataSource); + } + + @Test + public void switchMode_withNoModeTable_isNoOp() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + // Use legacy constructor (no mode table) + FDv2DataSource dataSource = buildDataSource(sink, + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false))))); + + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + + // Should not crash; logs a warning and returns + dataSource.switchMode(ConnectionMode.POLLING); + Thread.sleep(100); + + stopDataSource(dataSource); + } + + @Test + public void switchMode_afterStop_isNoOp() throws Exception { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + Map modeTable = new EnumMap<>(ConnectionMode.class); + modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + Collections.emptyList(), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + )); + + FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); + AwaitableCallback startCallback = startDataSource(dataSource); + assertTrue(startCallback.await(2000)); + + stopDataSource(dataSource); + + // Should not crash or schedule new work after stop + dataSource.switchMode(ConnectionMode.POLLING); + Thread.sleep(100); + } + + // ============================================================================ + // Status Reporting + // ============================================================================ + @Test public void stopReportsOffStatus() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); From 128f53a36269db3186d0a1c06826df431a1b694e Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 10 Mar 2026 13:24:33 -0700 Subject: [PATCH 04/14] feat: Add FDv2DataSourceBuilder with stub configurer resolution Introduces FDv2DataSourceBuilder, a ComponentConfigurer that resolves the ModeDefinition table's ComponentConfigurers into DataSourceFactories at build time by capturing the ClientContext. The configurers are currently stubbed (return null); real wiring of concrete initializer/synchronizer types will follow in a subsequent commit. --- .../sdk/android/FDv2DataSourceBuilder.java | 119 +++++++++++++++ .../android/FDv2DataSourceBuilderTest.java | 142 ++++++++++++++++++ 2 files changed, 261 insertions(+) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java create mode 100644 launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java new file mode 100644 index 00000000..014fbd5e --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -0,0 +1,119 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.ClientContext; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.EnumMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +/** + * Builds a mode-aware {@link FDv2DataSource} from a {@link ModeDefinition} table. + *

+ * At build time, each {@link ComponentConfigurer} in the mode table is resolved into a + * {@link FDv2DataSource.DataSourceFactory} by partially applying the {@link ClientContext}: + *

{@code
+ * DataSourceFactory factory = () -> configurer.build(clientContext);
+ * }
+ * This bridges the SDK's {@link ComponentConfigurer} pattern (used in the mode table) with + * the {@link FDv2DataSource.DataSourceFactory} pattern (used inside {@link FDv2DataSource}). + *

+ * The configurers in {@link ModeDefinition#DEFAULT_MODE_TABLE} are currently stubbed. A + * subsequent commit will replace them with real implementations that create + * {@link FDv2PollingInitializer}, {@link FDv2PollingSynchronizer}, + * {@link FDv2StreamingSynchronizer}, etc. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + * @see FDv2DataSource.ResolvedModeDefinition + */ +final class FDv2DataSourceBuilder implements ComponentConfigurer { + + private final Map modeTable; + private final ConnectionMode startingMode; + + /** + * Creates a builder using the {@link ModeDefinition#DEFAULT_MODE_TABLE} and + * {@link ConnectionMode#STREAMING} as the starting mode. + */ + FDv2DataSourceBuilder() { + this(ModeDefinition.DEFAULT_MODE_TABLE, ConnectionMode.STREAMING); + } + + /** + * @param modeTable the mode definitions to resolve at build time + * @param startingMode the initial connection mode for the data source + */ + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode + ) { + this.modeTable = Collections.unmodifiableMap(new EnumMap<>(modeTable)); + this.startingMode = startingMode; + } + + @Override + public DataSource build(ClientContext clientContext) { + Map resolved = + resolveModeTable(clientContext); + + DataSourceUpdateSinkV2 sinkV2 = + (DataSourceUpdateSinkV2) clientContext.getDataSourceUpdateSink(); + + // TODO: executor lifecycle — FDv2DataSource does not shut down its executor. + // In a future commit, this should be replaced with an executor obtained from + // ClientContextImpl or managed by ConnectivityManager. + ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); + + return new FDv2DataSource( + clientContext.getEvaluationContext(), + resolved, + startingMode, + sinkV2, + executor, + clientContext.getBaseLogger() + ); + } + + /** + * Resolves every {@link ComponentConfigurer} in the mode table into a + * {@link FDv2DataSource.DataSourceFactory} by capturing the {@code clientContext}. + * The actual component is not created until the factory's {@code build()} is called. + */ + private Map resolveModeTable( + ClientContext clientContext + ) { + Map resolved = + new EnumMap<>(ConnectionMode.class); + + for (Map.Entry entry : modeTable.entrySet()) { + ModeDefinition def = entry.getValue(); + + List> initFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getInitializers()) { + initFactories.add(() -> configurer.build(clientContext)); + } + + List> syncFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getSynchronizers()) { + syncFactories.add(() -> configurer.build(clientContext)); + } + + resolved.put(entry.getKey(), new FDv2DataSource.ResolvedModeDefinition( + initFactories, syncFactories)); + } + + return resolved; + } +} diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java new file mode 100644 index 00000000..4b03c8ab --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -0,0 +1,142 @@ +package com.launchdarkly.sdk.android; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.subsystems.ClientContext; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import org.junit.Rule; +import org.junit.Test; + +import java.util.Collections; +import java.util.EnumMap; +import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; + +public class FDv2DataSourceBuilderTest { + + private static final LDContext CONTEXT = LDContext.create("builder-test-key"); + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + private ClientContext makeClientContext() { + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + return new ClientContext( + "mobile-key", null, logging.logger, null, sink, + "", false, CONTEXT, null, false, null, null, false + ); + } + + @Test + public void build_returnsNonNullDataSource() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void build_returnsModeAwareDataSource() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + assertTrue(ds instanceof ModeAware); + } + + @Test + public void build_resolvesConfigurersViaClientContext() { + AtomicReference capturedContext = new AtomicReference<>(); + ComponentConfigurer trackingConfigurer = ctx -> { + capturedContext.set(ctx); + return null; + }; + + Map customTable = new EnumMap<>(ConnectionMode.class); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.singletonList(trackingConfigurer) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + ClientContext ctx = makeClientContext(); + DataSource ds = builder.build(ctx); + + // The factory hasn't been invoked yet (lazy resolution). + // Trigger it by starting the data source — but for a unit test, we can verify + // through the resolved mode definition structure instead. We rely on the fact + // that construction succeeded, meaning the starting mode was found in the table. + assertNotNull(ds); + + // Verify that the configurer is callable with the right context. The factory + // wraps `() -> configurer.build(clientContext)`, so we verify the configurer + // itself is wired correctly by calling it directly. + assertNull(trackingConfigurer.build(ctx)); + assertEquals(ctx, capturedContext.get()); + } + + @Test + public void build_usesProvidedStartingMode() { + Map customTable = new EnumMap<>(ConnectionMode.class); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); + DataSource ds = builder.build(makeClientContext()); + + // If the starting mode wasn't found in the table, construction would throw + // IllegalArgumentException. A successful build confirms the mode was resolved. + assertNotNull(ds); + } + + @Test(expected = IllegalArgumentException.class) + public void build_throwsWhenStartingModeNotInTable() { + Map customTable = new EnumMap<>(ConnectionMode.class); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + + // Starting mode is STREAMING, but table only has POLLING + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.build(makeClientContext()); + } + + @Test + public void build_defaultConstructorUsesDefaultModeTable() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + + // The default table has all 5 modes and starts with STREAMING. + // Successful construction confirms both the table and starting mode are valid. + assertNotNull(ds); + assertTrue(ds instanceof ModeAware); + } + + @Test + public void build_resolvesAllModesFromTable() { + Map customTable = new EnumMap<>(ConnectionMode.class); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.singletonList(ctx -> null) + )); + customTable.put(ConnectionMode.OFFLINE, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + + // Verify that switchMode to a different mode in the table works (doesn't throw) + ((ModeAware) ds).switchMode(ConnectionMode.OFFLINE); + } +} From ab2f87be22bcb650d0717f5ebab9fc9dc4860763 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 10 Mar 2026 15:28:00 -0700 Subject: [PATCH 05/14] feat: wire real FDv2 ComponentConfigurer implementations in FDv2DataSourceBuilder Replace stub configurers with concrete factories that create FDv2PollingInitializer, FDv2PollingSynchronizer, and FDv2StreamingSynchronizer. Shared dependencies (SelectorSource, ScheduledExecutorService) are created once per build() call; each factory creates a fresh DefaultFDv2Requestor for lifecycle isolation. Add FDv2 endpoint path constants to StandardEndpoints. Thread TransactionalDataStore through ClientContextImpl and ConnectivityManager so the builder can construct SelectorSourceFacade from ClientContext. --- .../sdk/android/ClientContextImpl.java | 42 ++++- .../sdk/android/ConnectivityManager.java | 4 + .../sdk/android/FDv2DataSourceBuilder.java | 171 +++++++++++++++--- .../sdk/android/StandardEndpoints.java | 6 + .../android/FDv2DataSourceBuilderTest.java | 117 ++++++++---- 5 files changed, 272 insertions(+), 68 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index 85066fbe..b02dccd5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -1,11 +1,14 @@ package com.launchdarkly.sdk.android; +import androidx.annotation.Nullable; + import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.internal.events.DiagnosticStore; /** @@ -33,6 +36,7 @@ final class ClientContextImpl extends ClientContext { private final PlatformState platformState; private final TaskExecutor taskExecutor; private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + private final TransactionalDataStore transactionalDataStore; ClientContextImpl( ClientContext base, @@ -41,6 +45,18 @@ final class ClientContextImpl extends ClientContext { PlatformState platformState, TaskExecutor taskExecutor, PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData + ) { + this(base, diagnosticStore, fetcher, platformState, taskExecutor, perEnvironmentData, null); + } + + ClientContextImpl( + ClientContext base, + DiagnosticStore diagnosticStore, + FeatureFetcher fetcher, + PlatformState platformState, + TaskExecutor taskExecutor, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, + TransactionalDataStore transactionalDataStore ) { super(base); this.diagnosticStore = diagnosticStore; @@ -48,6 +64,7 @@ final class ClientContextImpl extends ClientContext { this.platformState = platformState; this.taskExecutor = taskExecutor; this.perEnvironmentData = perEnvironmentData; + this.transactionalDataStore = transactionalDataStore; } static ClientContextImpl fromConfig( @@ -101,8 +118,23 @@ public static ClientContextImpl forDataSource( LDContext newEvaluationContext, boolean newInBackground, Boolean previouslyInBackground + ) { + return forDataSource(baseClientContext, dataSourceUpdateSink, null, + newEvaluationContext, newInBackground, previouslyInBackground); + } + + public static ClientContextImpl forDataSource( + ClientContext baseClientContext, + DataSourceUpdateSink dataSourceUpdateSink, + @Nullable TransactionalDataStore transactionalDataStore, + LDContext newEvaluationContext, + boolean newInBackground, + Boolean previouslyInBackground ) { ClientContextImpl baseContextImpl = ClientContextImpl.get(baseClientContext); + TransactionalDataStore store = transactionalDataStore != null + ? transactionalDataStore + : baseContextImpl.transactionalDataStore; return new ClientContextImpl( new ClientContext( baseClientContext.getMobileKey(), @@ -123,7 +155,8 @@ public static ClientContextImpl forDataSource( baseContextImpl.getFetcher(), baseContextImpl.getPlatformState(), baseContextImpl.getTaskExecutor(), - baseContextImpl.getPerEnvironmentData() + baseContextImpl.getPerEnvironmentData(), + store ); } @@ -139,7 +172,8 @@ public ClientContextImpl setEvaluationContext(LDContext context) { this.fetcher, this.platformState, this.taskExecutor, - this.perEnvironmentData + this.perEnvironmentData, + this.transactionalDataStore ); } @@ -163,6 +197,10 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { return throwExceptionIfNull(perEnvironmentData); } + public TransactionalDataStore getTransactionalDataStore() { + return transactionalDataStore; + } + private static T throwExceptionIfNull(T o) { if (o == null) { throw new IllegalStateException( 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 22b09e23..180be3bf 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 @@ -15,6 +15,7 @@ import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.EventProcessor; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.fdv2.Selector; import java.lang.ref.WeakReference; @@ -56,6 +57,7 @@ class ConnectivityManager { private final ClientContext baseClientContext; private final PlatformState platformState; private final ComponentConfigurer dataSourceFactory; + private final TransactionalDataStore transactionalDataStore; private final DataSourceUpdateSink dataSourceUpdateSink; private final ConnectionInformationState connectionInformation; private final PersistentDataStoreWrapper.PerEnvironmentData environmentStore; @@ -135,6 +137,7 @@ public void shutDown() { ) { this.baseClientContext = clientContext; this.dataSourceFactory = dataSourceFactory; + this.transactionalDataStore = contextDataManager; this.dataSourceUpdateSink = new DataSourceUpdateSinkImpl(contextDataManager); this.platformState = ClientContextImpl.get(clientContext).getPlatformState(); this.eventProcessor = eventProcessor; @@ -235,6 +238,7 @@ private synchronized boolean updateDataSource( ClientContext clientContext = ClientContextImpl.forDataSource( baseClientContext, dataSourceUpdateSink, + transactionalDataStore, context, inBackground, previouslyInBackground.get() diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 014fbd5e..7cb5c7d8 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -1,15 +1,25 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import com.launchdarkly.logging.LDLogger; +import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; +import com.launchdarkly.sdk.internal.events.DiagnosticStore; +import com.launchdarkly.sdk.internal.http.HttpProperties; +import java.net.URI; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collections; import java.util.EnumMap; import java.util.List; @@ -18,20 +28,19 @@ import java.util.concurrent.ScheduledExecutorService; /** - * Builds a mode-aware {@link FDv2DataSource} from a {@link ModeDefinition} table. + * Builds a mode-aware {@link FDv2DataSource} from either a custom {@link ModeDefinition} table + * or the built-in default mode definitions. *

- * At build time, each {@link ComponentConfigurer} in the mode table is resolved into a - * {@link FDv2DataSource.DataSourceFactory} by partially applying the {@link ClientContext}: - *

{@code
- * DataSourceFactory factory = () -> configurer.build(clientContext);
- * }
- * This bridges the SDK's {@link ComponentConfigurer} pattern (used in the mode table) with - * the {@link FDv2DataSource.DataSourceFactory} pattern (used inside {@link FDv2DataSource}). + * When no custom table is supplied, the builder creates concrete {@link FDv2PollingInitializer}, + * {@link FDv2PollingSynchronizer}, and {@link FDv2StreamingSynchronizer} factories using + * dependencies extracted from the {@link ClientContext}. Shared dependencies (executor, + * {@link SelectorSource}) are created once and captured by all factory closures. Each factory + * call creates fresh instances of requestors and concrete sources to ensure proper lifecycle + * management. *

- * The configurers in {@link ModeDefinition#DEFAULT_MODE_TABLE} are currently stubbed. A - * subsequent commit will replace them with real implementations that create - * {@link FDv2PollingInitializer}, {@link FDv2PollingSynchronizer}, - * {@link FDv2StreamingSynchronizer}, etc. + * When a custom table is supplied (for testing), each {@link ComponentConfigurer} is resolved + * into a {@link FDv2DataSource.DataSourceFactory} by partially applying the + * {@link ClientContext}. *

* Package-private — not part of the public SDK API. * @@ -40,42 +49,50 @@ */ final class FDv2DataSourceBuilder implements ComponentConfigurer { + @Nullable private final Map modeTable; private final ConnectionMode startingMode; /** - * Creates a builder using the {@link ModeDefinition#DEFAULT_MODE_TABLE} and + * Creates a builder using the built-in default mode definitions and * {@link ConnectionMode#STREAMING} as the starting mode. */ FDv2DataSourceBuilder() { - this(ModeDefinition.DEFAULT_MODE_TABLE, ConnectionMode.STREAMING); + this(null, ConnectionMode.STREAMING); } /** - * @param modeTable the mode definitions to resolve at build time + * @param modeTable custom mode definitions to resolve at build time, or {@code null} + * to use the built-in defaults * @param startingMode the initial connection mode for the data source */ FDv2DataSourceBuilder( - @NonNull Map modeTable, + @Nullable Map modeTable, @NonNull ConnectionMode startingMode ) { - this.modeTable = Collections.unmodifiableMap(new EnumMap<>(modeTable)); + this.modeTable = modeTable != null + ? Collections.unmodifiableMap(new EnumMap<>(modeTable)) + : null; this.startingMode = startingMode; } @Override public DataSource build(ClientContext clientContext) { - Map resolved = - resolveModeTable(clientContext); - - DataSourceUpdateSinkV2 sinkV2 = - (DataSourceUpdateSinkV2) clientContext.getDataSourceUpdateSink(); - // TODO: executor lifecycle — FDv2DataSource does not shut down its executor. // In a future commit, this should be replaced with an executor obtained from // ClientContextImpl or managed by ConnectivityManager. ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); + Map resolved; + if (modeTable != null) { + resolved = resolveCustomModeTable(clientContext); + } else { + resolved = buildDefaultModeTable(clientContext, executor); + } + + DataSourceUpdateSinkV2 sinkV2 = + (DataSourceUpdateSinkV2) clientContext.getDataSourceUpdateSink(); + return new FDv2DataSource( clientContext.getEvaluationContext(), resolved, @@ -87,11 +104,95 @@ public DataSource build(ClientContext clientContext) { } /** - * Resolves every {@link ComponentConfigurer} in the mode table into a - * {@link FDv2DataSource.DataSourceFactory} by capturing the {@code clientContext}. - * The actual component is not created until the factory's {@code build()} is called. + * Builds the default mode table with real factories. Shared dependencies are created + * once and captured by factory closures; each factory call creates fresh instances of + * requestors and concrete sources. + */ + private Map buildDefaultModeTable( + ClientContext clientContext, + ScheduledExecutorService executor + ) { + ClientContextImpl impl = ClientContextImpl.get(clientContext); + HttpProperties httpProperties = LDUtil.makeHttpProperties(clientContext); + LDContext evalContext = clientContext.getEvaluationContext(); + LDLogger logger = clientContext.getBaseLogger(); + boolean useReport = clientContext.getHttp().isUseReport(); + boolean evaluationReasons = clientContext.isEvaluationReasons(); + URI pollingBaseUri = clientContext.getServiceEndpoints().getPollingBaseUri(); + URI streamingBaseUri = clientContext.getServiceEndpoints().getStreamingBaseUri(); + DiagnosticStore diagnosticStore = impl.getDiagnosticStore(); + TransactionalDataStore txnStore = impl.getTransactionalDataStore(); + SelectorSource selectorSource = new SelectorSourceFacade(txnStore); + + // Each factory creates a fresh requestor so that lifecycle (close/shutdown) is isolated + // per initializer/synchronizer instance. + FDv2DataSource.DataSourceFactory pollingInitFactory = () -> + new FDv2PollingInitializer( + newRequestor(evalContext, pollingBaseUri, httpProperties, + useReport, evaluationReasons, logger), + selectorSource, executor, logger); + + FDv2DataSource.DataSourceFactory streamingSyncFactory = () -> + new FDv2StreamingSynchronizer( + httpProperties, streamingBaseUri, + StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, + evalContext, useReport, evaluationReasons, selectorSource, + newRequestor(evalContext, pollingBaseUri, httpProperties, + useReport, evaluationReasons, logger), + StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, + diagnosticStore, logger); + + FDv2DataSource.DataSourceFactory foregroundPollSyncFactory = () -> + new FDv2PollingSynchronizer( + newRequestor(evalContext, pollingBaseUri, httpProperties, + useReport, evaluationReasons, logger), + selectorSource, executor, + 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, logger); + + FDv2DataSource.DataSourceFactory backgroundPollSyncFactory = () -> + new FDv2PollingSynchronizer( + newRequestor(evalContext, pollingBaseUri, httpProperties, + useReport, evaluationReasons, logger), + selectorSource, executor, + 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, logger); + + Map resolved = + new EnumMap<>(ConnectionMode.class); + + // STREAMING: poll once for initial data, then stream (with polling fallback) + resolved.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + Collections.singletonList(pollingInitFactory), + Arrays.asList(streamingSyncFactory, foregroundPollSyncFactory))); + + // POLLING: poll once for initial data, then poll periodically + resolved.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + Collections.singletonList(pollingInitFactory), + Collections.singletonList(foregroundPollSyncFactory))); + + // OFFLINE: no network activity + resolved.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( + Collections.>emptyList(), + Collections.>emptyList())); + + // ONE_SHOT: poll once, then stop + resolved.put(ConnectionMode.ONE_SHOT, new FDv2DataSource.ResolvedModeDefinition( + Collections.singletonList(pollingInitFactory), + Collections.>emptyList())); + + // BACKGROUND: poll at reduced frequency (no re-initialization) + resolved.put(ConnectionMode.BACKGROUND, new FDv2DataSource.ResolvedModeDefinition( + Collections.>emptyList(), + Collections.singletonList(backgroundPollSyncFactory))); + + return resolved; + } + + /** + * Resolves a custom {@link ModeDefinition} table by wrapping each {@link ComponentConfigurer} + * in a {@link FDv2DataSource.DataSourceFactory} that defers to + * {@code configurer.build(clientContext)}. */ - private Map resolveModeTable( + private Map resolveCustomModeTable( ClientContext clientContext ) { Map resolved = @@ -116,4 +217,20 @@ private Map resolveModeTa return resolved; } + + private static DefaultFDv2Requestor newRequestor( + LDContext evalContext, + URI pollingBaseUri, + HttpProperties httpProperties, + boolean useReport, + boolean evaluationReasons, + LDLogger logger + ) { + return new DefaultFDv2Requestor( + evalContext, pollingBaseUri, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProperties, useReport, evaluationReasons, + null, logger); + } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java index c9c395e3..2f2e60bd 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java @@ -14,6 +14,12 @@ private StandardEndpoints() {} static final String STREAMING_REQUEST_BASE_PATH = "/meval"; static final String POLLING_REQUEST_GET_BASE_PATH = "/msdk/evalx/contexts"; static final String POLLING_REQUEST_REPORT_BASE_PATH = "/msdk/evalx/context"; + + // FDv2 paths per CSFDV2 Requirement 2.1.1 (unified for all client-side platforms). + // Context is appended as a base64 path segment for GET, or sent in the request body for REPORT/POST. + static final String FDV2_POLLING_REQUEST_GET_BASE_PATH = "/sdk/poll/eval"; + static final String FDV2_POLLING_REQUEST_REPORT_BASE_PATH = "/sdk/poll/eval"; + static final String FDV2_STREAMING_REQUEST_BASE_PATH = "/sdk/stream/eval"; static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 4b03c8ab..a6b8f518 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -5,12 +5,22 @@ import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; +import androidx.annotation.NonNull; + import com.launchdarkly.sdk.LDContext; +import com.launchdarkly.sdk.android.DataModel.Flag; +import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; +import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; +import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; +import com.launchdarkly.sdk.fdv2.ChangeSet; +import com.launchdarkly.sdk.fdv2.Selector; import org.junit.Rule; import org.junit.Test; @@ -23,11 +33,17 @@ public class FDv2DataSourceBuilderTest { private static final LDContext CONTEXT = LDContext.create("builder-test-key"); + private static final IEnvironmentReporter ENV_REPORTER = new EnvironmentReporterBuilder().build(); @Rule public LogCaptureRule logging = new LogCaptureRule(); - private ClientContext makeClientContext() { + /** + * Creates a minimal ClientContext for tests that use a custom mode table. + * No TransactionalDataStore or HTTP config needed — the custom path + * only wraps ComponentConfigurers in DataSourceFactory lambdas. + */ + private ClientContext makeMinimalClientContext() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); return new ClientContext( "mobile-key", null, logging.logger, null, sink, @@ -35,22 +51,70 @@ private ClientContext makeClientContext() { ); } + /** + * Creates a ClientContext backed by a real ClientContextImpl with HTTP config, + * ServiceEndpoints, and a TransactionalDataStore. Used by tests that exercise + * the default (real-wiring) build path. + */ + private ClientContext makeFullClientContext() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + + // Two-phase ClientContext creation: first without HTTP config to bootstrap it, + // then with the resolved HTTP config — mirrors ClientContextImpl.fromConfig(). + ClientContext bootstrap = new ClientContext( + "mobile-key", ENV_REPORTER, logging.logger, config, + null, "", false, CONTEXT, null, false, null, + config.serviceEndpoints, false + ); + HttpConfiguration httpConfig = config.http.build(bootstrap); + + ClientContext base = new ClientContext( + "mobile-key", ENV_REPORTER, logging.logger, config, + sink, "", false, CONTEXT, httpConfig, false, null, + config.serviceEndpoints, false + ); + + TransactionalDataStore mockStore = new TransactionalDataStore() { + @Override + public void apply(@NonNull LDContext context, + @NonNull ChangeSet> changeSet) { } + + @NonNull + @Override + public Selector getSelector() { + return Selector.EMPTY; + } + }; + + return new ClientContextImpl(base, null, null, null, null, null, mockStore); + } + + // --- Default constructor tests (real wiring path) --- + @Test - public void build_returnsNonNullDataSource() { + public void build_defaultConstructor_returnsNonNullModeAwareDataSource() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeClientContext()); + DataSource ds = builder.build(makeFullClientContext()); assertNotNull(ds); + assertTrue(ds instanceof ModeAware); } @Test - public void build_returnsModeAwareDataSource() { + public void build_defaultConstructor_allModesResolved() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeClientContext()); - assertTrue(ds instanceof ModeAware); + DataSource ds = builder.build(makeFullClientContext()); + + ModeAware modeAware = (ModeAware) ds; + for (ConnectionMode mode : ConnectionMode.values()) { + modeAware.switchMode(mode); + } } + // --- Custom mode table tests (configurer resolution path) --- + @Test - public void build_resolvesConfigurersViaClientContext() { + public void build_customTable_resolvesConfigurersViaClientContext() { AtomicReference capturedContext = new AtomicReference<>(); ComponentConfigurer trackingConfigurer = ctx -> { capturedContext.set(ctx); @@ -64,24 +128,16 @@ public void build_resolvesConfigurersViaClientContext() { )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - ClientContext ctx = makeClientContext(); + ClientContext ctx = makeMinimalClientContext(); DataSource ds = builder.build(ctx); - // The factory hasn't been invoked yet (lazy resolution). - // Trigger it by starting the data source — but for a unit test, we can verify - // through the resolved mode definition structure instead. We rely on the fact - // that construction succeeded, meaning the starting mode was found in the table. assertNotNull(ds); - - // Verify that the configurer is callable with the right context. The factory - // wraps `() -> configurer.build(clientContext)`, so we verify the configurer - // itself is wired correctly by calling it directly. assertNull(trackingConfigurer.build(ctx)); assertEquals(ctx, capturedContext.get()); } @Test - public void build_usesProvidedStartingMode() { + public void build_customTable_usesProvidedStartingMode() { Map customTable = new EnumMap<>(ConnectionMode.class); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), @@ -89,39 +145,24 @@ public void build_usesProvidedStartingMode() { )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); - DataSource ds = builder.build(makeClientContext()); - - // If the starting mode wasn't found in the table, construction would throw - // IllegalArgumentException. A successful build confirms the mode was resolved. + DataSource ds = builder.build(makeMinimalClientContext()); assertNotNull(ds); } @Test(expected = IllegalArgumentException.class) - public void build_throwsWhenStartingModeNotInTable() { + public void build_customTable_throwsWhenStartingModeNotInTable() { Map customTable = new EnumMap<>(ConnectionMode.class); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), Collections.>emptyList() )); - // Starting mode is STREAMING, but table only has POLLING FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - builder.build(makeClientContext()); + builder.build(makeMinimalClientContext()); } @Test - public void build_defaultConstructorUsesDefaultModeTable() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeClientContext()); - - // The default table has all 5 modes and starts with STREAMING. - // Successful construction confirms both the table and starting mode are valid. - assertNotNull(ds); - assertTrue(ds instanceof ModeAware); - } - - @Test - public void build_resolvesAllModesFromTable() { + public void build_customTable_resolvesAllModesAndSupportsSwitchMode() { Map customTable = new EnumMap<>(ConnectionMode.class); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), @@ -133,10 +174,8 @@ public void build_resolvesAllModesFromTable() { )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - DataSource ds = builder.build(makeClientContext()); + DataSource ds = builder.build(makeMinimalClientContext()); assertNotNull(ds); - - // Verify that switchMode to a different mode in the table works (doesn't throw) ((ModeAware) ds).switchMode(ConnectionMode.OFFLINE); } } From 475238d8bab1790f38d3c2e25b6c557604cf11b7 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 10 Mar 2026 16:52:42 -0700 Subject: [PATCH 06/14] feat: Add FDv2 mode resolution to ConnectivityManager ConnectivityManager now detects ModeAware data sources and routes foreground, connectivity, and force-offline state changes through resolveAndSwitchMode() instead of the legacy teardown/rebuild cycle. --- .../sdk/android/ConnectivityManager.java | 70 +++- .../sdk/android/ConnectivityManagerTest.java | 309 ++++++++++++++++++ .../sdk/android/MockPlatformState.java | 9 + 3 files changed, 376 insertions(+), 12 deletions(-) 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 180be3bf..82541128 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 @@ -26,8 +26,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import static com.launchdarkly.sdk.android.ConnectionInformation.ConnectionMode; - class ConnectivityManager { // Implementation notes: // @@ -76,6 +74,7 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; + private volatile ConnectionMode currentFDv2Mode; // 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 @@ -107,7 +106,7 @@ public void apply(@NonNull LDContext context, @NonNull ChangeSet { - updateDataSource(false, LDUtil.noOpCallback()); + DataSource dataSource = currentDataSource.get(); + if (dataSource instanceof ModeAware) { + eventProcessor.setOffline(forcedOffline.get() || !networkAvailable); + resolveAndSwitchMode((ModeAware) dataSource); + } else { + updateDataSource(false, LDUtil.noOpCallback()); + } }; platformState.addConnectivityChangeListener(connectivityChangeListener); foregroundListener = foreground -> { DataSource dataSource = currentDataSource.get(); - if (dataSource == null || dataSource.needsRefresh(!foreground, + if (dataSource instanceof ModeAware) { + eventProcessor.setInBackground(!foreground); + resolveAndSwitchMode((ModeAware) dataSource); + } else if (dataSource == null || dataSource.needsRefresh(!foreground, currentContext.get())) { updateDataSource(true, LDUtil.noOpCallback()); } @@ -212,11 +220,11 @@ private synchronized boolean updateDataSource( if (forceOffline) { logger.debug("Initialized in offline mode"); initialized = true; - dataSourceUpdateSink.setStatus(ConnectionMode.SET_OFFLINE, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.SET_OFFLINE, null); } else if (!networkEnabled) { - dataSourceUpdateSink.setStatus(ConnectionMode.OFFLINE, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.OFFLINE, null); } else if (inBackground && backgroundUpdatingDisabled) { - dataSourceUpdateSink.setStatus(ConnectionMode.BACKGROUND_DISABLED, null); + dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.BACKGROUND_DISABLED, null); } else { shouldStopExistingDataSource = mustReinitializeDataSource; shouldStartDataSourceIfStopped = true; @@ -266,6 +274,13 @@ public void onError(Throwable error) { } }); + // Resolve the initial mode after start() so that switchMode() can safely replace + // the source manager without conflicting with the start() task submission. + if (dataSource instanceof ModeAware) { + currentFDv2Mode = ConnectionMode.STREAMING; + resolveAndSwitchMode((ModeAware) dataSource); + } + return true; } @@ -297,7 +312,7 @@ void unregisterStatusListener(LDStatusListener LDStatusListener) { } } - private void updateConnectionInfoForSuccess(ConnectionMode connectionMode) { + private void updateConnectionInfoForSuccess(ConnectionInformation.ConnectionMode connectionMode) { boolean updated = false; if (connectionInformation.getConnectionMode() != connectionMode) { connectionInformation.setConnectionMode(connectionMode); @@ -322,7 +337,7 @@ private void updateConnectionInfoForSuccess(ConnectionMode connectionMode) { } } - private void updateConnectionInfoForError(ConnectionMode connectionMode, Throwable error) { + private void updateConnectionInfoForError(ConnectionInformation.ConnectionMode connectionMode, Throwable error) { LDFailure failure = null; if (error != null) { if (error instanceof LDFailure) { @@ -410,6 +425,31 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return updateDataSource(true, onCompletion); } + /** + * Resolves the current platform state to a {@link ConnectionMode} using the mode resolution + * table, and calls {@link ModeAware#switchMode} if the resolved mode differs from the current + * mode. This replaces the legacy teardown/rebuild cycle for FDv2 data sources. + */ + private void resolveAndSwitchMode(ModeAware modeAware) { + ConnectionMode resolvedMode; + if (forcedOffline.get()) { + resolvedMode = ConnectionMode.OFFLINE; + } else { + ModeState state = new ModeState( + platformState.isForeground(), + platformState.isNetworkAvailable() + ); + resolvedMode = ModeResolutionTable.MOBILE.resolve(state); + } + + ConnectionMode previousMode = currentFDv2Mode; + if (previousMode != resolvedMode) { + logger.debug("Switching FDv2 data source mode: {} -> {}", previousMode, resolvedMode); + currentFDv2Mode = resolvedMode; + modeAware.switchMode(resolvedMode); + } + } + /** * Permanently stops data updating for the current client instance. We call this if the client * is being closed, or if we receive an error that indicates the mobile key is invalid. @@ -429,7 +469,13 @@ void shutDown() { void setForceOffline(boolean forceOffline) { boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline); if (forceOffline != wasForcedOffline) { - updateDataSource(false, LDUtil.noOpCallback()); + DataSource dataSource = currentDataSource.get(); + if (dataSource instanceof ModeAware) { + eventProcessor.setOffline(forceOffline || !platformState.isNetworkAvailable()); + resolveAndSwitchMode((ModeAware) dataSource); + } else { + updateDataSource(false, LDUtil.noOpCallback()); + } } } 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 26523e5e..1697e5c9 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 @@ -657,4 +657,313 @@ private void verifyNoMoreDataSourcesWereCreated() { private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } + + // --- ModeAware (FDv2) tests --- + + /** + * A minimal {@link ModeAware} data source that tracks {@code switchMode()} calls. + * Mimics the threading behavior of real data sources by calling the start callback + * on a background thread after reporting initial status. + */ + private static class MockModeAwareDataSource implements ModeAware { + final BlockingQueue switchModeCalls = + new LinkedBlockingQueue<>(); + final BlockingQueue startedQueue; + final BlockingQueue stoppedQueue; + final ClientContext clientContext; + + MockModeAwareDataSource( + ClientContext clientContext, + BlockingQueue startedQueue, + BlockingQueue stoppedQueue + ) { + this.clientContext = clientContext; + this.startedQueue = startedQueue; + this.stoppedQueue = stoppedQueue; + } + + @Override + public void start(@NonNull Callback resultCallback) { + if (startedQueue != null) { + startedQueue.add(this); + } + new Thread(() -> { + clientContext.getDataSourceUpdateSink().setStatus( + ConnectionMode.STREAMING, null); + resultCallback.onSuccess(true); + }).start(); + } + + @Override + public void stop(@NonNull Callback completionCallback) { + if (stoppedQueue != null) { + stoppedQueue.add(this); + } + completionCallback.onSuccess(null); + } + + @Override + public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { + return false; + } + + @Override + public void switchMode(@NonNull com.launchdarkly.sdk.android.ConnectionMode newMode) { + switchModeCalls.add(newMode); + } + + com.launchdarkly.sdk.android.ConnectionMode requireSwitchMode() { + return requireValue(switchModeCalls, 1, TimeUnit.SECONDS, + "switchMode call"); + } + + void requireNoMoreSwitchModeCalls() { + requireNoMoreValues(switchModeCalls, 100, TimeUnit.MILLISECONDS, + "unexpected switchMode call"); + } + } + + private ComponentConfigurer makeModeAwareDataSourceFactory( + MockModeAwareDataSource[] holder + ) { + return clientContext -> { + receivedClientContexts.add(clientContext); + MockModeAwareDataSource ds = new MockModeAwareDataSource( + clientContext, startedDataSources, stoppedDataSources); + holder[0] = ds; + return ds; + }; + } + + @Test + public void modeAwareForegroundToBackgroundSwitchesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setInBackground(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + // Initial mode: STREAMING (foreground + network available). + // resolveAndSwitchMode was called after start() but resolved STREAMING = no-op. + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, newMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareBackgroundToForegroundSwitchesMode() throws Exception { + mockPlatformState.setForeground(false); + + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + eventProcessor.setInBackground(false); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + // Initial resolution: background → switchMode(BACKGROUND) + com.launchdarkly.sdk.android.ConnectionMode initialMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, initialMode); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + + com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, newMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareNetworkLostSwitchesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + + com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, newMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareNetworkRestoredSwitchesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setOffline(false); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + // Lose network + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + com.launchdarkly.sdk.android.ConnectionMode offlineMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, offlineMode); + + // Restore network + mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + com.launchdarkly.sdk.android.ConnectionMode restoredMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, restoredMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareSetForceOfflineSwitchesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + connectivityManager.setForceOffline(true); + + com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, newMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareUnsetForceOfflineResolvesMode() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setOffline(false); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + connectivityManager.setForceOffline(true); + com.launchdarkly.sdk.android.ConnectionMode offlineMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, offlineMode); + + connectivityManager.setForceOffline(false); + com.launchdarkly.sdk.android.ConnectionMode restoredMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, restoredMode); + + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareDoesNotSwitchWhenModeUnchanged() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + // Network is already available; re-notifying should not trigger switchMode + mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + + ds.requireNoMoreSwitchModeCalls(); + verifyAll(); + verifyNoMoreDataSourcesWereStopped(); + } + + @Test + public void modeAwareDoesNotTearDownOnForegroundChange() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setInBackground(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + ds.requireNoMoreSwitchModeCalls(); + + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + ds.requireSwitchMode(); + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); + verifyAll(); + } + + @Test + public void modeAwareStartsInBackgroundResolvesToBackground() throws Exception { + mockPlatformState.setForeground(false); + + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; + createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + awaitStartUp(); + + MockModeAwareDataSource ds = holder[0]; + assertNotNull(ds); + + // Builder default is STREAMING, but we start in background, so + // resolveAndSwitchMode should immediately switch to BACKGROUND + com.launchdarkly.sdk.android.ConnectionMode initialMode = ds.requireSwitchMode(); + assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, initialMode); + ds.requireNoMoreSwitchModeCalls(); + + verifyAll(); + } } \ No newline at end of file diff --git a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java index 4d99a282..7c0d12d3 100644 --- a/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java +++ b/shared-test-code/src/main/java/com/launchdarkly/sdk/android/MockPlatformState.java @@ -63,6 +63,15 @@ public void removeForegroundChangeListener(ForegroundChangeListener listener) { foregroundChangeListeners.remove(listener); } + public void setAndNotifyConnectivityChangeListeners(boolean networkAvailable) { + this.networkAvailable = networkAvailable; + new Thread(() -> { + for (ConnectivityChangeListener listener: connectivityChangeListeners) { + listener.onConnectivityChanged(networkAvailable); + } + }).start(); + } + public void setAndNotifyForegroundChangeListeners(boolean foreground) { this.foreground = foreground; new Thread(() -> { From be8b4c94b0e90ecf3a6f317bbae9d809cefd9575 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 11 Mar 2026 17:06:53 -0700 Subject: [PATCH 07/14] [SDK-1956] clean up unused code --- .../sdk/android/ConnectivityManager.java | 2 - .../sdk/android/ModeDefinition.java | 52 +++---------------- 2 files changed, 7 insertions(+), 47 deletions(-) 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 82541128..199daae4 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 @@ -6,7 +6,6 @@ import com.launchdarkly.logging.LogValues; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.subsystems.Callback; -import com.launchdarkly.sdk.android.DataModel; import com.launchdarkly.sdk.fdv2.ChangeSet; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; @@ -16,7 +15,6 @@ import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.EventProcessor; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; -import com.launchdarkly.sdk.fdv2.Selector; import java.lang.ref.WeakReference; import java.util.ArrayList; diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java index 70c0202b..cd33312f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -6,62 +6,24 @@ import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; -import java.util.Arrays; import java.util.Collections; -import java.util.EnumMap; import java.util.List; -import java.util.Map; /** * Defines the initializer and synchronizer pipelines for a {@link ConnectionMode}. + * Each instance is a pure data holder — it stores {@link ComponentConfigurer} factories + * but does not create any concrete initializer or synchronizer objects. *

- * Each mode in the {@link #DEFAULT_MODE_TABLE} maps to a {@code ModeDefinition} that - * describes which data source components to create. At build time, - * {@code FDv2DataSourceBuilder} resolves each {@link ComponentConfigurer} into a - * {@link FDv2DataSource.DataSourceFactory} by applying the {@code ClientContext}. - *

- * The configurers in {@link #DEFAULT_MODE_TABLE} are currently stubbed (return null). - * Real {@link ComponentConfigurer} implementations will be wired in when - * {@code FDv2DataSourceBuilder} is created. + * At build time, {@code FDv2DataSourceBuilder} resolves each {@link ComponentConfigurer} + * into a {@link FDv2DataSource.DataSourceFactory} by partially applying the + * {@link com.launchdarkly.sdk.android.subsystems.ClientContext}. *

* Package-private — not part of the public SDK API. + * + * @see ConnectionMode */ final class ModeDefinition { - // Stubbed configurer — will be replaced with real ComponentConfigurer implementations - // in FDv2DataSourceBuilder when concrete types are wired up. - private static final ComponentConfigurer STUB_INITIALIZER = clientContext -> null; - private static final ComponentConfigurer STUB_SYNCHRONIZER = clientContext -> null; - - static final Map DEFAULT_MODE_TABLE; - - static { - Map table = new EnumMap<>(ConnectionMode.class); - // Initializer/synchronizer lists per CONNMODE spec and js-core ConnectionModeConfig.ts. - // Stubs will be replaced with real factories (cache, polling, streaming) in FDv2DataSourceBuilder. - table.put(ConnectionMode.STREAMING, new ModeDefinition( - Arrays.asList(STUB_INITIALIZER, STUB_INITIALIZER), // cache, polling - Arrays.asList(STUB_SYNCHRONIZER, STUB_SYNCHRONIZER) // streaming, polling - )); - table.put(ConnectionMode.POLLING, new ModeDefinition( - Collections.singletonList(STUB_INITIALIZER), // cache - Collections.singletonList(STUB_SYNCHRONIZER) // polling - )); - table.put(ConnectionMode.OFFLINE, new ModeDefinition( - Collections.singletonList(STUB_INITIALIZER), // cache - Collections.>emptyList() - )); - table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( - Arrays.asList(STUB_INITIALIZER, STUB_INITIALIZER, STUB_INITIALIZER), // cache, polling, streaming - Collections.>emptyList() - )); - table.put(ConnectionMode.BACKGROUND, new ModeDefinition( - Collections.singletonList(STUB_INITIALIZER), // cache - Collections.singletonList(STUB_SYNCHRONIZER) // polling (LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS) - )); - DEFAULT_MODE_TABLE = Collections.unmodifiableMap(table); - } - private final List> initializers; private final List> synchronizers; From 0c49ad1f9a0f45c61613cd2a2ef0dd50c6eeebcd Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Mar 2026 14:56:28 -0700 Subject: [PATCH 08/14] [SDK-1956] refactor: switch to Approach 2 for FDv2 mode resolution and switching Replace Approach 1 implementation with Approach 2, which the team preferred for its cleaner architecture: - ConnectivityManager owns the resolved mode table and performs ModeState -> ConnectionMode -> ResolvedModeDefinition lookup - FDv2DataSource receives ResolvedModeDefinition via switchMode() and has no internal mode table - FDv2DataSourceBuilder uses a unified ComponentConfigurer-based code path for both production and test mode tables - ResolvedModeDefinition is a top-level class rather than an inner class of FDv2DataSource - ConnectionMode is a final class with static instances instead of a Java enum Made-with: Cursor --- docs/SDK-1956-development-plan.md | 122 +++--- .../sdk/android/ClientContextImpl.java | 23 +- .../sdk/android/ConnectionMode.java | 33 +- .../sdk/android/ConnectivityManager.java | 78 ++-- .../sdk/android/FDv2DataSource.java | 179 +-------- .../sdk/android/FDv2DataSourceBuilder.java | 370 +++++++++--------- .../launchdarkly/sdk/android/ModeAware.java | 19 +- .../sdk/android/ModeDefinition.java | 3 +- .../sdk/android/ModeResolutionEntry.java | 25 +- .../launchdarkly/sdk/android/ModeState.java | 11 +- .../sdk/android/ResolvedModeDefinition.java | 48 +++ .../sdk/android/StandardEndpoints.java | 6 +- .../sdk/android/ConnectivityManagerTest.java | 298 ++++++-------- .../android/FDv2DataSourceBuilderTest.java | 170 +++----- .../sdk/android/FDv2DataSourceTest.java | 328 +++++----------- .../sdk/android/ModeResolutionTableTest.java | 72 ++-- 16 files changed, 747 insertions(+), 1038 deletions(-) create mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java diff --git a/docs/SDK-1956-development-plan.md b/docs/SDK-1956-development-plan.md index b617c711..a28f071e 100644 --- a/docs/SDK-1956-development-plan.md +++ b/docs/SDK-1956-development-plan.md @@ -199,21 +199,15 @@ Maps to JS `FDv2ConnectionMode`. Closed enum — custom modes are out of scope f ```java final class ModeDefinition { - final List> initializers; - final List> synchronizers; + private final List> initializers; + private final List> synchronizers; + // + getters: getInitializers(), getSynchronizers() } ``` Uses the SDK's existing `ComponentConfigurer` pattern (which takes `ClientContext` at build time) rather than a custom `DataSourceEntry` config type. This eliminates the need for a separate config-to-factory conversion step — the mode table directly holds factory functions. -Helper factory methods provide readable construction: - -```java -static ComponentConfigurer pollingInitializer() { ... } -static ComponentConfigurer pollingSynchronizer(long intervalMs) { ... } -static ComponentConfigurer streamingSynchronizer() { ... } -static ComponentConfigurer cacheInitializer() { ... } // stubbed for now -``` +`ModeDefinition` is a pure data class — it holds configurer lists but does not define factory methods. The `DEFAULT_MODE_TABLE` entries are currently stubbed (`clientContext -> null`). Real `ComponentConfigurer` implementations will be wired in when `FDv2DataSourceBuilder` is created (Commit 4). ### 3. Default Mode Table @@ -243,12 +237,13 @@ This resolution produces a `Map` where ` ```java final class ModeState { - final boolean foreground; - final boolean networkAvailable; + private final boolean foreground; + private final boolean networkAvailable; + // + getters: isForeground(), isNetworkAvailable() } ``` -Represents the current platform state. All fields required, primitive `boolean` types. Built by ConnectivityManager from `PlatformState` events. +Represents the current platform state. All fields required, primitive `boolean` types, accessed via getters (codebase convention). Built by ConnectivityManager from `PlatformState` events. In this PR, `ModeState` only carries platform state. User-configurable foreground/background mode selection (CONNMODE 2.2.2) is deferred to a future PR. When that's added, `foregroundMode` and `backgroundMode` fields will be introduced here and the resolution table entries will reference them via lambdas instead of hardcoded enum values. @@ -256,12 +251,18 @@ In this PR, `ModeState` only carries platform state. User-configurable foregroun ```java final class ModeResolutionEntry { - final Predicate conditions; // does this entry apply to the given state? - final ConnectionMode mode; // the resolved mode if this entry matches + // Custom functional interface (minSdk 21 — java.util.function.Predicate requires API 24+) + interface Condition { + boolean test(@NonNull ModeState state); + } + + private final Condition conditions; + private final ConnectionMode mode; + // + getters: getConditions(), getMode() } ``` -With hardcoded defaults, the resolver is a simple `ConnectionMode` value rather than a `Function`. When user-configurable mode selection is added later, `mode` can be replaced with a `Function` resolver to support indirection like `state -> state.foregroundMode`. +Uses a custom `Condition` functional interface instead of `java.util.function.Predicate` to support minSdk 21. With hardcoded defaults, the resolver is a simple `ConnectionMode` value rather than a `Function`. When user-configurable mode selection is added later, `mode` can be replaced with a resolver function to support indirection like `state -> state.foregroundMode`. ### 6. `ModeResolutionTable` + `resolve()` (pure function) @@ -269,13 +270,13 @@ With hardcoded defaults, the resolver is a simple `ConnectionMode` value rather final class ModeResolutionTable { static final ModeResolutionTable MOBILE = new ModeResolutionTable(Arrays.asList( new ModeResolutionEntry( - state -> !state.networkAvailable, + state -> !state.isNetworkAvailable(), ConnectionMode.OFFLINE), new ModeResolutionEntry( - state -> !state.foreground, + state -> !state.isForeground(), ConnectionMode.BACKGROUND), new ModeResolutionEntry( - state -> state.foreground, + state -> true, // catch-all ConnectionMode.STREAMING) )); @@ -283,9 +284,9 @@ final class ModeResolutionTable { } ``` -`resolve()` iterates entries in order. The first entry whose `conditions` predicate returns `true` wins, and its `mode` value is returned. Pure function — no side effects, no platform awareness. +`resolve()` iterates entries in order. The first entry whose `conditions` predicate returns `true` wins, and its `mode` value is returned. The last entry is a true catch-all (`state -> true`) for robustness. Pure function — no side effects, no platform awareness. -**Adaptation note:** This is a Java-idiomatic adaptation of Ryan Lamb's mode resolution code from js-core PR [#1146](https://github.com/launchdarkly/js-core/pull/1146). The js-core version uses `Partial` for conditions (partial object matching) and `ConfiguredMode` indirection for user-configurable modes. In this PR, we simplify: conditions become `Predicate` and modes are hardcoded `ConnectionMode` enum values. The data-driven table structure is preserved so that user-configurable mode selection can be added later by replacing the `ConnectionMode mode` field with a `Function` resolver. +**Adaptation note:** This is a Java-idiomatic adaptation of Ryan Lamb's mode resolution code from js-core PR [#1146](https://github.com/launchdarkly/js-core/pull/1146). The js-core version uses `Partial` for conditions (partial object matching) and `ConfiguredMode` indirection for user-configurable modes. In this PR, we simplify: conditions use a custom `ModeResolutionEntry.Condition` functional interface (for minSdk 21 compatibility) and modes are hardcoded `ConnectionMode` enum values. The data-driven table structure is preserved so that user-configurable mode selection can be added later by replacing the `ConnectionMode mode` field with a resolver function. ### 7. `ModeAware` (package-private interface) @@ -307,11 +308,11 @@ FDv2DataSource implements this. ConnectivityManager checks `instanceof ModeAware - `ModeAware` is a marker interface with a single method: `void switchMode(ConnectionMode newMode)`. All logic lives in `FDv2DataSource`. - Alternative: skip the interface entirely and have ConnectivityManager use `instanceof FDv2DataSource` directly. The interface is a thin abstraction; either approach works. 2. **Add `switchMode(ConnectionMode)` method:** - - Look up the new mode in the mode table to get its `ModeDefinition`. - - Stop current synchronizers (close the active `SourceManager`). - - Create a new `SourceManager` with the new mode's synchronizer factories. - - Signal the background thread to resume the synchronizer loop with new factories. - - Do NOT re-run initializers (spec 2.0.1). + - Look up the new mode in the mode table to get its `ResolvedModeDefinition`. + - Create a new `SourceManager` with the new mode's synchronizer factories (no initializers — spec 2.0.1). + - Swap the `sourceManager` field (now `volatile`) and close the old one to interrupt its active source. + - Schedule a new executor task to run the new mode's synchronizers. + - `runSynchronizers()` takes an explicit `SourceManager` parameter to prevent the `finally` block from closing a SourceManager swapped in by a concurrent `switchMode()`. 3. **Override `needsRefresh()`:** - Return `false` when only the background state changed (mode-aware data source handles this via `switchMode`). - Return `true` when the evaluation context changed (requires full teardown/rebuild). @@ -331,7 +332,7 @@ FDv2DataSource implements this. ConnectivityManager checks `instanceof ModeAware - `DataSource` interface (public API) - `StreamingDataSource`, `PollingDataSource` (FDv1 paths) - `PlatformState`, `AndroidPlatformState` -- `ClientContext`, `ClientContextImpl` +- `ClientContext` (public API) - `LDConfig` public API --- @@ -345,41 +346,52 @@ The work is decomposed into small commits that each build on the previous one. E | File | Description | |------|-------------| | `ConnectionMode.java` | Enum: STREAMING, POLLING, OFFLINE, ONE_SHOT, BACKGROUND | -| `ModeDefinition.java` | `List>` + `List>` + DEFAULT_MODE_TABLE + helper factory methods | -| `ModeState.java` | Platform state for mode resolution: `boolean foreground`, `boolean networkAvailable` | -| `ModeResolutionEntry.java` | Predicate (`Predicate`) + hardcoded `ConnectionMode` | +| `ModeDefinition.java` | `List>` + `List>` + DEFAULT_MODE_TABLE (stubbed configurers, no factory methods) | +| `ModeState.java` | Platform state for mode resolution: `private boolean foreground`, `private boolean networkAvailable` + getters | +| `ModeResolutionEntry.java` | Custom `Condition` functional interface (minSdk 21) + hardcoded `ConnectionMode` | | `ModeResolutionTable.java` | Ordered list + `resolve()` method + MOBILE constant | | `ModeAware.java` | Package-private interface extending DataSource with `switchMode(ConnectionMode)` | Tests: `ModeResolutionTable.resolve()` with various `ModeState` inputs. -### Commit 2: `ModeAware` implementation on `FDv2DataSource` +### Commit 2: `ModeAware` + `switchMode()` implementation on `FDv2DataSource` | File | Description | |------|-------------| -| `FDv2DataSource.java` (modify) | Implement `ModeAware`, override `needsRefresh()`, add stub `switchMode()` | +| `FDv2DataSource.java` (modify) | Implement `ModeAware`, override `needsRefresh()`, add `ResolvedModeDefinition` inner class (resolved factory lists per mode), mode-table constructors, full `switchMode()` implementation, refactor `runSynchronizers()` to take `SourceManager` parameter, guard exhaustion report in `start()` | -Tests: `needsRefresh()` returns false for background-only changes, true for context changes. +Tests: `needsRefresh()` returns false for background-only changes, true for context changes. `switchMode()` activates new mode synchronizers, skips initializers, handles offline/round-trip/no-mode-table/after-stop scenarios. -### Commit 3: `switchMode()` implementation +### Commit 3: `FDv2DataSourceBuilder` (stub resolution) | File | Description | |------|-------------| -| `FDv2DataSource.java` (modify) | Full `switchMode()` — stop synchronizers, swap to new mode's factories, resume | +| `FDv2DataSourceBuilder.java` (new) | `ComponentConfigurer` that builds mode-aware FDv2DataSource | + +The builder resolves `ComponentConfigurer` → `DataSourceFactory` by partially applying the `ClientContext`. This bridges the SDK's `ComponentConfigurer` pattern (used in the mode table) with Todd's `DataSourceFactory` pattern (used inside `FDv2DataSource`). + +The configurers in `ModeDefinition.DEFAULT_MODE_TABLE` are currently stubbed (`ctx -> null`). The builder resolves the full table, constructs a `ScheduledExecutorService` (with a TODO for proper lifecycle management), and returns an `FDv2DataSource` using the mode-table constructor. + +Tests: builder returns non-null `ModeAware`, resolves configurers via `ClientContext`, respects starting mode, throws on missing starting mode, `switchMode()` works across resolved modes. -### Commit 4: `FDv2DataSourceBuilder` +### Commit 4: Wire real `ComponentConfigurer` implementations | File | Description | |------|-------------| -| `FDv2DataSourceBuilder.java` (new) | `ComponentConfigurer` that builds mode-aware FDv2DataSource | +| `StandardEndpoints.java` (modify) | Add FDv2 endpoint path constants (`FDV2_POLLING_REQUEST_GET_BASE_PATH`, `FDV2_POLLING_REQUEST_REPORT_BASE_PATH`, `FDV2_STREAMING_REQUEST_BASE_PATH`) | +| `ClientContextImpl.java` (modify) | Add `TransactionalDataStore` field, getter, 7-arg constructor, and `forDataSource` overload with `@Nullable TransactionalDataStore` parameter | +| `ConnectivityManager.java` (modify) | Store `ContextDataManager` as `TransactionalDataStore` and pass it through the new `forDataSource` overload so it's available to `FDv2DataSourceBuilder` via `ClientContext` | +| `FDv2DataSourceBuilder.java` (modify) | Two build paths: default path creates real factories directly in `buildDefaultModeTable()` (shared `SelectorSource` and `ScheduledExecutorService`, fresh `DefaultFDv2Requestor` per factory call); custom path retains `resolveCustomModeTable()` for testing with mock `ComponentConfigurer`s | -The builder resolves `ComponentConfigurer` → `DataSourceFactory` by partially applying the `ClientContext`. This bridges the SDK's `ComponentConfigurer` pattern (used in the mode table) with Todd's `DataSourceFactory` pattern (used inside `FDv2DataSource`). +This commit wires up the concrete types from Todd's PR #325: `FDv2PollingInitializer`, `FDv2PollingSynchronizer`, `FDv2StreamingSynchronizer`, `DefaultFDv2Requestor`, `SelectorSourceFacade`. The stubs in `ModeDefinition.DEFAULT_MODE_TABLE` remain unchanged — the real factories are constructed directly in the builder's `buildDefaultModeTable()` method. The background poll interval references `LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS`; the foreground poll interval references `PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS`; the streaming reconnect delay references `StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS`. Dependencies are extracted from `ClientContext`/`ClientContextImpl` at build time. ### Commit 5: ConnectivityManager mode resolution integration | File | Description | |------|-------------| -| `ConnectivityManager.java` (modify) | Add mode resolution for `ModeAware` instances in foreground/network listeners | +| `ConnectivityManager.java` (modify) | Removed static import of `ConnectionInformation.ConnectionMode` (qualified all 7 references) to free bare `ConnectionMode` for the FDv2 enum. Added `volatile ConnectionMode currentFDv2Mode` field. Updated foreground, connectivity, and force-offline listeners to check `instanceof ModeAware` and route to `resolveAndSwitchMode()` instead of `updateDataSource()`. Added `eventProcessor.setInBackground()`/`setOffline()` calls in the ModeAware paths (since they bypass `updateDataSource` which normally handles this). Added `resolveAndSwitchMode()` private method: resolves `ModeState` via `ModeResolutionTable.MOBILE`, calls `switchMode()` only when mode changes. Added initial mode resolution after `start()` in `updateDataSource` to correct the builder's default STREAMING mode when the app starts in background. | +| `MockPlatformState.java` (modify) | Added `setAndNotifyConnectivityChangeListeners()` convenience method (mirrors existing `setAndNotifyForegroundChangeListeners()`) | +| `ConnectivityManagerTest.java` (modify) | Added `MockModeAwareDataSource` inner class and 9 new tests covering foreground↔background, network loss/restore, force-offline on/off, no-op on unchanged mode, no teardown on foreground change, and initial background mode resolution | ### Future PR: State debouncing @@ -391,9 +403,7 @@ The builder resolves `ComponentConfigurer` → `DataSo Spec 5.3.8 says the SDK SHOULD retain active data sources when switching modes if the old and new modes have equivalent synchronizer configuration. This avoids unnecessary teardown/rebuild when, for example, a user configures both streaming and background modes to use the same synchronizers. -**Approach: instance equality (`==`) on factories.** The simplest way to determine if two synchronizers are "equivalent" is to check if they are the *same instance*. This works if the `DEFAULT_MODE_TABLE` (and any future user overrides) shares `ComponentConfigurer` instances across modes where the configuration is identical. At build time, `FDv2DataSourceBuilder` resolves each `ComponentConfigurer` into a `DataSourceFactory`. If two modes reference the same `ComponentConfigurer` instance, they get the same `DataSourceFactory` instance, and `==` comparison identifies them as equivalent. - -**Implication for mode table construction:** factory helper methods (e.g., `pollingInitializer()`, `streamingSynchronizer()`) should return shared static instances for the default configurations. Different configurations (e.g., polling at 30s vs. 3600s) produce different instances, so `==` correctly identifies them as non-equivalent. +**Approach: instance equality (`==`) on factories.** The simplest way to determine if two synchronizers are "equivalent" is to check if they are the *same instance*. `FDv2DataSourceBuilder.buildDefaultModeTable()` already shares `DataSourceFactory` instances across modes where the configuration is identical (e.g., `pollingInitFactory` is reused in STREAMING, POLLING, and ONE_SHOT; `foregroundPollSyncFactory` is reused in STREAMING and POLLING). Different configurations (e.g., foreground polling at 5 min vs. background polling at 1 hour) produce different factory instances, so `==` correctly identifies them as non-equivalent. **Implication for `SourceManager`:** Todd is interested in enhancing `SourceManager` to support this optimization. Instead of closing all synchronizers and building new ones on `switchMode()`, `SourceManager` could diff the old and new synchronizer factory lists using `==`, keep running any that are shared, and only tear down removed / start added synchronizers. This change is internal to `SourceManager` and `FDv2DataSource` — it doesn't affect the `ModeAware.switchMode(ConnectionMode)` contract or the mode resolution layer. @@ -410,7 +420,7 @@ Our work builds on two of Todd's branches: | `ta/SDK-1817/composite-src-pt2` | (base) | In progress | `FDv2DataSource`, `SourceManager`, `FDv2DataSourceConditions`, `Initializer`/`Synchronizer` interfaces, `DataSourceFactory`, `FDv2SourceResult`, `DataSourceUpdateSinkV2` | | `ta/SDK-1835/initializers-synchronizers` | [#325](https://github.com/launchdarkly/android-client-sdk/pull/325) | Open | `FDv2PollingInitializer`, `FDv2PollingSynchronizer`, `FDv2StreamingSynchronizer`, `FDv2Requestor`/`DefaultFDv2Requestor`, `FDv2ChangeSetTranslator`, `SelectorSource`/`SelectorSourceFacade`, `LDAsyncQueue`, `LDFutures.anyOf` | -**Our branching strategy:** Branch off `ta/SDK-1835/initializers-synchronizers` (which itself targets `ta/SDK-1817/composite-src-pt2`). Our commits (1–6) can be developed independently of PR #325 merging — we only need the types/interfaces, not the running implementations. However, Commit 5 (`FDv2DataSourceBuilder`) will reference the concrete constructors from PR #325 directly. +**Our branching strategy:** Branch off `ta/SDK-1835/initializers-synchronizers` (which itself targets `ta/SDK-1817/composite-src-pt2`). Our commits (1–5) can be developed independently of PR #325 merging — we only need the types/interfaces, not the running implementations. However, Commit 4 (wiring real `ComponentConfigurer` implementations) will reference the concrete constructors from PR #325 directly. --- @@ -424,20 +434,20 @@ When user-configurable mode selection is added in a future PR, ConnectivityManag ### 2. How does the mode table connect to concrete implementations? -**Answered.** The mode table holds `ComponentConfigurer` and `ComponentConfigurer` entries (the SDK's established factory pattern). At build time, `FDv2DataSourceBuilder.build(clientContext)` resolves each one into a `DataSourceFactory` (Todd's zero-arg factory pattern) by partially applying the `ClientContext`: +**Answered.** `FDv2DataSourceBuilder` has two build paths: -```java -DataSourceFactory factory = () -> configurer.build(clientContext); -``` +1. **Default path** (no custom `ModeDefinition` table): the builder's `buildDefaultModeTable()` method creates `DataSourceFactory` instances directly using dependencies extracted from `ClientContext`. Shared dependencies (`SelectorSource`, `ScheduledExecutorService`) are created once and captured by factory closures. Each factory call creates a fresh `DefaultFDv2Requestor` for lifecycle isolation. The stubs in `ModeDefinition.DEFAULT_MODE_TABLE` are not used in this path. + +2. **Custom path** (for testing): a custom `ModeDefinition` table is resolved by wrapping each `ComponentConfigurer` in a `DataSourceFactory` via `() -> configurer.build(clientContext)`. -The concrete types created by the factory methods (from Todd's PR #325): +The concrete types created by the builder's default path (from Todd's PR #325): -| Factory method | Creates | -|---------------|---------| -| `pollingInitializer()` | `FDv2PollingInitializer(requestor, selectorSource, executor, logger)` | -| `pollingSynchronizer(intervalMs)` | `FDv2PollingSynchronizer(requestor, selectorSource, scheduledExecutor, initialDelayMs, intervalMs, logger)` | -| `streamingSynchronizer()` | `FDv2StreamingSynchronizer(httpProperties, streamBaseUri, requestPath, context, useReport, evaluationReasons, selectorSource, requestor, initialReconnectDelayMs, diagnosticStore, logger)` | -| `cacheInitializer()` | (stubbed for now; cache initializer not yet implemented on Android) | +| Factory | Creates | +|---------|---------| +| Polling initializer | `FDv2PollingInitializer(requestor, selectorSource, executor, logger)` | +| Foreground polling synchronizer | `FDv2PollingSynchronizer(requestor, selectorSource, executor, 0, DEFAULT_POLL_INTERVAL_MILLIS, logger)` | +| Background polling synchronizer | `FDv2PollingSynchronizer(requestor, selectorSource, executor, 0, DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, logger)` | +| Streaming synchronizer | `FDv2StreamingSynchronizer(httpProperties, streamBaseUri, requestPath, context, useReport, evaluationReasons, selectorSource, requestor, initialReconnectDelayMs, diagnosticStore, logger)` | All dependencies come from `ClientContext` at build time. `FDv2DataSource` only works with resolved `DataSourceFactory` instances — it never sees `ComponentConfigurer` or `ClientContext`. @@ -453,9 +463,9 @@ We need to ensure that when mode resolution is active, the existing `updateDataS ### 5. Should `switchMode()` be synchronous or asynchronous? -`switchMode()` is called from listener threads (foreground/network events). FDv2DataSource runs its synchronizer loop on a background thread. The call must signal the background thread to swap synchronizers. +`switchMode()` is called from listener threads (foreground/network events). FDv2DataSource runs its synchronizer loop on a background thread. -Design: `switchMode()` sets the desired mode atomically and closes the current `SourceManager` (which interrupts the active synchronizer's `next()` future). The background thread detects the mode change, creates a new `SourceManager` with the new mode's factories, and re-enters the synchronizer loop. This is the same pattern as the existing `stop()` method. +**Implemented design:** `switchMode()` creates the new `SourceManager` on the calling thread, swaps the `volatile` field, closes the old SourceManager (interrupting its active synchronizer), and schedules a new executor task to run the new mode's synchronizers. Each task receives its `SourceManager` as a parameter (not via the field) so it operates on a stable reference even if another `switchMode()` swaps the field concurrently. The old task exits naturally when its SourceManager is closed; the exhaustion report in `start()` checks `sourceManager == sm` to skip reporting when the SourceManager was replaced by a mode switch. --- diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index b02dccd5..7993acdc 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -1,16 +1,16 @@ package com.launchdarkly.sdk.android; -import androidx.annotation.Nullable; - import com.launchdarkly.logging.LDLogger; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; -import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; +import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import com.launchdarkly.sdk.internal.events.DiagnosticStore; +import androidx.annotation.Nullable; + /** * This package-private subclass of {@link ClientContext} contains additional non-public SDK objects * that may be used by our internal components. @@ -36,6 +36,7 @@ final class ClientContextImpl extends ClientContext { private final PlatformState platformState; private final TaskExecutor taskExecutor; private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + @Nullable private final TransactionalDataStore transactionalDataStore; ClientContextImpl( @@ -56,7 +57,7 @@ final class ClientContextImpl extends ClientContext { PlatformState platformState, TaskExecutor taskExecutor, PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, - TransactionalDataStore transactionalDataStore + @Nullable TransactionalDataStore transactionalDataStore ) { super(base); this.diagnosticStore = diagnosticStore; @@ -119,22 +120,19 @@ public static ClientContextImpl forDataSource( boolean newInBackground, Boolean previouslyInBackground ) { - return forDataSource(baseClientContext, dataSourceUpdateSink, null, - newEvaluationContext, newInBackground, previouslyInBackground); + return forDataSource(baseClientContext, dataSourceUpdateSink, newEvaluationContext, + newInBackground, previouslyInBackground, null); } public static ClientContextImpl forDataSource( ClientContext baseClientContext, DataSourceUpdateSink dataSourceUpdateSink, - @Nullable TransactionalDataStore transactionalDataStore, LDContext newEvaluationContext, boolean newInBackground, - Boolean previouslyInBackground + Boolean previouslyInBackground, + @Nullable TransactionalDataStore transactionalDataStore ) { ClientContextImpl baseContextImpl = ClientContextImpl.get(baseClientContext); - TransactionalDataStore store = transactionalDataStore != null - ? transactionalDataStore - : baseContextImpl.transactionalDataStore; return new ClientContextImpl( new ClientContext( baseClientContext.getMobileKey(), @@ -156,7 +154,7 @@ public static ClientContextImpl forDataSource( baseContextImpl.getPlatformState(), baseContextImpl.getTaskExecutor(), baseContextImpl.getPerEnvironmentData(), - store + transactionalDataStore ); } @@ -197,6 +195,7 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { return throwExceptionIfNull(perEnvironmentData); } + @Nullable public TransactionalDataStore getTransactionalDataStore() { return transactionalDataStore; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java index 789b4efb..31777cef 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -1,17 +1,34 @@ package com.launchdarkly.sdk.android; /** - * Named connection modes for the FDv2 data system. Each mode maps to a - * {@link ModeDefinition} that specifies which initializers and synchronizers to run. + * Enumerates the built-in FDv2 connection modes. Each mode maps to a + * {@link ModeDefinition} that specifies which initializers and synchronizers + * are active when the SDK is operating in that mode. + *

+ * This is a closed enum — custom connection modes (spec 5.3.5 TBD) are not + * supported in this release. *

* Package-private — not part of the public SDK API. * * @see ModeDefinition + * @see ModeResolutionTable */ -enum ConnectionMode { - STREAMING, - POLLING, - OFFLINE, - ONE_SHOT, - BACKGROUND +final class ConnectionMode { + + static final ConnectionMode STREAMING = new ConnectionMode("STREAMING"); + static final ConnectionMode POLLING = new ConnectionMode("POLLING"); + static final ConnectionMode OFFLINE = new ConnectionMode("OFFLINE"); + static final ConnectionMode ONE_SHOT = new ConnectionMode("ONE_SHOT"); + static final ConnectionMode BACKGROUND = new ConnectionMode("BACKGROUND"); + + private final String name; + + private ConnectionMode(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } } 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 199daae4..b8998b45 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 @@ -53,11 +53,11 @@ class ConnectivityManager { private final ClientContext baseClientContext; private final PlatformState platformState; private final ComponentConfigurer dataSourceFactory; - private final TransactionalDataStore transactionalDataStore; private final DataSourceUpdateSink dataSourceUpdateSink; private final ConnectionInformationState connectionInformation; private final PersistentDataStoreWrapper.PerEnvironmentData environmentStore; private final EventProcessor eventProcessor; + private final TransactionalDataStore transactionalDataStore; private final PlatformState.ForegroundChangeListener foregroundListener; private final PlatformState.ConnectivityChangeListener connectivityChangeListener; private final TaskExecutor taskExecutor; @@ -72,6 +72,7 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; + private volatile Map resolvedModeTable; private volatile ConnectionMode currentFDv2Mode; // The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource. @@ -134,11 +135,11 @@ public void shutDown() { ) { this.baseClientContext = clientContext; this.dataSourceFactory = dataSourceFactory; - this.transactionalDataStore = contextDataManager; this.dataSourceUpdateSink = new DataSourceUpdateSinkImpl(contextDataManager); this.platformState = ClientContextImpl.get(clientContext).getPlatformState(); this.eventProcessor = eventProcessor; this.environmentStore = environmentStore; + this.transactionalDataStore = contextDataManager; this.taskExecutor = ClientContextImpl.get(clientContext).getTaskExecutor(); this.logger = clientContext.getBaseLogger(); @@ -153,7 +154,7 @@ public void shutDown() { connectivityChangeListener = networkAvailable -> { DataSource dataSource = currentDataSource.get(); if (dataSource instanceof ModeAware) { - eventProcessor.setOffline(forcedOffline.get() || !networkAvailable); + eventProcessor.setOffline(!networkAvailable); resolveAndSwitchMode((ModeAware) dataSource); } else { updateDataSource(false, LDUtil.noOpCallback()); @@ -244,15 +245,21 @@ private synchronized boolean updateDataSource( ClientContext clientContext = ClientContextImpl.forDataSource( baseClientContext, dataSourceUpdateSink, - transactionalDataStore, context, inBackground, - previouslyInBackground.get() + previouslyInBackground.get(), + transactionalDataStore ); DataSource dataSource = dataSourceFactory.build(clientContext); currentDataSource.set(dataSource); previouslyInBackground.set(Boolean.valueOf(inBackground)); + if (dataSourceFactory instanceof FDv2DataSourceBuilder) { + FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; + resolvedModeTable = fdv2Builder.getResolvedModeTable(); + currentFDv2Mode = fdv2Builder.getStartingMode(); + } + dataSource.start(new Callback() { @Override public void onSuccess(Boolean result) { @@ -272,10 +279,9 @@ public void onError(Throwable error) { } }); - // Resolve the initial mode after start() so that switchMode() can safely replace - // the source manager without conflicting with the start() task submission. + // If the app starts in the background, the builder creates the data source with + // STREAMING as the starting mode. Perform an initial mode resolution to correct this. if (dataSource instanceof ModeAware) { - currentFDv2Mode = ConnectionMode.STREAMING; resolveAndSwitchMode((ModeAware) dataSource); } @@ -423,31 +429,6 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return updateDataSource(true, onCompletion); } - /** - * Resolves the current platform state to a {@link ConnectionMode} using the mode resolution - * table, and calls {@link ModeAware#switchMode} if the resolved mode differs from the current - * mode. This replaces the legacy teardown/rebuild cycle for FDv2 data sources. - */ - private void resolveAndSwitchMode(ModeAware modeAware) { - ConnectionMode resolvedMode; - if (forcedOffline.get()) { - resolvedMode = ConnectionMode.OFFLINE; - } else { - ModeState state = new ModeState( - platformState.isForeground(), - platformState.isNetworkAvailable() - ); - resolvedMode = ModeResolutionTable.MOBILE.resolve(state); - } - - ConnectionMode previousMode = currentFDv2Mode; - if (previousMode != resolvedMode) { - logger.debug("Switching FDv2 data source mode: {} -> {}", previousMode, resolvedMode); - currentFDv2Mode = resolvedMode; - modeAware.switchMode(resolvedMode); - } - } - /** * Permanently stops data updating for the current client instance. We call this if the client * is being closed, or if we receive an error that indicates the mobile key is invalid. @@ -481,6 +462,37 @@ boolean isForcedOffline() { return forcedOffline.get(); } + /** + * Resolves the current platform state to a ConnectionMode via the ModeResolutionTable, + * looks up the ResolvedModeDefinition from the resolved mode table, and calls + * switchMode() on the data source if the mode has changed. + */ + private void resolveAndSwitchMode(@NonNull ModeAware modeAware) { + Map table = resolvedModeTable; + if (table == null) { + return; + } + boolean forceOffline = forcedOffline.get(); + boolean networkAvailable = platformState.isNetworkAvailable(); + boolean foreground = platformState.isForeground(); + ModeState state = new ModeState( + foreground && !forceOffline, + networkAvailable && !forceOffline + ); + ConnectionMode newMode = ModeResolutionTable.MOBILE.resolve(state); + if (newMode == currentFDv2Mode) { + return; + } + currentFDv2Mode = newMode; + ResolvedModeDefinition def = table.get(newMode); + if (def == null) { + logger.warn("No resolved definition for mode {}; skipping switchMode", newMode); + return; + } + logger.debug("Switching FDv2 mode to {}", newMode); + modeAware.switchMode(def); + } + synchronized ConnectionInformation getConnectionInformation() { return connectionInformation; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index b52b15da..ca1dc1ed 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -15,7 +15,6 @@ import java.util.ArrayList; import java.util.Collections; -import java.util.EnumMap; import java.util.List; import java.util.Map; import java.util.concurrent.CancellationException; @@ -39,37 +38,9 @@ public interface DataSourceFactory { T build(); } - /** - * A resolved mode definition holding factories that are ready to use (already bound to - * their ClientContext). Produced by FDv2DataSourceBuilder from ComponentConfigurer entries. - */ - static final class ResolvedModeDefinition { - private final List> initializers; - private final List> synchronizers; - - ResolvedModeDefinition( - @NonNull List> initializers, - @NonNull List> synchronizers - ) { - this.initializers = Collections.unmodifiableList(new ArrayList<>(initializers)); - this.synchronizers = Collections.unmodifiableList(new ArrayList<>(synchronizers)); - } - - @NonNull - List> getInitializers() { - return initializers; - } - - @NonNull - List> getSynchronizers() { - return synchronizers; - } - } - private final LDLogger logger; private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; - private final Map modeTable; private volatile SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; @@ -129,7 +100,6 @@ List> getSynchronizers() { this.evaluationContext = evaluationContext; this.dataSourceUpdateSink = dataSourceUpdateSink; this.logger = logger; - this.modeTable = null; List synchronizerFactoriesWithState = new ArrayList<>(); for (DataSourceFactory factory : synchronizers) { synchronizerFactoriesWithState.add(new SynchronizerFactoryWithState(factory)); @@ -140,74 +110,6 @@ List> getSynchronizers() { this.sharedExecutor = sharedExecutor; } - /** - * Mode-aware convenience constructor using default fallback and recovery timeouts. - * - * @param evaluationContext the context to evaluate flags for - * @param modeTable resolved mode definitions keyed by ConnectionMode - * @param startingMode the initial connection mode - * @param dataSourceUpdateSink sink to apply changesets and status updates to - * @param sharedExecutor executor used for internal background tasks - * @param logger logger - */ - FDv2DataSource( - @NonNull LDContext evaluationContext, - @NonNull Map modeTable, - @NonNull ConnectionMode startingMode, - @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, - @NonNull ScheduledExecutorService sharedExecutor, - @NonNull LDLogger logger - ) { - this(evaluationContext, modeTable, startingMode, dataSourceUpdateSink, sharedExecutor, logger, - FDv2DataSourceConditions.DEFAULT_FALLBACK_TIMEOUT_SECONDS, - FDv2DataSourceConditions.DEFAULT_RECOVERY_TIMEOUT_SECONDS); - } - - /** - * Mode-aware constructor. The mode table maps each {@link ConnectionMode} to a - * {@link ResolvedModeDefinition} containing pre-built factories. The starting mode - * determines the initial set of initializers and synchronizers. - * - * @param evaluationContext the context to evaluate flags for - * @param modeTable resolved mode definitions keyed by ConnectionMode - * @param startingMode the initial connection mode - * @param dataSourceUpdateSink sink to apply changesets and status updates to - * @param sharedExecutor executor used for internal background tasks; must have - * at least 2 threads - * @param logger logger - * @param fallbackTimeoutSeconds seconds of INTERRUPTED state before falling back - * @param recoveryTimeoutSeconds seconds before attempting to recover to the primary - * synchronizer - */ - FDv2DataSource( - @NonNull LDContext evaluationContext, - @NonNull Map modeTable, - @NonNull ConnectionMode startingMode, - @NonNull DataSourceUpdateSinkV2 dataSourceUpdateSink, - @NonNull ScheduledExecutorService sharedExecutor, - @NonNull LDLogger logger, - long fallbackTimeoutSeconds, - long recoveryTimeoutSeconds - ) { - this.evaluationContext = evaluationContext; - this.dataSourceUpdateSink = dataSourceUpdateSink; - this.logger = logger; - this.modeTable = Collections.unmodifiableMap(new EnumMap<>(modeTable)); - this.fallbackTimeoutSeconds = fallbackTimeoutSeconds; - this.recoveryTimeoutSeconds = recoveryTimeoutSeconds; - this.sharedExecutor = sharedExecutor; - - ResolvedModeDefinition startDef = modeTable.get(startingMode); - if (startDef == null) { - throw new IllegalArgumentException("No mode definition for starting mode: " + startingMode); - } - List syncFactories = new ArrayList<>(); - for (DataSourceFactory factory : startDef.getSynchronizers()) { - syncFactories.add(new SynchronizerFactoryWithState(factory)); - } - this.sourceManager = new SourceManager(syncFactories, new ArrayList<>(startDef.getInitializers())); - } - @Override public void start(@NonNull Callback resultCallback) { synchronized (startResultLock) { @@ -235,20 +137,21 @@ public void start(@NonNull Callback resultCallback) { // race with a concurrent stop() and could undo it, causing a spurious OFF/exhaustion report. LDContext context = evaluationContext; + final SourceManager sm = sourceManager; sharedExecutor.execute(() -> { try { - if (!sourceManager.hasAvailableSources()) { + if (!sm.hasAvailableSources()) { logger.info("No initializers or synchronizers; data source will not connect."); dataSourceUpdateSink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); return; } - if (sourceManager.hasInitializers()) { + if (sm.hasInitializers()) { runInitializers(context, dataSourceUpdateSink); } - if (!sourceManager.hasAvailableSynchronizers()) { + if (!sm.hasAvailableSynchronizers()) { if (!startCompleted.get()) { maybeReportUnexpectedExhaustion("All initializers exhausted and there are no available synchronizers."); } @@ -256,10 +159,9 @@ public void start(@NonNull Callback resultCallback) { return; } - SourceManager sm = sourceManager; - runSynchronizers(sm, context, dataSourceUpdateSink); - // Only report exhaustion if the SourceManager was NOT replaced by a - // concurrent switchMode() call; a mode switch is not an error. + runSynchronizers(context, dataSourceUpdateSink, sm); + // Only report exhaustion if this SourceManager is still the active one + // (a concurrent switchMode() may have replaced it). if (sourceManager == sm) { maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); } @@ -319,68 +221,29 @@ public void stop(@NonNull Callback completionCallback) { @Override public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { - // Mode-aware data sources handle foreground/background transitions via switchMode(), - // so only a context change requires a full teardown/rebuild (to re-run initializers). - return !newEvaluationContext.equals(evaluationContext); + // Mode-aware data sources handle background/foreground transitions via switchMode(), + // so only request a full rebuild when the evaluation context changes. + return !evaluationContext.equals(newEvaluationContext); } - /** - * Switches to a new connection mode by tearing down the current synchronizers and - * starting the new mode's synchronizers on the background executor. Initializers are - * NOT re-run (spec CONNMODE 2.0.1). - *

- * Expected to be called from a single thread (ConnectivityManager's listener). The - * field swap is not atomic; concurrent calls from multiple threads could leave an - * intermediate SourceManager unclosed. - */ @Override - public void switchMode(@NonNull ConnectionMode newMode) { - if (modeTable == null) { - logger.warn("switchMode({}) called but no mode table configured", newMode); - return; - } - if (stopped.get()) { - return; - } - ResolvedModeDefinition def = modeTable.get(newMode); - if (def == null) { - logger.error("switchMode({}) failed: no definition found", newMode); - return; - } - - // Build new SourceManager with the mode's synchronizer factories. - // Initializers are NOT included — spec 2.0.1: mode switch does not re-run initializers. - List syncFactories = new ArrayList<>(); - for (DataSourceFactory factory : def.getSynchronizers()) { - syncFactories.add(new SynchronizerFactoryWithState(factory)); + public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { + List newSyncFactories = new ArrayList<>(); + for (DataSourceFactory factory : newDefinition.getSynchronizerFactories()) { + newSyncFactories.add(new SynchronizerFactoryWithState(factory)); } + // Per CONNMODE 2.0.1: mode switches only transition synchronizers, no initializers. SourceManager newManager = new SourceManager( - syncFactories, Collections.>emptyList()); - - // Swap the source manager and close the old one to interrupt its active source. + newSyncFactories, + Collections.>emptyList() + ); SourceManager oldManager = sourceManager; sourceManager = newManager; if (oldManager != null) { oldManager.close(); } - - // Run the new mode's synchronizers on the background thread. - LDContext context = evaluationContext; sharedExecutor.execute(() -> { - try { - if (!newManager.hasAvailableSynchronizers()) { - logger.debug("Mode {} has no synchronizers; data source idle", newMode); - return; - } - runSynchronizers(newManager, context, dataSourceUpdateSink); - // Report exhaustion only if we weren't replaced by another switchMode(). - if (sourceManager == newManager && !stopped.get()) { - maybeReportUnexpectedExhaustion( - "All synchronizers exhausted after mode switch to " + newMode); - } - } catch (Throwable t) { - logger.warn("FDv2DataSource error after mode switch to {}: {}", newMode, t.toString()); - } + runSynchronizers(evaluationContext, dataSourceUpdateSink, newManager); }); } @@ -464,9 +327,9 @@ private List getConditions(int synchronizerC } private void runSynchronizers( - @NonNull SourceManager sm, @NonNull LDContext context, - @NonNull DataSourceUpdateSinkV2 sink + @NonNull DataSourceUpdateSinkV2 sink, + @NonNull SourceManager sm ) { try { Synchronizer synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 7cb5c7d8..1b786dc5 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -1,236 +1,244 @@ package com.launchdarkly.sdk.android; import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.launchdarkly.logging.LDLogger; -import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; -import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; +import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink; import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; +import com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; -import com.launchdarkly.sdk.internal.events.DiagnosticStore; import com.launchdarkly.sdk.internal.http.HttpProperties; import java.net.URI; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; /** - * Builds a mode-aware {@link FDv2DataSource} from either a custom {@link ModeDefinition} table - * or the built-in default mode definitions. - *

- * When no custom table is supplied, the builder creates concrete {@link FDv2PollingInitializer}, - * {@link FDv2PollingSynchronizer}, and {@link FDv2StreamingSynchronizer} factories using - * dependencies extracted from the {@link ClientContext}. Shared dependencies (executor, - * {@link SelectorSource}) are created once and captured by all factory closures. Each factory - * call creates fresh instances of requestors and concrete sources to ensure proper lifecycle - * management. + * Builds an {@link FDv2DataSource} and resolves the mode table from + * {@link ComponentConfigurer} factories into zero-arg {@link FDv2DataSource.DataSourceFactory} + * instances. The resolved table is stored and exposed via {@link #getResolvedModeTable()} + * so that {@link ConnectivityManager} can perform mode→definition lookups when switching modes. *

- * When a custom table is supplied (for testing), each {@link ComponentConfigurer} is resolved - * into a {@link FDv2DataSource.DataSourceFactory} by partially applying the - * {@link ClientContext}. + * This is the key architectural difference in Approach 2: the builder owns the resolved + * table rather than the data source itself. *

* Package-private — not part of the public SDK API. - * - * @see ModeDefinition - * @see FDv2DataSource.ResolvedModeDefinition */ -final class FDv2DataSourceBuilder implements ComponentConfigurer { +class FDv2DataSourceBuilder implements ComponentConfigurer { - @Nullable private final Map modeTable; private final ConnectionMode startingMode; - /** - * Creates a builder using the built-in default mode definitions and - * {@link ConnectionMode#STREAMING} as the starting mode. - */ + private Map resolvedModeTable; + FDv2DataSourceBuilder() { - this(null, ConnectionMode.STREAMING); + this(makeDefaultModeTable(), ConnectionMode.STREAMING); + } + + private static Map makeDefaultModeTable() { + ComponentConfigurer pollingInitializer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + return new FDv2PollingInitializer(requestor, selectorSource, + Executors.newSingleThreadExecutor(), ctx.getBaseLogger()); + }; + + ComponentConfigurer pollingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); + return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); + }; + + ComponentConfigurer streamingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI streamBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getStreamingBaseUri(), + StandardEndpoints.DEFAULT_STREAMING_BASE_URI, + "streaming", ctx.getBaseLogger()); + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + return new FDv2StreamingSynchronizer( + ctx.getEvaluationContext(), selectorSource, streamBase, + StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, + requestor, + StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, + ctx.isEvaluationReasons(), ctx.getHttp().isUseReport(), + httpProps, Executors.newSingleThreadExecutor(), + ctx.getBaseLogger(), null); + }; + + ComponentConfigurer backgroundPollingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); + return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); + }; + + Map table = new LinkedHashMap<>(); + table.put(ConnectionMode.STREAMING, new ModeDefinition( + Arrays.asList(pollingInitializer, pollingInitializer), + Arrays.asList(streamingSynchronizer, pollingSynchronizer) + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.singletonList(pollingSynchronizer) + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.>emptyList() + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + Arrays.asList(pollingInitializer, pollingInitializer, pollingInitializer), + Collections.>emptyList() + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.singletonList(backgroundPollingSynchronizer) + )); + return table; } - /** - * @param modeTable custom mode definitions to resolve at build time, or {@code null} - * to use the built-in defaults - * @param startingMode the initial connection mode for the data source - */ FDv2DataSourceBuilder( - @Nullable Map modeTable, + @NonNull Map modeTable, @NonNull ConnectionMode startingMode ) { - this.modeTable = modeTable != null - ? Collections.unmodifiableMap(new EnumMap<>(modeTable)) - : null; + this.modeTable = Collections.unmodifiableMap(new LinkedHashMap<>(modeTable)); this.startingMode = startingMode; } - @Override - public DataSource build(ClientContext clientContext) { - // TODO: executor lifecycle — FDv2DataSource does not shut down its executor. - // In a future commit, this should be replaced with an executor obtained from - // ClientContextImpl or managed by ConnectivityManager. - ScheduledExecutorService executor = Executors.newScheduledThreadPool(2); - - Map resolved; - if (modeTable != null) { - resolved = resolveCustomModeTable(clientContext); - } else { - resolved = buildDefaultModeTable(clientContext, executor); - } - - DataSourceUpdateSinkV2 sinkV2 = - (DataSourceUpdateSinkV2) clientContext.getDataSourceUpdateSink(); - - return new FDv2DataSource( - clientContext.getEvaluationContext(), - resolved, - startingMode, - sinkV2, - executor, - clientContext.getBaseLogger() - ); - } - /** - * Builds the default mode table with real factories. Shared dependencies are created - * once and captured by factory closures; each factory call creates fresh instances of - * requestors and concrete sources. + * Returns the resolved mode table after {@link #build} has been called. + * Each entry maps a {@link ConnectionMode} to a {@link ResolvedModeDefinition} + * containing zero-arg factories that capture the {@link ClientContext}. + * + * @return unmodifiable map of resolved mode definitions + * @throws IllegalStateException if called before {@link #build} */ - private Map buildDefaultModeTable( - ClientContext clientContext, - ScheduledExecutorService executor - ) { - ClientContextImpl impl = ClientContextImpl.get(clientContext); - HttpProperties httpProperties = LDUtil.makeHttpProperties(clientContext); - LDContext evalContext = clientContext.getEvaluationContext(); - LDLogger logger = clientContext.getBaseLogger(); - boolean useReport = clientContext.getHttp().isUseReport(); - boolean evaluationReasons = clientContext.isEvaluationReasons(); - URI pollingBaseUri = clientContext.getServiceEndpoints().getPollingBaseUri(); - URI streamingBaseUri = clientContext.getServiceEndpoints().getStreamingBaseUri(); - DiagnosticStore diagnosticStore = impl.getDiagnosticStore(); - TransactionalDataStore txnStore = impl.getTransactionalDataStore(); - SelectorSource selectorSource = new SelectorSourceFacade(txnStore); - - // Each factory creates a fresh requestor so that lifecycle (close/shutdown) is isolated - // per initializer/synchronizer instance. - FDv2DataSource.DataSourceFactory pollingInitFactory = () -> - new FDv2PollingInitializer( - newRequestor(evalContext, pollingBaseUri, httpProperties, - useReport, evaluationReasons, logger), - selectorSource, executor, logger); - - FDv2DataSource.DataSourceFactory streamingSyncFactory = () -> - new FDv2StreamingSynchronizer( - httpProperties, streamingBaseUri, - StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, - evalContext, useReport, evaluationReasons, selectorSource, - newRequestor(evalContext, pollingBaseUri, httpProperties, - useReport, evaluationReasons, logger), - StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, - diagnosticStore, logger); - - FDv2DataSource.DataSourceFactory foregroundPollSyncFactory = () -> - new FDv2PollingSynchronizer( - newRequestor(evalContext, pollingBaseUri, httpProperties, - useReport, evaluationReasons, logger), - selectorSource, executor, - 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, logger); - - FDv2DataSource.DataSourceFactory backgroundPollSyncFactory = () -> - new FDv2PollingSynchronizer( - newRequestor(evalContext, pollingBaseUri, httpProperties, - useReport, evaluationReasons, logger), - selectorSource, executor, - 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, logger); - - Map resolved = - new EnumMap<>(ConnectionMode.class); - - // STREAMING: poll once for initial data, then stream (with polling fallback) - resolved.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.singletonList(pollingInitFactory), - Arrays.asList(streamingSyncFactory, foregroundPollSyncFactory))); - - // POLLING: poll once for initial data, then poll periodically - resolved.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( - Collections.singletonList(pollingInitFactory), - Collections.singletonList(foregroundPollSyncFactory))); - - // OFFLINE: no network activity - resolved.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( - Collections.>emptyList(), - Collections.>emptyList())); - - // ONE_SHOT: poll once, then stop - resolved.put(ConnectionMode.ONE_SHOT, new FDv2DataSource.ResolvedModeDefinition( - Collections.singletonList(pollingInitFactory), - Collections.>emptyList())); - - // BACKGROUND: poll at reduced frequency (no re-initialization) - resolved.put(ConnectionMode.BACKGROUND, new FDv2DataSource.ResolvedModeDefinition( - Collections.>emptyList(), - Collections.singletonList(backgroundPollSyncFactory))); - - return resolved; + @NonNull + ConnectionMode getStartingMode() { + return startingMode; } - /** - * Resolves a custom {@link ModeDefinition} table by wrapping each {@link ComponentConfigurer} - * in a {@link FDv2DataSource.DataSourceFactory} that defers to - * {@code configurer.build(clientContext)}. - */ - private Map resolveCustomModeTable( - ClientContext clientContext - ) { - Map resolved = - new EnumMap<>(ConnectionMode.class); + @NonNull + Map getResolvedModeTable() { + if (resolvedModeTable == null) { + throw new IllegalStateException("build() must be called before getResolvedModeTable()"); + } + return resolvedModeTable; + } + @Override + public DataSource build(ClientContext clientContext) { + Map resolved = new LinkedHashMap<>(); for (Map.Entry entry : modeTable.entrySet()) { - ModeDefinition def = entry.getValue(); - - List> initFactories = new ArrayList<>(); - for (ComponentConfigurer configurer : def.getInitializers()) { - initFactories.add(() -> configurer.build(clientContext)); - } + resolved.put(entry.getKey(), resolve(entry.getValue(), clientContext)); + } + this.resolvedModeTable = Collections.unmodifiableMap(resolved); - List> syncFactories = new ArrayList<>(); - for (ComponentConfigurer configurer : def.getSynchronizers()) { - syncFactories.add(() -> configurer.build(clientContext)); - } + ResolvedModeDefinition startDef = resolvedModeTable.get(startingMode); + if (startDef == null) { + throw new IllegalStateException( + "Starting mode " + startingMode + " not found in mode table"); + } - resolved.put(entry.getKey(), new FDv2DataSource.ResolvedModeDefinition( - initFactories, syncFactories)); + DataSourceUpdateSink baseSink = clientContext.getDataSourceUpdateSink(); + if (!(baseSink instanceof DataSourceUpdateSinkV2)) { + throw new IllegalStateException( + "FDv2DataSource requires a DataSourceUpdateSinkV2 implementation"); } - return resolved; + ScheduledExecutorService sharedExecutor = Executors.newScheduledThreadPool(2); + + return new FDv2DataSource( + clientContext.getEvaluationContext(), + startDef.getInitializerFactories(), + startDef.getSynchronizerFactories(), + (DataSourceUpdateSinkV2) baseSink, + sharedExecutor, + clientContext.getBaseLogger() + ); } - private static DefaultFDv2Requestor newRequestor( - LDContext evalContext, - URI pollingBaseUri, - HttpProperties httpProperties, - boolean useReport, - boolean evaluationReasons, - LDLogger logger + private static ResolvedModeDefinition resolve( + ModeDefinition def, ClientContext clientContext ) { - return new DefaultFDv2Requestor( - evalContext, pollingBaseUri, - StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, - StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, - httpProperties, useReport, evaluationReasons, - null, logger); + List> initFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getInitializers()) { + initFactories.add(() -> configurer.build(clientContext)); + } + List> syncFactories = new ArrayList<>(); + for (ComponentConfigurer configurer : def.getSynchronizers()) { + syncFactories.add(() -> configurer.build(clientContext)); + } + return new ResolvedModeDefinition(initFactories, syncFactories); } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java index 14825583..d4902f33 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java @@ -10,19 +10,26 @@ * {@link ConnectivityManager} checks {@code instanceof ModeAware} to decide * whether to use mode resolution (FDv2) or legacy teardown/rebuild behavior (FDv1). *

+ * In this approach (Approach 2), the data source receives the full + * {@link ResolvedModeDefinition} — it has no internal mode table and does not + * know which named {@link ConnectionMode} it is operating in. The mode table + * and mode-to-definition lookup live in {@link ConnectivityManager}. + *

* Package-private — not part of the public SDK API. * - * @see ConnectionMode + * @see ResolvedModeDefinition * @see ModeResolutionTable */ interface ModeAware extends DataSource { /** - * Switches the data source to the specified connection mode. The implementation - * stops the current synchronizers and starts the new mode's synchronizers without - * re-running initializers (per CONNMODE spec 2.0.1). + * Switches the data source to operate with the given mode definition. + * The implementation stops the current synchronizers and starts the new + * definition's synchronizers without re-running initializers + * (per CONNMODE spec 2.0.1). * - * @param newMode the target connection mode + * @param newDefinition the resolved initializer/synchronizer factories for + * the target mode */ - void switchMode(@NonNull ConnectionMode newMode); + void switchMode(@NonNull ResolvedModeDefinition newDefinition); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java index cd33312f..81d69b8f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -10,7 +10,7 @@ import java.util.List; /** - * Defines the initializer and synchronizer pipelines for a {@link ConnectionMode}. + * Defines the initializers and synchronizers for a single {@link ConnectionMode}. * Each instance is a pure data holder — it stores {@link ComponentConfigurer} factories * but does not create any concrete initializer or synchronizer objects. *

@@ -21,6 +21,7 @@ * Package-private — not part of the public SDK API. * * @see ConnectionMode + * @see ResolvedModeDefinition */ final class ModeDefinition { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java index 9b3a1c49..80fd1982 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java @@ -3,23 +3,21 @@ import androidx.annotation.NonNull; /** - * A single entry in a {@link ModeResolutionTable}. Pairs a condition with a - * target {@link ConnectionMode}. If {@link Condition#test(ModeState)} returns - * {@code true} for a given {@link ModeState}, this entry's {@code mode} is the - * resolved result. - *

- * When user-configurable mode selection is added, {@code mode} can be replaced - * with a resolver function to support indirection (e.g., returning a - * user-configured foreground mode from {@code ModeState}). + * A single entry in a {@link ModeResolutionTable}. Pairs a {@link Condition} + * predicate with the {@link ConnectionMode} that should be activated when the + * condition matches the current {@link ModeState}. *

* Package-private — not part of the public SDK API. + * + * @see ModeResolutionTable + * @see ModeState */ final class ModeResolutionEntry { /** - * Functional interface for evaluating whether a {@link ModeResolutionEntry} - * matches a given {@link ModeState}. Defined here to avoid a dependency on - * {@code java.util.function.Predicate} (requires API 24+; SDK minimum is 21). + * Functional interface for evaluating a {@link ModeState} against a condition. + * Defined here (rather than using {@code java.util.function.Predicate}) because + * {@code Predicate} requires API 24+ and the SDK targets minSdk 21. */ interface Condition { boolean test(@NonNull ModeState state); @@ -28,10 +26,7 @@ interface Condition { private final Condition conditions; private final ConnectionMode mode; - ModeResolutionEntry( - @NonNull Condition conditions, - @NonNull ConnectionMode mode - ) { + ModeResolutionEntry(@NonNull Condition conditions, @NonNull ConnectionMode mode) { this.conditions = conditions; this.mode = mode; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java index f3942927..1450f052 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java @@ -1,14 +1,15 @@ package com.launchdarkly.sdk.android; /** - * Snapshot of platform state used as input to {@link ModeResolutionTable#resolve(ModeState)}. + * Snapshot of the current platform state used as input to + * {@link ModeResolutionTable#resolve(ModeState)}. *

- * In this initial implementation, {@code ModeState} carries only platform state with - * hardcoded Android defaults for foreground/background modes. When user-configurable - * mode selection is added (CONNMODE 2.2.2), {@code foregroundMode} and - * {@code backgroundMode} fields will be introduced here. + * Immutable value object — all fields are set in the constructor with no setters. *

* Package-private — not part of the public SDK API. + * + * @see ModeResolutionTable + * @see ModeResolutionEntry */ final class ModeState { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java new file mode 100644 index 00000000..fccffc7a --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -0,0 +1,48 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.Collections; +import java.util.List; + +/** + * A fully resolved mode definition containing zero-arg factories for initializers + * and synchronizers. This is the result of resolving a {@link ModeDefinition}'s + * {@link com.launchdarkly.sdk.android.subsystems.ComponentConfigurer} entries against + * a {@link com.launchdarkly.sdk.android.subsystems.ClientContext}. + *

+ * Instances are immutable and created by {@code FDv2DataSourceBuilder} at build time. + * {@link ConnectivityManager} passes these to {@link ModeAware#switchMode} when the + * resolved connection mode changes. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + * @see ModeAware + */ +final class ResolvedModeDefinition { + + private final List> initializerFactories; + private final List> synchronizerFactories; + + ResolvedModeDefinition( + @NonNull List> initializerFactories, + @NonNull List> synchronizerFactories + ) { + this.initializerFactories = Collections.unmodifiableList(initializerFactories); + this.synchronizerFactories = Collections.unmodifiableList(synchronizerFactories); + } + + @NonNull + List> getInitializerFactories() { + return initializerFactories; + } + + @NonNull + List> getSynchronizerFactories() { + return synchronizerFactories; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java index 2f2e60bd..06c51e32 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/StandardEndpoints.java @@ -14,14 +14,12 @@ private StandardEndpoints() {} static final String STREAMING_REQUEST_BASE_PATH = "/meval"; static final String POLLING_REQUEST_GET_BASE_PATH = "/msdk/evalx/contexts"; static final String POLLING_REQUEST_REPORT_BASE_PATH = "/msdk/evalx/context"; + static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; + static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; - // FDv2 paths per CSFDV2 Requirement 2.1.1 (unified for all client-side platforms). - // Context is appended as a base64 path segment for GET, or sent in the request body for REPORT/POST. static final String FDV2_POLLING_REQUEST_GET_BASE_PATH = "/sdk/poll/eval"; static final String FDV2_POLLING_REQUEST_REPORT_BASE_PATH = "/sdk/poll/eval"; static final String FDV2_STREAMING_REQUEST_BASE_PATH = "/sdk/stream/eval"; - static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; - static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; /** * Internal method to decide which URI a given component should connect to. 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 1697e5c9..f9c73606 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 @@ -37,8 +37,15 @@ import org.junit.Test; import org.junit.rules.Timeout; +import com.launchdarkly.sdk.android.subsystems.DataSourceState; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + import java.io.IOException; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.BlockingQueue; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutionException; @@ -658,47 +665,38 @@ private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } - // --- ModeAware (FDv2) tests --- + // ==== ModeAware tests ==== /** - * A minimal {@link ModeAware} data source that tracks {@code switchMode()} calls. - * Mimics the threading behavior of real data sources by calling the start callback - * on a background thread after reporting initial status. + * A mock ModeAware data source that records switchMode calls and + * signals start success immediately. */ private static class MockModeAwareDataSource implements ModeAware { - final BlockingQueue switchModeCalls = - new LinkedBlockingQueue<>(); - final BlockingQueue startedQueue; - final BlockingQueue stoppedQueue; - final ClientContext clientContext; - - MockModeAwareDataSource( - ClientContext clientContext, - BlockingQueue startedQueue, - BlockingQueue stoppedQueue - ) { - this.clientContext = clientContext; + final BlockingQueue switchModeCalls = new LinkedBlockingQueue<>(); + private final BlockingQueue startedQueue; + private volatile com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink; + + MockModeAwareDataSource(BlockingQueue startedQueue) { this.startedQueue = startedQueue; - this.stoppedQueue = stoppedQueue; + } + + void setSink(com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink) { + this.sink = sink; } @Override public void start(@NonNull Callback resultCallback) { - if (startedQueue != null) { - startedQueue.add(this); - } + startedQueue.add(this); new Thread(() -> { - clientContext.getDataSourceUpdateSink().setStatus( - ConnectionMode.STREAMING, null); + if (sink != null) { + sink.setStatus(ConnectionMode.STREAMING, null); + } resultCallback.onSuccess(true); }).start(); } @Override public void stop(@NonNull Callback completionCallback) { - if (stoppedQueue != null) { - stoppedQueue.add(this); - } completionCallback.onSuccess(null); } @@ -708,261 +706,191 @@ public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvalu } @Override - public void switchMode(@NonNull com.launchdarkly.sdk.android.ConnectionMode newMode) { - switchModeCalls.add(newMode); - } - - com.launchdarkly.sdk.android.ConnectionMode requireSwitchMode() { - return requireValue(switchModeCalls, 1, TimeUnit.SECONDS, - "switchMode call"); - } - - void requireNoMoreSwitchModeCalls() { - requireNoMoreValues(switchModeCalls, 100, TimeUnit.MILLISECONDS, - "unexpected switchMode call"); + public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { + switchModeCalls.add(newDefinition); } } - private ComponentConfigurer makeModeAwareDataSourceFactory( - MockModeAwareDataSource[] holder + /** + * Creates a test FDv2DataSourceBuilder that returns a MockModeAwareDataSource. + * The resolved mode table contains STREAMING, BACKGROUND, and OFFLINE modes. + */ + private FDv2DataSourceBuilder makeModeAwareDataSourceFactory( + MockModeAwareDataSource mockDataSource ) { - return clientContext -> { - receivedClientContexts.add(clientContext); - MockModeAwareDataSource ds = new MockModeAwareDataSource( - clientContext, startedDataSources, stoppedDataSources); - holder[0] = ds; - return ds; + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + return new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + super.build(clientContext); + receivedClientContexts.add(clientContext); + mockDataSource.setSink(clientContext.getDataSourceUpdateSink()); + return mockDataSource; + } }; } @Test - public void modeAwareForegroundToBackgroundSwitchesMode() throws Exception { + public void modeAware_foregroundToBackground_switchesMode() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); - - // Initial mode: STREAMING (foreground + network available). - // resolveAndSwitchMode was called after start() but resolved STREAMING = no-op. - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, newMode); - + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode call for background transition", def); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); + verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAwareBackgroundToForegroundSwitchesMode() throws Exception { - mockPlatformState.setForeground(false); - + public void modeAware_backgroundToForeground_switchesMode() throws Exception { eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); eventProcessor.setInBackground(true); eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - // Initial resolution: background → switchMode(BACKGROUND) - com.launchdarkly.sdk.android.ConnectionMode initialMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, initialMode); + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + ResolvedModeDefinition def1 = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode for background", def1); mockPlatformState.setAndNotifyForegroundChangeListeners(true); - - com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, newMode); - - verifyAll(); - verifyNoMoreDataSourcesWereStopped(); - } - - @Test - public void modeAwareNetworkLostSwitchesMode() throws Exception { - eventProcessor.setOffline(false); - eventProcessor.setInBackground(false); - eventProcessor.setOffline(true); - replayAll(); - - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); - awaitStartUp(); - - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); - - mockPlatformState.setAndNotifyConnectivityChangeListeners(false); - - com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, newMode); + ResolvedModeDefinition def2 = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode for foreground", def2); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); + verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAwareNetworkRestoredSwitchesMode() throws Exception { + public void modeAware_networkLost_switchesToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); - eventProcessor.setOffline(false); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); - - // Lose network mockPlatformState.setAndNotifyConnectivityChangeListeners(false); - com.launchdarkly.sdk.android.ConnectionMode offlineMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, offlineMode); - // Restore network - mockPlatformState.setAndNotifyConnectivityChangeListeners(true); - com.launchdarkly.sdk.android.ConnectionMode restoredMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, restoredMode); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode call for offline", def); + assertTrue("OFFLINE mode should have no synchronizers", + def.getSynchronizerFactories().isEmpty()); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); + verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAwareSetForceOfflineSwitchesMode() throws Exception { + public void modeAware_forceOffline_switchesToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); - - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); connectivityManager.setForceOffline(true); - com.launchdarkly.sdk.android.ConnectionMode newMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, newMode); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode call for forced offline", def); + assertTrue("OFFLINE mode should have no synchronizers", + def.getSynchronizerFactories().isEmpty()); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); } @Test - public void modeAwareUnsetForceOfflineResolvesMode() throws Exception { + public void modeAware_doesNotTearDownOnForegroundChange() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); - eventProcessor.setOffline(true); - eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); - - connectivityManager.setForceOffline(true); - com.launchdarkly.sdk.android.ConnectionMode offlineMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.OFFLINE, offlineMode); - - connectivityManager.setForceOffline(false); - com.launchdarkly.sdk.android.ConnectionMode restoredMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, restoredMode); + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull(def); + verifyNoMoreDataSourcesWereCreated(); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); } @Test - public void modeAwareDoesNotSwitchWhenModeUnchanged() throws Exception { + public void modeAware_sameModeDoesNotTriggerSwitch() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); - eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); + // Fire a foreground event when already in foreground — should not trigger switchMode + mockPlatformState.setAndNotifyForegroundChangeListeners(true); - // Network is already available; re-notifying should not trigger switchMode - mockPlatformState.setAndNotifyConnectivityChangeListeners(true); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(500, TimeUnit.MILLISECONDS); + assertNull("Should not switchMode when mode hasn't changed", def); - ds.requireNoMoreSwitchModeCalls(); verifyAll(); - verifyNoMoreDataSourcesWereStopped(); } @Test - public void modeAwareDoesNotTearDownOnForegroundChange() throws Exception { + public void modeAware_switchModePassesResolvedDefinition() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); + MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); + createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); awaitStartUp(); - - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - ds.requireNoMoreSwitchModeCalls(); - - verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - ds.requireSwitchMode(); - verifyNoMoreDataSourcesWereCreated(); - verifyNoMoreDataSourcesWereStopped(); - verifyAll(); - } - - @Test - public void modeAwareStartsInBackgroundResolvesToBackground() throws Exception { - mockPlatformState.setForeground(false); - - eventProcessor.setOffline(false); - eventProcessor.setInBackground(true); - replayAll(); - - MockModeAwareDataSource[] holder = new MockModeAwareDataSource[1]; - createTestManager(false, false, makeModeAwareDataSourceFactory(holder)); - awaitStartUp(); - - MockModeAwareDataSource ds = holder[0]; - assertNotNull(ds); - - // Builder default is STREAMING, but we start in background, so - // resolveAndSwitchMode should immediately switch to BACKGROUND - com.launchdarkly.sdk.android.ConnectionMode initialMode = ds.requireSwitchMode(); - assertEquals(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, initialMode); - ds.requireNoMoreSwitchModeCalls(); + ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); + assertNotNull("Expected switchMode call", def); + assertNotNull("Definition should have synchronizer factories", def.getSynchronizerFactories()); + assertNotNull("Definition should have initializer factories", def.getInitializerFactories()); verifyAll(); } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index a6b8f518..3e15d053 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -2,180 +2,126 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; -import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; - -import androidx.annotation.NonNull; +import static org.junit.Assert.fail; import com.launchdarkly.sdk.LDContext; -import com.launchdarkly.sdk.android.DataModel.Flag; import com.launchdarkly.sdk.android.LDConfig.Builder.AutoEnvAttributes; import com.launchdarkly.sdk.android.env.EnvironmentReporterBuilder; import com.launchdarkly.sdk.android.env.IEnvironmentReporter; import com.launchdarkly.sdk.android.subsystems.ClientContext; import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; import com.launchdarkly.sdk.android.subsystems.DataSource; -import com.launchdarkly.sdk.android.subsystems.HttpConfiguration; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; -import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; -import com.launchdarkly.sdk.fdv2.ChangeSet; -import com.launchdarkly.sdk.fdv2.Selector; import org.junit.Rule; import org.junit.Test; import java.util.Collections; -import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; public class FDv2DataSourceBuilderTest { - private static final LDContext CONTEXT = LDContext.create("builder-test-key"); + private static final LDContext CONTEXT = LDContext.create("test-context"); private static final IEnvironmentReporter ENV_REPORTER = new EnvironmentReporterBuilder().build(); @Rule public LogCaptureRule logging = new LogCaptureRule(); - /** - * Creates a minimal ClientContext for tests that use a custom mode table. - * No TransactionalDataStore or HTTP config needed — the custom path - * only wraps ComponentConfigurers in DataSourceFactory lambdas. - */ - private ClientContext makeMinimalClientContext() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - return new ClientContext( - "mobile-key", null, logging.logger, null, sink, - "", false, CONTEXT, null, false, null, null, false - ); - } - - /** - * Creates a ClientContext backed by a real ClientContextImpl with HTTP config, - * ServiceEndpoints, and a TransactionalDataStore. Used by tests that exercise - * the default (real-wiring) build path. - */ - private ClientContext makeFullClientContext() { + private ClientContext makeClientContext() { LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - - // Two-phase ClientContext creation: first without HTTP config to bootstrap it, - // then with the resolved HTTP config — mirrors ClientContextImpl.fromConfig(). - ClientContext bootstrap = new ClientContext( - "mobile-key", ENV_REPORTER, logging.logger, config, - null, "", false, CONTEXT, null, false, null, - config.serviceEndpoints, false - ); - HttpConfiguration httpConfig = config.http.build(bootstrap); - - ClientContext base = new ClientContext( - "mobile-key", ENV_REPORTER, logging.logger, config, - sink, "", false, CONTEXT, httpConfig, false, null, - config.serviceEndpoints, false + return new ClientContext( + "mobile-key", + ENV_REPORTER, + logging.logger, + config, + sink, + "default", + false, + CONTEXT, + null, + false, + null, + config.serviceEndpoints, + false ); - - TransactionalDataStore mockStore = new TransactionalDataStore() { - @Override - public void apply(@NonNull LDContext context, - @NonNull ChangeSet> changeSet) { } - - @NonNull - @Override - public Selector getSelector() { - return Selector.EMPTY; - } - }; - - return new ClientContextImpl(base, null, null, null, null, null, mockStore); } - // --- Default constructor tests (real wiring path) --- - @Test - public void build_defaultConstructor_returnsNonNullModeAwareDataSource() { + public void defaultBuilder_buildsFDv2DataSource() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeFullClientContext()); + DataSource ds = builder.build(makeClientContext()); assertNotNull(ds); + assertTrue(ds instanceof FDv2DataSource); assertTrue(ds instanceof ModeAware); } @Test - public void build_defaultConstructor_allModesResolved() { + public void resolvedModeTable_availableAfterBuild() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - DataSource ds = builder.build(makeFullClientContext()); - - ModeAware modeAware = (ModeAware) ds; - for (ConnectionMode mode : ConnectionMode.values()) { - modeAware.switchMode(mode); - } + builder.build(makeClientContext()); + Map table = builder.getResolvedModeTable(); + assertNotNull(table); + assertEquals(5, table.size()); } - // --- Custom mode table tests (configurer resolution path) --- - - @Test - public void build_customTable_resolvesConfigurersViaClientContext() { - AtomicReference capturedContext = new AtomicReference<>(); - ComponentConfigurer trackingConfigurer = ctx -> { - capturedContext.set(ctx); - return null; - }; - - Map customTable = new EnumMap<>(ConnectionMode.class); - customTable.put(ConnectionMode.STREAMING, new ModeDefinition( - Collections.>emptyList(), - Collections.singletonList(trackingConfigurer) - )); - - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - ClientContext ctx = makeMinimalClientContext(); - DataSource ds = builder.build(ctx); - - assertNotNull(ds); - assertNull(trackingConfigurer.build(ctx)); - assertEquals(ctx, capturedContext.get()); + @Test(expected = IllegalStateException.class) + public void resolvedModeTable_throwsBeforeBuild() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + builder.getResolvedModeTable(); } @Test - public void build_customTable_usesProvidedStartingMode() { - Map customTable = new EnumMap<>(ConnectionMode.class); + public void customModeTable_resolvesCorrectly() { + Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>emptyList() + Collections.>singletonList(ctx -> null) )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); - DataSource ds = builder.build(makeMinimalClientContext()); + DataSource ds = builder.build(makeClientContext()); assertNotNull(ds); + + Map table = builder.getResolvedModeTable(); + assertEquals(1, table.size()); + assertTrue(table.containsKey(ConnectionMode.POLLING)); } - @Test(expected = IllegalArgumentException.class) - public void build_customTable_throwsWhenStartingModeNotInTable() { - Map customTable = new EnumMap<>(ConnectionMode.class); + @Test + public void startingMode_notInTable_throws() { + Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), - Collections.>emptyList() + Collections.>singletonList(ctx -> null) )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - builder.build(makeMinimalClientContext()); + try { + builder.build(makeClientContext()); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not found in mode table")); + } } @Test - public void build_customTable_resolvesAllModesAndSupportsSwitchMode() { - Map customTable = new EnumMap<>(ConnectionMode.class); + public void resolvedDefinition_hasSameSizeAsOriginal() { + Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( - Collections.>emptyList(), - Collections.singletonList(ctx -> null) - )); - customTable.put(ConnectionMode.OFFLINE, new ModeDefinition( - Collections.>emptyList(), - Collections.>emptyList() + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - DataSource ds = builder.build(makeMinimalClientContext()); - assertNotNull(ds); - ((ModeAware) ds).switchMode(ConnectionMode.OFFLINE); + builder.build(makeClientContext()); + + ResolvedModeDefinition def = builder.getResolvedModeTable().get(ConnectionMode.STREAMING); + assertNotNull(def); + assertEquals(1, def.getInitializerFactories().size()); + assertEquals(1, def.getSynchronizerFactories().size()); } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index 2d8ba4a9..fa569509 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -33,7 +33,6 @@ import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; -import java.util.EnumMap; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -1401,275 +1400,164 @@ public void statusTransitionsFromValidToOffWhenAllSynchronizersFail() throws Exc assertNotNull(sink.getLastError()); } - // ============================================================================ - // needsRefresh — ModeAware behavior - // ============================================================================ - - @Test - public void needsRefresh_sameContextDifferentBackground_returnsFalse() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); - - assertFalse(dataSource.needsRefresh(true, CONTEXT)); - assertFalse(dataSource.needsRefresh(false, CONTEXT)); - } - - @Test - public void needsRefresh_differentContext_returnsTrue() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); - - LDContext otherContext = LDContext.create("other-context"); - assertTrue(dataSource.needsRefresh(false, otherContext)); - assertTrue(dataSource.needsRefresh(true, otherContext)); - } - @Test - public void needsRefresh_differentContextAndBackground_returnsTrue() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.emptyList()); - - LDContext otherContext = LDContext.create("other-context"); - assertTrue(dataSource.needsRefresh(true, otherContext)); - } - - @Test - public void needsRefresh_equalContextInstance_returnsFalse() { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - LDContext context = LDContext.create("test-context"); - FDv2DataSource dataSource = new FDv2DataSource( - context, Collections.emptyList(), Collections.emptyList(), - sink, executor, logging.logger); - - LDContext sameValueContext = LDContext.create("test-context"); - assertFalse(dataSource.needsRefresh(false, sameValueContext)); - assertFalse(dataSource.needsRefresh(true, sameValueContext)); - } - - // ============================================================================ - // switchMode — ModeAware behavior - // ============================================================================ - - private FDv2DataSource buildModeAwareDataSource( - MockComponents.MockDataSourceUpdateSink sink, - Map modeTable, - ConnectionMode startingMode) { - return new FDv2DataSource( - CONTEXT, modeTable, startingMode, - sink, executor, logging.logger); - } - - @Test - public void switchMode_activatesNewModeSynchronizer() throws Exception { + public void stopReportsOffStatus() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - CountDownLatch pollingCreated = new CountDownLatch(1); - - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.singletonList(() -> { - pollingCreated.countDown(); - return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); - }) - )); + FDv2SourceResult.changeSet(makeChangeSet(false))))); - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - - dataSource.switchMode(ConnectionMode.POLLING); - assertTrue(pollingCreated.await(2, TimeUnit.SECONDS)); + assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); - // Both streaming and polling changesets should have been applied - sink.awaitApplyCount(2, 2, TimeUnit.SECONDS); - assertEquals(2, sink.getApplyCount()); + DataSourceState validStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(DataSourceState.VALID, validStatus); stopDataSource(dataSource); + + DataSourceState offStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); + assertEquals(DataSourceState.OFF, offStatus); } + // ==== switchMode tests ==== + @Test - public void switchMode_doesNotReRunInitializers() throws Exception { + public void switchMode_replacesActiveSynchronizer() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + CountDownLatch oldSyncStarted = new CountDownLatch(1); + CountDownLatch oldSyncClosed = new CountDownLatch(1); - AtomicInteger initializerBuildCount = new AtomicInteger(0); - CountDownLatch pollingSyncCreated = new CountDownLatch(1); + MockQueuedSynchronizer oldSync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)) + ) { + @Override + public LDAwaitFuture next() { + oldSyncStarted.countDown(); + return super.next(); + } + @Override + public void close() { + super.close(); + oldSyncClosed.countDown(); + } + }; - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.singletonList(() -> { - initializerBuildCount.incrementAndGet(); - return new MockInitializer(FDv2SourceResult.changeSet(makeChangeSet(true))); - }), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> { - pollingSyncCreated.countDown(); - return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); - }) - )); - - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - assertEquals(1, initializerBuildCount.get()); - - dataSource.switchMode(ConnectionMode.POLLING); - assertTrue(pollingSyncCreated.await(2, TimeUnit.SECONDS)); + Collections.singletonList(() -> oldSync)); + + startDataSource(dataSource); + assertTrue(oldSyncStarted.await(2, TimeUnit.SECONDS)); + assertEquals(DataSourceState.VALID, sink.awaitStatus(2, TimeUnit.SECONDS)); + + CountDownLatch newSyncStarted = new CountDownLatch(1); + MockQueuedSynchronizer newSync = new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)) + ) { + @Override + public LDAwaitFuture next() { + newSyncStarted.countDown(); + return super.next(); + } + }; + ResolvedModeDefinition newDef = new ResolvedModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(() -> newSync) + ); - // Initializer count should still be 1 — mode switch skips initializers (spec 2.0.1) - assertEquals(1, initializerBuildCount.get()); + dataSource.switchMode(newDef); - stopDataSource(dataSource); + assertTrue("Old synchronizer should be closed", oldSyncClosed.await(2, TimeUnit.SECONDS)); + assertTrue("New synchronizer should start", newSyncStarted.await(2, TimeUnit.SECONDS)); } @Test - public void switchMode_toModeWithNoSynchronizers_doesNotCrash() throws Exception { + public void switchMode_doesNotReRunInitializers() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + AtomicInteger initializerRunCount = new AtomicInteger(0); - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - modeTable.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.emptyList() - )); + FDv2DataSource.DataSourceFactory initFactory = () -> { + initializerRunCount.incrementAndGet(); + return new MockInitializer( + FDv2SourceResult.changeSet(makeChangeSet(true))); + }; - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - - dataSource.switchMode(ConnectionMode.OFFLINE); - Thread.sleep(200); // allow mode switch to complete - - stopDataSource(dataSource); - } - - @Test - public void switchMode_fromOfflineBackToStreaming_resumesSynchronizers() throws Exception { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - - AtomicInteger streamingSyncBuildCount = new AtomicInteger(0); + CountDownLatch syncStarted = new CountDownLatch(1); + FDv2DataSource dataSource = buildDataSource(sink, + Collections.singletonList(initFactory), + Collections.singletonList(() -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)) + ) { + @Override + public LDAwaitFuture next() { + syncStarted.countDown(); + return super.next(); + } + })); - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.singletonList(() -> { - streamingSyncBuildCount.incrementAndGet(); - return new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))); - }) - )); - modeTable.put(ConnectionMode.OFFLINE, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.emptyList() - )); + startDataSource(dataSource); + assertTrue(syncStarted.await(2, TimeUnit.SECONDS)); + assertEquals(1, initializerRunCount.get()); - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - assertEquals(1, streamingSyncBuildCount.get()); + ResolvedModeDefinition newDef = new ResolvedModeDefinition( + Collections.>emptyList(), + Collections.>singletonList( + () -> new MockQueuedSynchronizer( + FDv2SourceResult.changeSet(makeChangeSet(false)))) + ); - // Switch to offline — no synchronizers - dataSource.switchMode(ConnectionMode.OFFLINE); + dataSource.switchMode(newDef); Thread.sleep(200); - - // Switch back to streaming — new synchronizer should be created - dataSource.switchMode(ConnectionMode.STREAMING); - Thread.sleep(500); - - // A new streaming sync was created (2 total: one from start, one from mode switch back) - assertEquals(2, streamingSyncBuildCount.get()); - - // Both streaming changesets (initial + resumed) should have been applied - sink.awaitApplyCount(2, 2, TimeUnit.SECONDS); - assertEquals(2, sink.getApplyCount()); - - stopDataSource(dataSource); + assertEquals("Initializers should NOT run again on switchMode", 1, initializerRunCount.get()); } @Test - public void switchMode_withNoModeTable_isNoOp() throws Exception { + public void switchMode_toEmptySynchronizers_closesOld() throws Exception { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + CountDownLatch oldClosed = new CountDownLatch(1); - // Use legacy constructor (no mode table) FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); + FDv2SourceResult.changeSet(makeChangeSet(false)) + ) { + @Override + public void close() { + super.close(); + oldClosed.countDown(); + } + })); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); + startDataSource(dataSource); + assertEquals(DataSourceState.VALID, sink.awaitStatus(2, TimeUnit.SECONDS)); - // Should not crash; logs a warning and returns - dataSource.switchMode(ConnectionMode.POLLING); - Thread.sleep(100); + ResolvedModeDefinition offlineDef = new ResolvedModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + ); - stopDataSource(dataSource); + dataSource.switchMode(offlineDef); + assertTrue("Old synchronizer should be closed", oldClosed.await(2, TimeUnit.SECONDS)); } @Test - public void switchMode_afterStop_isNoOp() throws Exception { + public void needsRefresh_sameContext_returnsFalse() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - - Map modeTable = new EnumMap<>(ConnectionMode.class); - modeTable.put(ConnectionMode.STREAMING, new FDv2DataSource.ResolvedModeDefinition( - Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - modeTable.put(ConnectionMode.POLLING, new FDv2DataSource.ResolvedModeDefinition( + FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - )); - - FDv2DataSource dataSource = buildModeAwareDataSource(sink, modeTable, ConnectionMode.STREAMING); - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(2000)); - - stopDataSource(dataSource); - - // Should not crash or schedule new work after stop - dataSource.switchMode(ConnectionMode.POLLING); - Thread.sleep(100); + Collections.emptyList()); + assertFalse(dataSource.needsRefresh(true, CONTEXT)); + assertFalse(dataSource.needsRefresh(false, CONTEXT)); } - // ============================================================================ - // Status Reporting - // ============================================================================ - @Test - public void stopReportsOffStatus() throws Exception { + public void needsRefresh_differentContext_returnsTrue() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - FDv2DataSource dataSource = buildDataSource(sink, Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false))))); - - AwaitableCallback startCallback = startDataSource(dataSource); - assertTrue(startCallback.await(AWAIT_TIMEOUT_SECONDS * 1000)); - - DataSourceState validStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertEquals(DataSourceState.VALID, validStatus); - - stopDataSource(dataSource); - - DataSourceState offStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); - assertEquals(DataSourceState.OFF, offStatus); + Collections.emptyList()); + assertTrue(dataSource.needsRefresh(false, LDContext.create("other-context"))); } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java index 2beec0fc..b4c559fc 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -1,94 +1,82 @@ package com.launchdarkly.sdk.android; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; import org.junit.Test; import java.util.Arrays; import java.util.Collections; -/** - * Unit tests for {@link ModeResolutionTable} and the {@link ModeResolutionTable#MOBILE} constant. - */ public class ModeResolutionTableTest { - // ==== MOBILE table — standard Android resolution ==== + // ==== MOBILE table tests ==== @Test public void mobile_foregroundWithNetwork_resolvesToStreaming() { ModeState state = new ModeState(true, true); - assertEquals(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); + assertSame(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); } @Test public void mobile_backgroundWithNetwork_resolvesToBackground() { ModeState state = new ModeState(false, true); - assertEquals(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); + assertSame(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); } @Test - public void mobile_foregroundNoNetwork_resolvesToOffline() { + public void mobile_foregroundWithoutNetwork_resolvesToOffline() { ModeState state = new ModeState(true, false); - assertEquals(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); } @Test - public void mobile_backgroundNoNetwork_resolvesToOffline() { + public void mobile_backgroundWithoutNetwork_resolvesToOffline() { ModeState state = new ModeState(false, false); - assertEquals(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); } - // ==== resolve() — first match wins ==== + // ==== Custom table tests ==== @Test - public void resolve_firstMatchWins_evenIfLaterEntryAlsoMatches() { + public void customTable_firstMatchWins() { ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( new ModeResolutionEntry(state -> true, ConnectionMode.POLLING), new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) )); - assertEquals(ConnectionMode.POLLING, table.resolve(new ModeState(true, true))); + assertSame(ConnectionMode.POLLING, table.resolve(new ModeState(true, true))); } - @Test - public void resolve_skipsNonMatchingEntries() { - ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( - new ModeResolutionEntry(state -> false, ConnectionMode.POLLING), - new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) - )); - assertEquals(ConnectionMode.STREAMING, table.resolve(new ModeState(true, true))); - } - - @Test - public void resolve_singleEntry() { - ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( - new ModeResolutionEntry(state -> true, ConnectionMode.OFFLINE) - )); - assertEquals(ConnectionMode.OFFLINE, table.resolve(new ModeState(false, false))); + @Test(expected = IllegalStateException.class) + public void emptyTable_throws() { + ModeResolutionTable table = new ModeResolutionTable(Collections.emptyList()); + table.resolve(new ModeState(true, true)); } @Test(expected = IllegalStateException.class) - public void resolve_noMatchingEntry_throws() { + public void noMatch_throws() { ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( - new ModeResolutionEntry(state -> false, ConnectionMode.OFFLINE) + new ModeResolutionEntry(state -> false, ConnectionMode.STREAMING) )); table.resolve(new ModeState(true, true)); } - @Test(expected = IllegalStateException.class) - public void resolve_emptyTable_throws() { - ModeResolutionTable table = new ModeResolutionTable( - Collections.emptyList() - ); - table.resolve(new ModeState(true, true)); + // ==== ModeState tests ==== + + @Test + public void modeState_getters() { + ModeState state = new ModeState(true, false); + assertEquals(true, state.isForeground()); + assertEquals(false, state.isNetworkAvailable()); } - // ==== Network takes priority over lifecycle ==== + // ==== ModeResolutionEntry tests ==== @Test - public void mobile_networkUnavailable_alwaysResolvesToOffline_regardlessOfForeground() { - assertEquals(ConnectionMode.OFFLINE, - ModeResolutionTable.MOBILE.resolve(new ModeState(true, false))); - assertEquals(ConnectionMode.OFFLINE, - ModeResolutionTable.MOBILE.resolve(new ModeState(false, false))); + public void modeResolutionEntry_getters() { + ModeResolutionEntry.Condition cond = state -> true; + ModeResolutionEntry entry = new ModeResolutionEntry(cond, ConnectionMode.OFFLINE); + assertSame(cond, entry.getConditions()); + assertSame(ConnectionMode.OFFLINE, entry.getMode()); } } From 9795623da07c79e8228aa143c28c17e72180977a Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Mar 2026 15:15:57 -0700 Subject: [PATCH 09/14] [SDK-1956] refactor: ModeAware no longer extends DataSource FDv2DataSource now explicitly implements both DataSource and ModeAware, keeping the two interfaces independent. Made-with: Cursor --- .../launchdarkly/sdk/android/FDv2DataSource.java | 3 ++- .../com/launchdarkly/sdk/android/ModeAware.java | 14 ++++++-------- .../sdk/android/ConnectivityManagerTest.java | 2 +- 3 files changed, 9 insertions(+), 10 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index ca1dc1ed..6da69fc7 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -11,6 +11,7 @@ import com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSinkV2; import com.launchdarkly.sdk.android.subsystems.FDv2SourceResult; import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.DataSource; import com.launchdarkly.sdk.android.subsystems.Synchronizer; import java.util.ArrayList; @@ -29,7 +30,7 @@ * switch to next synchronizer) and recovery (when on non-prime synchronizer, try * to return to the first after timeout). */ -final class FDv2DataSource implements ModeAware { +final class FDv2DataSource implements DataSource, ModeAware { /** * Factory for creating Initializer or Synchronizer instances. diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java index d4902f33..929f1d4a 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java @@ -2,25 +2,23 @@ import androidx.annotation.NonNull; -import com.launchdarkly.sdk.android.subsystems.DataSource; - /** - * A {@link DataSource} that supports runtime connection mode switching. + * Supports runtime connection mode switching. *

* {@link ConnectivityManager} checks {@code instanceof ModeAware} to decide * whether to use mode resolution (FDv2) or legacy teardown/rebuild behavior (FDv1). *

- * In this approach (Approach 2), the data source receives the full - * {@link ResolvedModeDefinition} — it has no internal mode table and does not - * know which named {@link ConnectionMode} it is operating in. The mode table - * and mode-to-definition lookup live in {@link ConnectivityManager}. + * The data source receives the full {@link ResolvedModeDefinition} — it has no + * internal mode table and does not know which named {@link ConnectionMode} it is + * operating in. The mode table and mode-to-definition lookup live in + * {@link ConnectivityManager}. *

* Package-private — not part of the public SDK API. * * @see ResolvedModeDefinition * @see ModeResolutionTable */ -interface ModeAware extends DataSource { +interface ModeAware { /** * Switches the data source to operate with the given mode definition. 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 f9c73606..d34ca129 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 @@ -671,7 +671,7 @@ private void verifyNoMoreDataSourcesWereStopped() { * A mock ModeAware data source that records switchMode calls and * signals start success immediately. */ - private static class MockModeAwareDataSource implements ModeAware { + private static class MockModeAwareDataSource implements DataSource, ModeAware { final BlockingQueue switchModeCalls = new LinkedBlockingQueue<>(); private final BlockingQueue startedQueue; private volatile com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink; From 1612d840e108e611fa15971e80ef274f9b82ead6 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Mar 2026 15:51:03 -0700 Subject: [PATCH 10/14] [SDK-1956] refactor: separate event processor and data source logic in ConnectivityManager Extract updateEventProcessor() and handleModeStateChange() so that event processor state (setOffline, setInBackground) is managed independently from data source lifecycle. Both platform listeners and setForceOffline() now route through handleModeStateChange(), which snapshots state once and updates each subsystem separately. Made-with: Cursor --- .../sdk/android/ConnectivityManager.java | 61 ++++++++++--------- .../sdk/android/ConnectivityManagerTest.java | 8 +++ 2 files changed, 40 insertions(+), 29 deletions(-) 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 b8998b45..ace1f26f 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 @@ -151,27 +151,10 @@ public void shutDown() { readStoredConnectionState(); this.backgroundUpdatingDisabled = ldConfig.isDisableBackgroundPolling(); - connectivityChangeListener = networkAvailable -> { - DataSource dataSource = currentDataSource.get(); - if (dataSource instanceof ModeAware) { - eventProcessor.setOffline(!networkAvailable); - resolveAndSwitchMode((ModeAware) dataSource); - } else { - updateDataSource(false, LDUtil.noOpCallback()); - } - }; + connectivityChangeListener = networkAvailable -> handleModeStateChange(); platformState.addConnectivityChangeListener(connectivityChangeListener); - foregroundListener = foreground -> { - DataSource dataSource = currentDataSource.get(); - if (dataSource instanceof ModeAware) { - eventProcessor.setInBackground(!foreground); - resolveAndSwitchMode((ModeAware) dataSource); - } else if (dataSource == null || dataSource.needsRefresh(!foreground, - currentContext.get())) { - updateDataSource(true, LDUtil.noOpCallback()); - } - }; + foregroundListener = foreground -> handleModeStateChange(); platformState.addForegroundChangeListener(foregroundListener); } @@ -190,6 +173,7 @@ void switchToContext(@NonNull LDContext context, @NonNull Callback onCompl onCompletion.onSuccess(null); } else { if (dataSource == null || dataSource.needsRefresh(!platformState.isForeground(), context)) { + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); updateDataSource(true, onCompletion); } else { onCompletion.onSuccess(null); @@ -210,9 +194,6 @@ private synchronized boolean updateDataSource( boolean inBackground = !platformState.isForeground(); LDContext context = currentContext.get(); - eventProcessor.setOffline(forceOffline || !networkEnabled); - eventProcessor.setInBackground(inBackground); - boolean shouldStopExistingDataSource = true, shouldStartDataSourceIfStopped = false; @@ -426,6 +407,7 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return false; } initialized = false; + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); return updateDataSource(true, onCompletion); } @@ -448,13 +430,7 @@ void shutDown() { void setForceOffline(boolean forceOffline) { boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline); if (forceOffline != wasForcedOffline) { - DataSource dataSource = currentDataSource.get(); - if (dataSource instanceof ModeAware) { - eventProcessor.setOffline(forceOffline || !platformState.isNetworkAvailable()); - resolveAndSwitchMode((ModeAware) dataSource); - } else { - updateDataSource(false, LDUtil.noOpCallback()); - } + handleModeStateChange(); } } @@ -462,6 +438,33 @@ boolean isForcedOffline() { return forcedOffline.get(); } + private void updateEventProcessor(boolean forceOffline, boolean networkAvailable, boolean foreground) { + eventProcessor.setOffline(forceOffline || !networkAvailable); + eventProcessor.setInBackground(!foreground); + } + + /** + * 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. + */ + private void handleModeStateChange() { + boolean forceOffline = forcedOffline.get(); + boolean networkAvailable = platformState.isNetworkAvailable(); + boolean foreground = platformState.isForeground(); + + updateEventProcessor(forceOffline, networkAvailable, foreground); + + DataSource dataSource = currentDataSource.get(); + if (dataSource instanceof ModeAware) { + resolveAndSwitchMode((ModeAware) dataSource); + } else if (dataSource != null && dataSource.needsRefresh(!foreground, currentContext.get())) { + updateDataSource(true, LDUtil.noOpCallback()); + } else { + updateDataSource(false, LDUtil.noOpCallback()); + } + } + /** * Resolves the current platform state to a ConnectionMode via the ModeResolutionTable, * looks up the ResolvedModeDefinition from the resolved mode table, and calls 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 d34ca129..018a674c 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 @@ -746,6 +746,7 @@ public DataSource build(ClientContext clientContext) { public void modeAware_foregroundToBackground_switchesMode() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); @@ -766,7 +767,9 @@ public void modeAware_foregroundToBackground_switchesMode() throws Exception { public void modeAware_backgroundToForeground_switchesMode() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(true); + eventProcessor.setOffline(false); eventProcessor.setInBackground(false); replayAll(); @@ -792,6 +795,7 @@ public void modeAware_networkLost_switchesToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); replayAll(); MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); @@ -815,6 +819,7 @@ public void modeAware_forceOffline_switchesToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); replayAll(); MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); @@ -836,6 +841,7 @@ public void modeAware_forceOffline_switchesToOffline() throws Exception { public void modeAware_doesNotTearDownOnForegroundChange() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); @@ -856,6 +862,7 @@ public void modeAware_doesNotTearDownOnForegroundChange() throws Exception { public void modeAware_sameModeDoesNotTriggerSwitch() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(false); replayAll(); @@ -877,6 +884,7 @@ public void modeAware_sameModeDoesNotTriggerSwitch() throws Exception { public void modeAware_switchModePassesResolvedDefinition() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); From 403ec8b89bfb631b3d269b46834c416485d342bc Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Tue, 17 Mar 2026 16:25:39 -0700 Subject: [PATCH 11/14] [SDK-1956] refactor: move synchronizer switching into SourceManager to prevent race condition SourceManager now owns a switchSynchronizers() method that atomically swaps the synchronizer list under the existing lock, eliminating the window where two runSynchronizers() loops could push data into the update sink concurrently. FDv2DataSource keeps a single final SourceManager and uses an AtomicBoolean guard to ensure only one execution loop runs at a time. Made-with: Cursor --- .../sdk/android/FDv2DataSource.java | 207 ++++++++---------- .../sdk/android/SourceManager.java | 20 +- 2 files changed, 115 insertions(+), 112 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 6da69fc7..e47cd80f 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -42,7 +42,7 @@ public interface DataSourceFactory { private final LDLogger logger; private final LDContext evaluationContext; private final DataSourceUpdateSinkV2 dataSourceUpdateSink; - private volatile SourceManager sourceManager; + private final SourceManager sourceManager; private final long fallbackTimeoutSeconds; private final long recoveryTimeoutSeconds; private final ScheduledExecutorService sharedExecutor; @@ -50,6 +50,7 @@ public interface DataSourceFactory { private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicBoolean startCompleted = new AtomicBoolean(false); private final AtomicBoolean stopped = new AtomicBoolean(false); + private final AtomicBoolean executionLoopRunning = new AtomicBoolean(false); /** Result of the first start (null = not yet completed). Used so second start() gets the same result. */ private volatile Boolean startResult = null; @@ -138,21 +139,21 @@ public void start(@NonNull Callback resultCallback) { // race with a concurrent stop() and could undo it, causing a spurious OFF/exhaustion report. LDContext context = evaluationContext; - final SourceManager sm = sourceManager; sharedExecutor.execute(() -> { + executionLoopRunning.set(true); try { - if (!sm.hasAvailableSources()) { + if (!sourceManager.hasAvailableSources()) { logger.info("No initializers or synchronizers; data source will not connect."); dataSourceUpdateSink.setStatus(DataSourceState.VALID, null); tryCompleteStart(true, null); return; } - if (sm.hasInitializers()) { + if (sourceManager.hasInitializers()) { runInitializers(context, dataSourceUpdateSink); } - if (!sm.hasAvailableSynchronizers()) { + if (!sourceManager.hasAvailableSynchronizers()) { if (!startCompleted.get()) { maybeReportUnexpectedExhaustion("All initializers exhausted and there are no available synchronizers."); } @@ -160,16 +161,14 @@ public void start(@NonNull Callback resultCallback) { return; } - runSynchronizers(context, dataSourceUpdateSink, sm); - // Only report exhaustion if this SourceManager is still the active one - // (a concurrent switchMode() may have replaced it). - if (sourceManager == sm) { - maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); - } + runSynchronizers(context, dataSourceUpdateSink); + maybeReportUnexpectedExhaustion("All data source acquisition methods have been exhausted."); tryCompleteStart(false, null); } catch (Throwable t) { logger.warn("FDv2DataSource error: {}", t.toString()); tryCompleteStart(false, t); + } finally { + executionLoopRunning.set(false); } }); } @@ -233,18 +232,17 @@ public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { for (DataSourceFactory factory : newDefinition.getSynchronizerFactories()) { newSyncFactories.add(new SynchronizerFactoryWithState(factory)); } - // Per CONNMODE 2.0.1: mode switches only transition synchronizers, no initializers. - SourceManager newManager = new SourceManager( - newSyncFactories, - Collections.>emptyList() - ); - SourceManager oldManager = sourceManager; - sourceManager = newManager; - if (oldManager != null) { - oldManager.close(); - } + sourceManager.switchSynchronizers(newSyncFactories); + sharedExecutor.execute(() -> { - runSynchronizers(evaluationContext, dataSourceUpdateSink, newManager); + if (!executionLoopRunning.compareAndSet(false, true)) { + return; + } + try { + runSynchronizers(evaluationContext, dataSourceUpdateSink); + } finally { + executionLoopRunning.set(false); + } }); } @@ -329,106 +327,93 @@ private List getConditions(int synchronizerC private void runSynchronizers( @NonNull LDContext context, - @NonNull DataSourceUpdateSinkV2 sink, - @NonNull SourceManager sm + @NonNull DataSourceUpdateSinkV2 sink ) { - try { - Synchronizer synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); - while (synchronizer != null) { - if (stopped.get()) { - return; - } - int synchronizerCount = sm.getAvailableSynchronizerCount(); - boolean isPrime = sm.isPrimeSynchronizer(); - try { - boolean running = true; - try (FDv2DataSourceConditions.Conditions conditions = - new FDv2DataSourceConditions.Conditions(getConditions(synchronizerCount, isPrime))) { - while (running) { - Future nextFuture = synchronizer.next(); - // Race the next synchronizer result against any active conditions - // (fallback/recovery timers). Whichever resolves first wins. - Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); + Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + while (synchronizer != null) { + if (stopped.get()) { + return; + } + int synchronizerCount = sourceManager.getAvailableSynchronizerCount(); + boolean isPrime = sourceManager.isPrimeSynchronizer(); + try { + boolean running = true; + try (FDv2DataSourceConditions.Conditions conditions = + new FDv2DataSourceConditions.Conditions(getConditions(synchronizerCount, isPrime))) { + while (running) { + Future nextFuture = synchronizer.next(); + Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); - if (res instanceof FDv2DataSourceConditions.ConditionType) { - FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; - switch (ct) { - case FALLBACK: - logger.debug("Synchronizer {} experienced an interruption; falling back to next synchronizer.", - synchronizer.getClass().getSimpleName()); - break; - case RECOVERY: - logger.debug("The data source is attempting to recover to a higher priority synchronizer."); - sm.resetSourceIndex(); - break; - } - running = false; - break; + if (res instanceof FDv2DataSourceConditions.ConditionType) { + FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; + switch (ct) { + case FALLBACK: + logger.debug("Synchronizer {} experienced an interruption; falling back to next synchronizer.", + synchronizer.getClass().getSimpleName()); + break; + case RECOVERY: + logger.debug("The data source is attempting to recover to a higher priority synchronizer."); + sourceManager.resetSourceIndex(); + break; } + running = false; + break; + } - if (!(res instanceof FDv2SourceResult)) { - logger.error("Unexpected result type from synchronizer: {}", res != null ? res.getClass().getName() : "null"); - continue; - } + if (!(res instanceof FDv2SourceResult)) { + logger.error("Unexpected result type from synchronizer: {}", res != null ? res.getClass().getName() : "null"); + continue; + } - FDv2SourceResult result = (FDv2SourceResult) res; - // Let conditions observe the result before we act on it so - // they can update their internal state (e.g. reset interruption timers). - conditions.inform(result); + FDv2SourceResult result = (FDv2SourceResult) res; + conditions.inform(result); - switch (result.getResultType()) { - case CHANGE_SET: - ChangeSet> changeSet = result.getChangeSet(); - if (changeSet != null) { - sink.apply(context, changeSet); - sink.setStatus(DataSourceState.VALID, null); - tryCompleteStart(true, null); - } - break; - case STATUS: - FDv2SourceResult.Status status = result.getStatus(); - if (status != null) { - switch (status.getState()) { - case INTERRUPTED: - sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); - break; - case SHUTDOWN: - // This synchronizer is shutting down cleanly/intentionally - running = false; - break; - case TERMINAL_ERROR: - // This synchronizer cannot recover; block it so the outer - // loop advances to the next available synchronizer. - sm.blockCurrentSynchronizer(); - running = false; - sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); - break; - case GOODBYE: - // We let the synchronizer handle this internally. - break; - default: - break; - } + switch (result.getResultType()) { + case CHANGE_SET: + ChangeSet> changeSet = result.getChangeSet(); + if (changeSet != null) { + sink.apply(context, changeSet); + sink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); + } + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + if (status != null) { + switch (status.getState()) { + case INTERRUPTED: + sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); + break; + case SHUTDOWN: + running = false; + break; + case TERMINAL_ERROR: + sourceManager.blockCurrentSynchronizer(); + running = false; + sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); + break; + case GOODBYE: + break; + default: + break; } - break; - } + } + break; } } - } catch (ExecutionException e) { - logger.warn("Synchronizer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); - } catch (CancellationException e) { - logger.warn("Synchronizer cancelled: {}", e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e); - } catch (InterruptedException e) { - logger.warn("Synchronizer interrupted: {}", e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e); - return; } - synchronizer = sm.getNextAvailableSynchronizerAndSetActive(); + } catch (ExecutionException e) { + logger.warn("Synchronizer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); + } catch (CancellationException e) { + logger.warn("Synchronizer cancelled: {}", e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e); + } catch (InterruptedException e) { + logger.warn("Synchronizer interrupted: {}", e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e); + return; } - } finally { - sm.close(); + synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); } } } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java index 4d945eef..f3b307b0 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java @@ -18,7 +18,7 @@ */ final class SourceManager implements Closeable { - private final List synchronizerFactories; + private List synchronizerFactories; private final List> initializers; private final Object activeSourceLock = new Object(); @@ -49,6 +49,24 @@ void resetSourceIndex() { } } + /** + * Atomically replaces the synchronizer list, closing any active source and resetting + * the synchronizer index. Used by {@link FDv2DataSource#switchMode} to swap synchronizers + * without creating a new SourceManager, preventing concurrent loops from pushing data + * into the update sink simultaneously. + */ + void switchSynchronizers(@NonNull List newFactories) { + synchronized (activeSourceLock) { + if (activeSource != null) { + safeClose(activeSource); + activeSource = null; + } + synchronizerFactories = newFactories; + synchronizerIndex = -1; + currentSynchronizerFactory = null; + } + } + /** True if any synchronizer is marked as FDv1 fallback (Android: not used yet). */ boolean hasFDv1Fallback() { for (SynchronizerFactoryWithState s : synchronizerFactories) { From 31c9cd002fb0752ce3fec389a2504eb0952df1fe Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Wed, 18 Mar 2026 15:40:32 -0700 Subject: [PATCH 12/14] [SDK-1956] refactor: move needsRefresh and FDv1/FDv2 branching into updateDataSource handleModeStateChange() now simply updates the event processor and delegates to updateDataSource(). The FDv2 ModeAware early-return and FDv1 needsRefresh() check both live inside updateDataSource, keeping the branching logic in one place. Made-with: Cursor --- .../sdk/android/ConnectivityManager.java | 28 +++++++++++++------ .../sdk/android/FDv2DataSource.java | 1 + 2 files changed, 20 insertions(+), 9 deletions(-) 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 ace1f26f..40e51609 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 @@ -189,6 +189,24 @@ private synchronized boolean updateDataSource( return false; } + DataSource existingDataSource = currentDataSource.get(); + + // FDv2 ModeAware data sources handle all state transitions (including + // offline/background) via mode resolution rather than teardown/rebuild. + if (!mustReinitializeDataSource && existingDataSource instanceof ModeAware) { + resolveAndSwitchMode((ModeAware) existingDataSource); + onCompletion.onSuccess(null); + return false; + } + + // FDv1 path: check whether the data source needs a full rebuild. + if (!mustReinitializeDataSource && existingDataSource != null) { + boolean inBackground = !platformState.isForeground(); + if (existingDataSource.needsRefresh(inBackground, currentContext.get())) { + mustReinitializeDataSource = true; + } + } + boolean forceOffline = forcedOffline.get(); boolean networkEnabled = platformState.isNetworkAvailable(); boolean inBackground = !platformState.isForeground(); @@ -454,15 +472,7 @@ private void handleModeStateChange() { boolean foreground = platformState.isForeground(); updateEventProcessor(forceOffline, networkAvailable, foreground); - - DataSource dataSource = currentDataSource.get(); - if (dataSource instanceof ModeAware) { - resolveAndSwitchMode((ModeAware) dataSource); - } else if (dataSource != null && dataSource.needsRefresh(!foreground, currentContext.get())) { - updateDataSource(true, LDUtil.noOpCallback()); - } else { - updateDataSource(false, LDUtil.noOpCallback()); - } + updateDataSource(false, LDUtil.noOpCallback()); } /** diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index e47cd80f..b76be9fa 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -232,6 +232,7 @@ public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { for (DataSourceFactory factory : newDefinition.getSynchronizerFactories()) { newSyncFactories.add(new SynchronizerFactoryWithState(factory)); } + // Per CONNMODE 2.0.1: mode switches only transition synchronizers, no initializers. sourceManager.switchSynchronizers(newSyncFactories); sharedExecutor.execute(() -> { From be61139110c8ee877525019f297d8cf6e201aab5 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Thu, 19 Mar 2026 14:57:46 -0700 Subject: [PATCH 13/14] [SDK-1956] Replace internal switchMode() with teardown/rebuild at ConnectivityManager level Per updated CSFDV2 spec and JS implementation, mode switching now tears down the old data source and builds a new one rather than swapping internal synchronizers. Delete ModeAware interface, remove switchMode() from FDv2DataSource and switchSynchronizers() from SourceManager. FDv2DataSourceBuilder becomes the sole owner of mode resolution via setActiveMode()/build(), with ConnectivityManager using a useFDv2ModeResolution flag to route FDv2 through the new path while preserving FDv1 behavior. Implements CSFDV2 5.3.8 (retain data source when old and new modes share the same ModeDefinition). Made-with: Cursor --- .../sdk/android/ConnectivityManager.java | 90 ++++---- .../sdk/android/FDv2DataSource.java | 32 +-- .../sdk/android/FDv2DataSourceBuilder.java | 76 ++++--- .../launchdarkly/sdk/android/ModeAware.java | 33 --- .../sdk/android/ResolvedModeDefinition.java | 3 - .../sdk/android/SourceManager.java | 20 +- .../sdk/android/ConnectivityManagerTest.java | 204 ++++++++---------- .../android/FDv2DataSourceBuilderTest.java | 119 +++++++--- .../sdk/android/FDv2DataSourceTest.java | 121 ----------- 9 files changed, 277 insertions(+), 421 deletions(-) delete mode 100644 launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java 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 40e51609..79d8c2f7 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 @@ -72,7 +72,7 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; - private volatile Map resolvedModeTable; + private final boolean useFDv2ModeResolution; private volatile ConnectionMode currentFDv2Mode; // The DataSourceUpdateSinkImpl receives flag updates and status updates from the DataSource. @@ -150,6 +150,7 @@ public void shutDown() { connectionInformation = new ConnectionInformationState(); readStoredConnectionState(); this.backgroundUpdatingDisabled = ldConfig.isDisableBackgroundPolling(); + this.useFDv2ModeResolution = (dataSourceFactory instanceof FDv2DataSourceBuilder); connectivityChangeListener = networkAvailable -> handleModeStateChange(); platformState.addConnectivityChangeListener(connectivityChangeListener); @@ -190,13 +191,27 @@ private synchronized boolean updateDataSource( } DataSource existingDataSource = currentDataSource.get(); + boolean isFDv2ModeSwitch = false; - // FDv2 ModeAware data sources handle all state transitions (including - // offline/background) via mode resolution rather than teardown/rebuild. - if (!mustReinitializeDataSource && existingDataSource instanceof ModeAware) { - resolveAndSwitchMode((ModeAware) existingDataSource); - onCompletion.onSuccess(null); - return false; + // FDv2 path: resolve mode and determine if a teardown/rebuild is needed. + if (useFDv2ModeResolution && !mustReinitializeDataSource) { + ConnectionMode newMode = resolveMode(); + if (newMode == currentFDv2Mode) { + onCompletion.onSuccess(null); + return false; + } + // CSFDV2 5.3.8: retain active data source if old and new modes have equivalent config. + FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; + ModeDefinition oldDef = fdv2Builder.getModeDefinition(currentFDv2Mode); + ModeDefinition newDef = fdv2Builder.getModeDefinition(newMode); + if (oldDef != null && oldDef == newDef) { + currentFDv2Mode = newMode; + onCompletion.onSuccess(null); + return false; + } + currentFDv2Mode = newMode; + isFDv2ModeSwitch = true; + mustReinitializeDataSource = true; } // FDv1 path: check whether the data source needs a full rebuild. @@ -215,7 +230,12 @@ private synchronized boolean updateDataSource( boolean shouldStopExistingDataSource = true, shouldStartDataSourceIfStopped = false; - if (forceOffline) { + if (useFDv2ModeResolution) { + // FDv2 mode resolution already accounts for offline/background states via + // the ModeResolutionTable, so we always rebuild when the mode changed. + shouldStopExistingDataSource = mustReinitializeDataSource; + shouldStartDataSourceIfStopped = true; + } else if (forceOffline) { logger.debug("Initialized in offline mode"); initialized = true; dataSourceUpdateSink.setStatus(ConnectionInformation.ConnectionMode.SET_OFFLINE, null); @@ -249,41 +269,32 @@ private synchronized boolean updateDataSource( previouslyInBackground.get(), transactionalDataStore ); - DataSource dataSource = dataSourceFactory.build(clientContext); - currentDataSource.set(dataSource); - previouslyInBackground.set(Boolean.valueOf(inBackground)); - if (dataSourceFactory instanceof FDv2DataSourceBuilder) { + if (useFDv2ModeResolution) { FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; - resolvedModeTable = fdv2Builder.getResolvedModeTable(); - currentFDv2Mode = fdv2Builder.getStartingMode(); + // CONNMODE 2.0.1: mode switches only transition synchronizers, not initializers. + fdv2Builder.setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); } + DataSource dataSource = dataSourceFactory.build(clientContext); + currentDataSource.set(dataSource); + previouslyInBackground.set(Boolean.valueOf(inBackground)); + dataSource.start(new Callback() { @Override public void onSuccess(Boolean result) { initialized = true; - // passing the current connection mode since we don't want to change the mode, just trigger - // the logic to update the last connection success. updateConnectionInfoForSuccess(connectionInformation.getConnectionMode()); onCompletion.onSuccess(null); } @Override public void onError(Throwable error) { - // passing the current connection mode since we don't want to change the mode, just trigger - // the logic to update the last connection failure. updateConnectionInfoForError(connectionInformation.getConnectionMode(), error); onCompletion.onSuccess(null); } }); - // If the app starts in the background, the builder creates the data source with - // STREAMING as the starting mode. Perform an initial mode resolution to correct this. - if (dataSource instanceof ModeAware) { - resolveAndSwitchMode((ModeAware) dataSource); - } - return true; } @@ -425,6 +436,13 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return false; } initialized = false; + + if (useFDv2ModeResolution) { + currentFDv2Mode = resolveMode(); + FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; + fdv2Builder.setActiveMode(currentFDv2Mode, true); + } + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); return updateDataSource(true, onCompletion); } @@ -476,15 +494,10 @@ private void handleModeStateChange() { } /** - * Resolves the current platform state to a ConnectionMode via the ModeResolutionTable, - * looks up the ResolvedModeDefinition from the resolved mode table, and calls - * switchMode() on the data source if the mode has changed. + * Resolves the current platform state to a {@link ConnectionMode} via the + * {@link ModeResolutionTable}. */ - private void resolveAndSwitchMode(@NonNull ModeAware modeAware) { - Map table = resolvedModeTable; - if (table == null) { - return; - } + private ConnectionMode resolveMode() { boolean forceOffline = forcedOffline.get(); boolean networkAvailable = platformState.isNetworkAvailable(); boolean foreground = platformState.isForeground(); @@ -492,18 +505,7 @@ private void resolveAndSwitchMode(@NonNull ModeAware modeAware) { foreground && !forceOffline, networkAvailable && !forceOffline ); - ConnectionMode newMode = ModeResolutionTable.MOBILE.resolve(state); - if (newMode == currentFDv2Mode) { - return; - } - currentFDv2Mode = newMode; - ResolvedModeDefinition def = table.get(newMode); - if (def == null) { - logger.warn("No resolved definition for mode {}; skipping switchMode", newMode); - return; - } - logger.debug("Switching FDv2 mode to {}", newMode); - modeAware.switchMode(def); + return ModeResolutionTable.MOBILE.resolve(state); } synchronized ConnectionInformation getConnectionInformation() { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index b76be9fa..74f801f3 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -30,7 +30,7 @@ * switch to next synchronizer) and recovery (when on non-prime synchronizer, try * to return to the first after timeout). */ -final class FDv2DataSource implements DataSource, ModeAware { +final class FDv2DataSource implements DataSource { /** * Factory for creating Initializer or Synchronizer instances. @@ -50,8 +50,6 @@ public interface DataSourceFactory { private final AtomicBoolean started = new AtomicBoolean(false); private final AtomicBoolean startCompleted = new AtomicBoolean(false); private final AtomicBoolean stopped = new AtomicBoolean(false); - private final AtomicBoolean executionLoopRunning = new AtomicBoolean(false); - /** Result of the first start (null = not yet completed). Used so second start() gets the same result. */ private volatile Boolean startResult = null; private volatile Throwable startError = null; @@ -140,7 +138,6 @@ public void start(@NonNull Callback resultCallback) { LDContext context = evaluationContext; sharedExecutor.execute(() -> { - executionLoopRunning.set(true); try { if (!sourceManager.hasAvailableSources()) { logger.info("No initializers or synchronizers; data source will not connect."); @@ -167,8 +164,6 @@ public void start(@NonNull Callback resultCallback) { } catch (Throwable t) { logger.warn("FDv2DataSource error: {}", t.toString()); tryCompleteStart(false, t); - } finally { - executionLoopRunning.set(false); } }); } @@ -221,32 +216,11 @@ public void stop(@NonNull Callback completionCallback) { @Override public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { - // Mode-aware data sources handle background/foreground transitions via switchMode(), - // so only request a full rebuild when the evaluation context changes. + // FDv2 background/foreground transitions are handled externally by ConnectivityManager + // via teardown/rebuild, so only request a rebuild when the evaluation context changes. return !evaluationContext.equals(newEvaluationContext); } - @Override - public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { - List newSyncFactories = new ArrayList<>(); - for (DataSourceFactory factory : newDefinition.getSynchronizerFactories()) { - newSyncFactories.add(new SynchronizerFactoryWithState(factory)); - } - // Per CONNMODE 2.0.1: mode switches only transition synchronizers, no initializers. - sourceManager.switchSynchronizers(newSyncFactories); - - sharedExecutor.execute(() -> { - if (!executionLoopRunning.compareAndSet(false, true)) { - return; - } - try { - runSynchronizers(evaluationContext, dataSourceUpdateSink); - } finally { - executionLoopRunning.set(false); - } - }); - } - private void runInitializers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java index 1b786dc5..87bf7118 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -25,13 +25,10 @@ import java.util.concurrent.ScheduledExecutorService; /** - * Builds an {@link FDv2DataSource} and resolves the mode table from - * {@link ComponentConfigurer} factories into zero-arg {@link FDv2DataSource.DataSourceFactory} - * instances. The resolved table is stored and exposed via {@link #getResolvedModeTable()} - * so that {@link ConnectivityManager} can perform mode→definition lookups when switching modes. - *

- * This is the key architectural difference in Approach 2: the builder owns the resolved - * table rather than the data source itself. + * Builds an {@link FDv2DataSource} by resolving {@link ComponentConfigurer} factories + * into zero-arg {@link FDv2DataSource.DataSourceFactory} instances. The builder is the + * sole owner of mode resolution; {@link ConnectivityManager} configures the target mode + * via {@link #setActiveMode} before calling the standard {@link #build}. *

* Package-private — not part of the public SDK API. */ @@ -40,7 +37,9 @@ class FDv2DataSourceBuilder implements ComponentConfigurer { private final Map modeTable; private final ConnectionMode startingMode; - private Map resolvedModeTable; + private ConnectionMode activeMode; + private boolean includeInitializers = true; // false during mode switches to skip initializers (CONNMODE 2.0.1) + private ScheduledExecutorService sharedExecutor; FDv2DataSourceBuilder() { this(makeDefaultModeTable(), ConnectionMode.STREAMING); @@ -175,53 +174,64 @@ private static Map makeDefaultModeTable() { this.startingMode = startingMode; } - /** - * Returns the resolved mode table after {@link #build} has been called. - * Each entry maps a {@link ConnectionMode} to a {@link ResolvedModeDefinition} - * containing zero-arg factories that capture the {@link ClientContext}. - * - * @return unmodifiable map of resolved mode definitions - * @throws IllegalStateException if called before {@link #build} - */ @NonNull ConnectionMode getStartingMode() { return startingMode; } - @NonNull - Map getResolvedModeTable() { - if (resolvedModeTable == null) { - throw new IllegalStateException("build() must be called before getResolvedModeTable()"); - } - return resolvedModeTable; + /** + * Configures the mode to build for and whether to include initializers. + * Called by {@link ConnectivityManager} before each {@link #build} call. + * + * @param mode the target connection mode + * @param includeInitializers true for initial startup / identify, false for mode switches + * (per CONNMODE 2.0.1: mode switches only transition synchronizers) + */ + void setActiveMode(@NonNull ConnectionMode mode, boolean includeInitializers) { + this.activeMode = mode; + this.includeInitializers = includeInitializers; + } + + /** + * Returns the raw {@link ModeDefinition} for the given mode, used by + * {@link ConnectivityManager} for the CSFDV2 5.3.8 equivalence check. + */ + ModeDefinition getModeDefinition(@NonNull ConnectionMode mode) { + return modeTable.get(mode); } @Override public DataSource build(ClientContext clientContext) { - Map resolved = new LinkedHashMap<>(); - for (Map.Entry entry : modeTable.entrySet()) { - resolved.put(entry.getKey(), resolve(entry.getValue(), clientContext)); - } - this.resolvedModeTable = Collections.unmodifiableMap(resolved); + ConnectionMode mode = activeMode != null ? activeMode : startingMode; - ResolvedModeDefinition startDef = resolvedModeTable.get(startingMode); - if (startDef == null) { + ModeDefinition modeDef = modeTable.get(mode); + if (modeDef == null) { throw new IllegalStateException( - "Starting mode " + startingMode + " not found in mode table"); + "Mode " + mode + " not found in mode table"); } + ResolvedModeDefinition resolved = resolve(modeDef, clientContext); + DataSourceUpdateSink baseSink = clientContext.getDataSourceUpdateSink(); if (!(baseSink instanceof DataSourceUpdateSinkV2)) { throw new IllegalStateException( "FDv2DataSource requires a DataSourceUpdateSinkV2 implementation"); } - ScheduledExecutorService sharedExecutor = Executors.newScheduledThreadPool(2); + if (sharedExecutor == null) { + sharedExecutor = Executors.newScheduledThreadPool(2); + } + + List> initFactories = + includeInitializers ? resolved.getInitializerFactories() : Collections.>emptyList(); + + // Reset includeInitializers to default after each build to prevent stale state. + includeInitializers = true; return new FDv2DataSource( clientContext.getEvaluationContext(), - startDef.getInitializerFactories(), - startDef.getSynchronizerFactories(), + initFactories, + resolved.getSynchronizerFactories(), (DataSourceUpdateSinkV2) baseSink, sharedExecutor, clientContext.getBaseLogger() diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java deleted file mode 100644 index 929f1d4a..00000000 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeAware.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.launchdarkly.sdk.android; - -import androidx.annotation.NonNull; - -/** - * Supports runtime connection mode switching. - *

- * {@link ConnectivityManager} checks {@code instanceof ModeAware} to decide - * whether to use mode resolution (FDv2) or legacy teardown/rebuild behavior (FDv1). - *

- * The data source receives the full {@link ResolvedModeDefinition} — it has no - * internal mode table and does not know which named {@link ConnectionMode} it is - * operating in. The mode table and mode-to-definition lookup live in - * {@link ConnectivityManager}. - *

- * Package-private — not part of the public SDK API. - * - * @see ResolvedModeDefinition - * @see ModeResolutionTable - */ -interface ModeAware { - - /** - * Switches the data source to operate with the given mode definition. - * The implementation stops the current synchronizers and starts the new - * definition's synchronizers without re-running initializers - * (per CONNMODE spec 2.0.1). - * - * @param newDefinition the resolved initializer/synchronizer factories for - * the target mode - */ - void switchMode(@NonNull ResolvedModeDefinition newDefinition); -} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java index fccffc7a..e404a5ac 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -15,13 +15,10 @@ * a {@link com.launchdarkly.sdk.android.subsystems.ClientContext}. *

* Instances are immutable and created by {@code FDv2DataSourceBuilder} at build time. - * {@link ConnectivityManager} passes these to {@link ModeAware#switchMode} when the - * resolved connection mode changes. *

* Package-private — not part of the public SDK API. * * @see ModeDefinition - * @see ModeAware */ final class ResolvedModeDefinition { diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java index f3b307b0..4d945eef 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/SourceManager.java @@ -18,7 +18,7 @@ */ final class SourceManager implements Closeable { - private List synchronizerFactories; + private final List synchronizerFactories; private final List> initializers; private final Object activeSourceLock = new Object(); @@ -49,24 +49,6 @@ void resetSourceIndex() { } } - /** - * Atomically replaces the synchronizer list, closing any active source and resetting - * the synchronizer index. Used by {@link FDv2DataSource#switchMode} to swap synchronizers - * without creating a new SourceManager, preventing concurrent loops from pushing data - * into the update sink simultaneously. - */ - void switchSynchronizers(@NonNull List newFactories) { - synchronized (activeSourceLock) { - if (activeSource != null) { - safeClose(activeSource); - activeSource = null; - } - synchronizerFactories = newFactories; - synchronizerIndex = -1; - currentSynchronizerFactory = null; - } - } - /** True if any synchronizer is marked as FDv1 fallback (Android: not used yet). */ boolean hasFDv1Fallback() { for (SynchronizerFactoryWithState s : synchronizerFactories) { 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 018a674c..3980f36b 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 @@ -37,7 +37,6 @@ import org.junit.Test; import org.junit.rules.Timeout; -import com.launchdarkly.sdk.android.subsystems.DataSourceState; import com.launchdarkly.sdk.android.subsystems.Initializer; import com.launchdarkly.sdk.android.subsystems.Synchronizer; @@ -665,59 +664,14 @@ private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } - // ==== ModeAware tests ==== + // ==== FDv2 mode resolution tests ==== /** - * A mock ModeAware data source that records switchMode calls and - * signals start success immediately. + * Creates a test FDv2DataSourceBuilder that returns mock data sources + * which track start/stop via the shared queues. Each build() call creates + * a new mock data source. */ - private static class MockModeAwareDataSource implements DataSource, ModeAware { - final BlockingQueue switchModeCalls = new LinkedBlockingQueue<>(); - private final BlockingQueue startedQueue; - private volatile com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink; - - MockModeAwareDataSource(BlockingQueue startedQueue) { - this.startedQueue = startedQueue; - } - - void setSink(com.launchdarkly.sdk.android.subsystems.DataSourceUpdateSink sink) { - this.sink = sink; - } - - @Override - public void start(@NonNull Callback resultCallback) { - startedQueue.add(this); - new Thread(() -> { - if (sink != null) { - sink.setStatus(ConnectionMode.STREAMING, null); - } - resultCallback.onSuccess(true); - }).start(); - } - - @Override - public void stop(@NonNull Callback completionCallback) { - completionCallback.onSuccess(null); - } - - @Override - public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { - return false; - } - - @Override - public void switchMode(@NonNull ResolvedModeDefinition newDefinition) { - switchModeCalls.add(newDefinition); - } - } - - /** - * Creates a test FDv2DataSourceBuilder that returns a MockModeAwareDataSource. - * The resolved mode table contains STREAMING, BACKGROUND, and OFFLINE modes. - */ - private FDv2DataSourceBuilder makeModeAwareDataSourceFactory( - MockModeAwareDataSource mockDataSource - ) { + private FDv2DataSourceBuilder makeFDv2DataSourceFactory() { Map table = new LinkedHashMap<>(); table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( Collections.>emptyList(), @@ -734,37 +688,35 @@ private FDv2DataSourceBuilder makeModeAwareDataSourceFactory( return new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { @Override public DataSource build(ClientContext clientContext) { - super.build(clientContext); receivedClientContexts.add(clientContext); - mockDataSource.setSink(clientContext.getDataSourceUpdateSink()); - return mockDataSource; + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); } }; } @Test - public void modeAware_foregroundToBackground_switchesMode() throws Exception { + public void fdv2_foregroundToBackground_rebuildsDataSource() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode call for background transition", def); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "new data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "new data source started"); verifyAll(); - verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAware_backgroundToForeground_switchesMode() throws Exception { + public void fdv2_backgroundToForeground_rebuildsDataSource() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); @@ -773,133 +725,161 @@ public void modeAware_backgroundToForeground_switchesMode() throws Exception { eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - ResolvedModeDefinition def1 = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode for background", def1); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); mockPlatformState.setAndNotifyForegroundChangeListeners(true); - ResolvedModeDefinition def2 = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode for foreground", def2); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "fg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "fg data source started"); verifyAll(); - verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAware_networkLost_switchesToOffline() throws Exception { + public void fdv2_networkLost_rebuildsToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyConnectivityChangeListeners(false); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode call for offline", def); - assertTrue("OFFLINE mode should have no synchronizers", - def.getSynchronizerFactories().isEmpty()); - + 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"); verifyAll(); - verifyNoMoreDataSourcesWereCreated(); } @Test - public void modeAware_forceOffline_switchesToOffline() throws Exception { + public void fdv2_forceOffline_rebuildsToOffline() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(true); eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); - requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); connectivityManager.setForceOffline(true); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode call for forced offline", def); - assertTrue("OFFLINE mode should have no synchronizers", - def.getSynchronizerFactories().isEmpty()); - + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); verifyAll(); } @Test - public void modeAware_doesNotTearDownOnForegroundChange() throws Exception { + public void fdv2_sameModeDoesNotRebuild() throws Exception { eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); - eventProcessor.setInBackground(true); + eventProcessor.setInBackground(false); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, makeFDv2DataSourceFactory()); awaitStartUp(); verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - mockPlatformState.setAndNotifyForegroundChangeListeners(false); + mockPlatformState.setAndNotifyForegroundChangeListeners(true); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull(def); verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); verifyAll(); } @Test - public void modeAware_sameModeDoesNotTriggerSwitch() throws Exception { + public void fdv2_equivalentConfigDoesNotRebuild() throws Exception { + ModeDefinition sharedDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, sharedDef); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, sharedDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); - eventProcessor.setInBackground(false); + eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, builder); awaitStartUp(); - requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); - - // Fire a foreground event when already in foreground — should not trigger switchMode - mockPlatformState.setAndNotifyForegroundChangeListeners(true); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(500, TimeUnit.MILLISECONDS); - assertNull("Should not switchMode when mode hasn't changed", def); + // STREAMING and BACKGROUND share the same ModeDefinition object, so 5.3.8 says no rebuild + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); verifyAll(); } @Test - public void modeAware_switchModePassesResolvedDefinition() throws Exception { + public void fdv2_modeSwitchDoesNotIncludeInitializers() throws Exception { + BlockingQueue initializerIncluded = new LinkedBlockingQueue<>(); + + Map table = new LinkedHashMap<>(); + table.put(com.launchdarkly.sdk.android.ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + table.put(com.launchdarkly.sdk.android.ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(table, com.launchdarkly.sdk.android.ConnectionMode.STREAMING) { + @Override + public DataSource build(ClientContext clientContext) { + // After setActiveMode(mode, includeInitializers), build() resets includeInitializers + // to true. We can observe this by checking what build() would produce. The super.build() + // uses the includeInitializers flag internally. + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + eventProcessor.setOffline(false); eventProcessor.setInBackground(false); eventProcessor.setOffline(false); eventProcessor.setInBackground(true); replayAll(); - MockModeAwareDataSource mockDS = new MockModeAwareDataSource(startedDataSources); - createTestManager(false, false, makeModeAwareDataSourceFactory(mockDS)); + createTestManager(false, false, builder); awaitStartUp(); - requireValue(startedDataSources, 1, TimeUnit.SECONDS, "data source started"); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); mockPlatformState.setAndNotifyForegroundChangeListeners(false); - ResolvedModeDefinition def = mockDS.switchModeCalls.poll(2, TimeUnit.SECONDS); - assertNotNull("Expected switchMode call", def); - assertNotNull("Definition should have synchronizer factories", def.getSynchronizerFactories()); - assertNotNull("Definition should have initializer factories", def.getInitializerFactories()); - + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); verifyAll(); } } \ No newline at end of file diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java index 3e15d053..fe5928ce 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -2,6 +2,7 @@ import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; import static org.junit.Assert.assertTrue; import static org.junit.Assert.fail; @@ -56,26 +57,10 @@ public void defaultBuilder_buildsFDv2DataSource() { DataSource ds = builder.build(makeClientContext()); assertNotNull(ds); assertTrue(ds instanceof FDv2DataSource); - assertTrue(ds instanceof ModeAware); } @Test - public void resolvedModeTable_availableAfterBuild() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - builder.build(makeClientContext()); - Map table = builder.getResolvedModeTable(); - assertNotNull(table); - assertEquals(5, table.size()); - } - - @Test(expected = IllegalStateException.class) - public void resolvedModeTable_throwsBeforeBuild() { - FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); - builder.getResolvedModeTable(); - } - - @Test - public void customModeTable_resolvesCorrectly() { + public void customModeTable_buildsCorrectly() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.POLLING, new ModeDefinition( Collections.>emptyList(), @@ -85,10 +70,6 @@ public void customModeTable_resolvesCorrectly() { FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); DataSource ds = builder.build(makeClientContext()); assertNotNull(ds); - - Map table = builder.getResolvedModeTable(); - assertEquals(1, table.size()); - assertTrue(table.containsKey(ConnectionMode.POLLING)); } @Test @@ -109,7 +90,25 @@ public void startingMode_notInTable_throws() { } @Test - public void resolvedDefinition_hasSameSizeAsOriginal() { + public void setActiveMode_buildUsesSpecifiedMode() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.setActiveMode(ConnectionMode.POLLING, true); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void setActiveMode_withoutInitializers_buildsWithEmptyInitializers() { Map customTable = new LinkedHashMap<>(); customTable.put(ConnectionMode.STREAMING, new ModeDefinition( Collections.>singletonList(ctx -> null), @@ -117,11 +116,77 @@ public void resolvedDefinition_hasSameSizeAsOriginal() { )); FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); - builder.build(makeClientContext()); + builder.setActiveMode(ConnectionMode.STREAMING, false); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void defaultBehavior_usesStartingMode() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void getModeDefinition_returnsCorrectDefinition() { + ModeDefinition streamingDef = new ModeDefinition( + Collections.>singletonList(ctx -> null), + Collections.>singletonList(ctx -> null) + ); + ModeDefinition pollingDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, streamingDef); + customTable.put(ConnectionMode.POLLING, pollingDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + assertEquals(streamingDef, builder.getModeDefinition(ConnectionMode.STREAMING)); + assertEquals(pollingDef, builder.getModeDefinition(ConnectionMode.POLLING)); + assertNull(builder.getModeDefinition(ConnectionMode.OFFLINE)); + } + + @Test + public void getModeDefinition_sameObjectUsedForEquivalenceCheck() { + ModeDefinition sharedDef = new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + ); + + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, sharedDef); + customTable.put(ConnectionMode.POLLING, sharedDef); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + // Identity check: same ModeDefinition object shared across modes enables 5.3.8 equivalence + assertTrue(builder.getModeDefinition(ConnectionMode.STREAMING) + == builder.getModeDefinition(ConnectionMode.POLLING)); + } - ResolvedModeDefinition def = builder.getResolvedModeTable().get(ConnectionMode.STREAMING); - assertNotNull(def); - assertEquals(1, def.getInitializerFactories().size()); - assertEquals(1, def.getSynchronizerFactories().size()); + @Test + public void setActiveMode_notInTable_throws() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.STREAMING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + builder.setActiveMode(ConnectionMode.POLLING, true); + try { + builder.build(makeClientContext()); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not found in mode table")); + } } } diff --git a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java index fa569509..cd730bf9 100644 --- a/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceTest.java @@ -1421,127 +1421,6 @@ public void stopReportsOffStatus() throws Exception { assertEquals(DataSourceState.OFF, offStatus); } - // ==== switchMode tests ==== - - @Test - public void switchMode_replacesActiveSynchronizer() throws Exception { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - CountDownLatch oldSyncStarted = new CountDownLatch(1); - CountDownLatch oldSyncClosed = new CountDownLatch(1); - - MockQueuedSynchronizer oldSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)) - ) { - @Override - public LDAwaitFuture next() { - oldSyncStarted.countDown(); - return super.next(); - } - @Override - public void close() { - super.close(); - oldSyncClosed.countDown(); - } - }; - - FDv2DataSource dataSource = buildDataSource(sink, - Collections.emptyList(), - Collections.singletonList(() -> oldSync)); - - startDataSource(dataSource); - assertTrue(oldSyncStarted.await(2, TimeUnit.SECONDS)); - assertEquals(DataSourceState.VALID, sink.awaitStatus(2, TimeUnit.SECONDS)); - - CountDownLatch newSyncStarted = new CountDownLatch(1); - MockQueuedSynchronizer newSync = new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)) - ) { - @Override - public LDAwaitFuture next() { - newSyncStarted.countDown(); - return super.next(); - } - }; - ResolvedModeDefinition newDef = new ResolvedModeDefinition( - Collections.>emptyList(), - Collections.>singletonList(() -> newSync) - ); - - dataSource.switchMode(newDef); - - assertTrue("Old synchronizer should be closed", oldSyncClosed.await(2, TimeUnit.SECONDS)); - assertTrue("New synchronizer should start", newSyncStarted.await(2, TimeUnit.SECONDS)); - } - - @Test - public void switchMode_doesNotReRunInitializers() throws Exception { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - AtomicInteger initializerRunCount = new AtomicInteger(0); - - FDv2DataSource.DataSourceFactory initFactory = () -> { - initializerRunCount.incrementAndGet(); - return new MockInitializer( - FDv2SourceResult.changeSet(makeChangeSet(true))); - }; - - CountDownLatch syncStarted = new CountDownLatch(1); - FDv2DataSource dataSource = buildDataSource(sink, - Collections.singletonList(initFactory), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)) - ) { - @Override - public LDAwaitFuture next() { - syncStarted.countDown(); - return super.next(); - } - })); - - startDataSource(dataSource); - assertTrue(syncStarted.await(2, TimeUnit.SECONDS)); - assertEquals(1, initializerRunCount.get()); - - ResolvedModeDefinition newDef = new ResolvedModeDefinition( - Collections.>emptyList(), - Collections.>singletonList( - () -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)))) - ); - - dataSource.switchMode(newDef); - Thread.sleep(200); - assertEquals("Initializers should NOT run again on switchMode", 1, initializerRunCount.get()); - } - - @Test - public void switchMode_toEmptySynchronizers_closesOld() throws Exception { - MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); - CountDownLatch oldClosed = new CountDownLatch(1); - - FDv2DataSource dataSource = buildDataSource(sink, - Collections.emptyList(), - Collections.singletonList(() -> new MockQueuedSynchronizer( - FDv2SourceResult.changeSet(makeChangeSet(false)) - ) { - @Override - public void close() { - super.close(); - oldClosed.countDown(); - } - })); - - startDataSource(dataSource); - assertEquals(DataSourceState.VALID, sink.awaitStatus(2, TimeUnit.SECONDS)); - - ResolvedModeDefinition offlineDef = new ResolvedModeDefinition( - Collections.>emptyList(), - Collections.>emptyList() - ); - - dataSource.switchMode(offlineDef); - assertTrue("Old synchronizer should be closed", oldClosed.await(2, TimeUnit.SECONDS)); - } - @Test public void needsRefresh_sameContext_returnsFalse() { MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); From ba6ac91a84f06d5ecdf4080ef5c8eefea3855308 Mon Sep 17 00:00:00 2001 From: Aaron Zeisler Date: Fri, 20 Mar 2026 13:06:38 -0700 Subject: [PATCH 14/14] [SDK-1956] Address PR review feedback - Short-circuit forceOffline in resolveMode() so ModeState reflects actual platform state - Match ConnectionMode string values to cross-SDK spec (lowercase, hyphenated) - Add Javadoc to ConnectionMode, ClientContextImpl overloads, and FDv2DataSource internals - Inline FDv2DataSourceBuilder casts in ConnectivityManager - Restore try/finally and explanatory comments in runSynchronizers Made-with: Cursor --- .../sdk/android/ClientContextImpl.java | 12 ++ .../sdk/android/ConnectionMode.java | 15 +- .../sdk/android/ConnectivityManager.java | 19 +- .../sdk/android/FDv2DataSource.java | 162 ++++++++++-------- 4 files changed, 118 insertions(+), 90 deletions(-) diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java index 7993acdc..948b56f6 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ClientContextImpl.java @@ -39,6 +39,7 @@ final class ClientContextImpl extends ClientContext { @Nullable private final TransactionalDataStore transactionalDataStore; + /** Used by FDv1 code paths that do not need a {@link TransactionalDataStore}. */ ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, @@ -50,6 +51,11 @@ final class ClientContextImpl extends ClientContext { this(base, diagnosticStore, fetcher, platformState, taskExecutor, perEnvironmentData, null); } + /** + * Used by FDv2 code paths. The {@code transactionalDataStore} is needed by + * {@link FDv2DataSourceBuilder} to create {@link SelectorSourceFacade} instances + * that provide selector state to initializers and synchronizers. + */ ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, @@ -113,6 +119,7 @@ public static ClientContextImpl get(ClientContext context) { return new ClientContextImpl(context, null, null, null, null, null); } + /** Creates a context for FDv1 data sources that do not need a {@link TransactionalDataStore}. */ public static ClientContextImpl forDataSource( ClientContext baseClientContext, DataSourceUpdateSink dataSourceUpdateSink, @@ -124,6 +131,11 @@ public static ClientContextImpl forDataSource( newInBackground, previouslyInBackground, null); } + /** + * Creates a context for data sources, optionally including a {@link TransactionalDataStore}. + * FDv2 data sources require the store so that {@link FDv2DataSourceBuilder} can provide + * selector state to initializers and synchronizers via {@link SelectorSourceFacade}. + */ public static ClientContextImpl forDataSource( ClientContext baseClientContext, DataSourceUpdateSink dataSourceUpdateSink, diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java index 31777cef..a256ec96 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -5,6 +5,11 @@ * {@link ModeDefinition} that specifies which initializers and synchronizers * are active when the SDK is operating in that mode. *

+ * Not to be confused with {@link ConnectionInformation.ConnectionMode}, which + * is the public FDv1 enum representing the SDK's current connection state + * (e.g. POLLING, STREAMING, SET_OFFLINE). This class is an internal FDv2 + * concept describing the desired data-acquisition pipeline. + *

* This is a closed enum — custom connection modes (spec 5.3.5 TBD) are not * supported in this release. *

@@ -15,11 +20,11 @@ */ final class ConnectionMode { - static final ConnectionMode STREAMING = new ConnectionMode("STREAMING"); - static final ConnectionMode POLLING = new ConnectionMode("POLLING"); - static final ConnectionMode OFFLINE = new ConnectionMode("OFFLINE"); - static final ConnectionMode ONE_SHOT = new ConnectionMode("ONE_SHOT"); - static final ConnectionMode BACKGROUND = new ConnectionMode("BACKGROUND"); + static final ConnectionMode STREAMING = new ConnectionMode("streaming"); + static final ConnectionMode POLLING = new ConnectionMode("polling"); + static final ConnectionMode OFFLINE = new ConnectionMode("offline"); + static final ConnectionMode ONE_SHOT = new ConnectionMode("one-shot"); + static final ConnectionMode BACKGROUND = new ConnectionMode("background"); private final String name; 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 79d8c2f7..527d1f69 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 @@ -271,9 +271,8 @@ private synchronized boolean updateDataSource( ); if (useFDv2ModeResolution) { - FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; // CONNMODE 2.0.1: mode switches only transition synchronizers, not initializers. - fdv2Builder.setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); + ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); } DataSource dataSource = dataSourceFactory.build(clientContext); @@ -439,8 +438,7 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { if (useFDv2ModeResolution) { currentFDv2Mode = resolveMode(); - FDv2DataSourceBuilder fdv2Builder = (FDv2DataSourceBuilder) dataSourceFactory; - fdv2Builder.setActiveMode(currentFDv2Mode, true); + ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, true); } updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); @@ -495,15 +493,16 @@ private void handleModeStateChange() { /** * Resolves the current platform state to a {@link ConnectionMode} via the - * {@link ModeResolutionTable}. + * {@link ModeResolutionTable}. Force-offline is handled as a short-circuit + * so that {@link ModeState} faithfully represents actual platform state. */ private ConnectionMode resolveMode() { - boolean forceOffline = forcedOffline.get(); - boolean networkAvailable = platformState.isNetworkAvailable(); - boolean foreground = platformState.isForeground(); + if (forcedOffline.get()) { + return ConnectionMode.OFFLINE; + } ModeState state = new ModeState( - foreground && !forceOffline, - networkAvailable && !forceOffline + platformState.isForeground(), + platformState.isNetworkAvailable() ); return ModeResolutionTable.MOBILE.resolve(state); } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java index 74f801f3..5a5ff5b3 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSource.java @@ -304,91 +304,103 @@ private void runSynchronizers( @NonNull LDContext context, @NonNull DataSourceUpdateSinkV2 sink ) { - Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); - while (synchronizer != null) { - if (stopped.get()) { - return; - } - int synchronizerCount = sourceManager.getAvailableSynchronizerCount(); - boolean isPrime = sourceManager.isPrimeSynchronizer(); - try { - boolean running = true; - try (FDv2DataSourceConditions.Conditions conditions = - new FDv2DataSourceConditions.Conditions(getConditions(synchronizerCount, isPrime))) { - while (running) { - Future nextFuture = synchronizer.next(); - Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); + try { + Synchronizer synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + while (synchronizer != null) { + if (stopped.get()) { + return; + } + int synchronizerCount = sourceManager.getAvailableSynchronizerCount(); + boolean isPrime = sourceManager.isPrimeSynchronizer(); + try { + boolean running = true; + try (FDv2DataSourceConditions.Conditions conditions = + new FDv2DataSourceConditions.Conditions(getConditions(synchronizerCount, isPrime))) { + while (running) { + Future nextFuture = synchronizer.next(); + // Race the next synchronizer result against any active conditions + // (fallback/recovery timers). Whichever resolves first wins. + Object res = LDFutures.anyOf(conditions.getFuture(), nextFuture).get(); - if (res instanceof FDv2DataSourceConditions.ConditionType) { - FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; - switch (ct) { - case FALLBACK: - logger.debug("Synchronizer {} experienced an interruption; falling back to next synchronizer.", - synchronizer.getClass().getSimpleName()); - break; - case RECOVERY: - logger.debug("The data source is attempting to recover to a higher priority synchronizer."); - sourceManager.resetSourceIndex(); - break; + if (res instanceof FDv2DataSourceConditions.ConditionType) { + FDv2DataSourceConditions.ConditionType ct = (FDv2DataSourceConditions.ConditionType) res; + switch (ct) { + case FALLBACK: + logger.debug("Synchronizer {} experienced an interruption; falling back to next synchronizer.", + synchronizer.getClass().getSimpleName()); + break; + case RECOVERY: + logger.debug("The data source is attempting to recover to a higher priority synchronizer."); + sourceManager.resetSourceIndex(); + break; + } + running = false; + break; } - running = false; - break; - } - if (!(res instanceof FDv2SourceResult)) { - logger.error("Unexpected result type from synchronizer: {}", res != null ? res.getClass().getName() : "null"); - continue; - } + if (!(res instanceof FDv2SourceResult)) { + logger.error("Unexpected result type from synchronizer: {}", res != null ? res.getClass().getName() : "null"); + continue; + } - FDv2SourceResult result = (FDv2SourceResult) res; - conditions.inform(result); + FDv2SourceResult result = (FDv2SourceResult) res; + // Let conditions observe the result before we act on it so + // they can update their internal state (e.g. reset interruption timers). + conditions.inform(result); - switch (result.getResultType()) { - case CHANGE_SET: - ChangeSet> changeSet = result.getChangeSet(); - if (changeSet != null) { - sink.apply(context, changeSet); - sink.setStatus(DataSourceState.VALID, null); - tryCompleteStart(true, null); - } - break; - case STATUS: - FDv2SourceResult.Status status = result.getStatus(); - if (status != null) { - switch (status.getState()) { - case INTERRUPTED: - sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); - break; - case SHUTDOWN: - running = false; - break; - case TERMINAL_ERROR: - sourceManager.blockCurrentSynchronizer(); - running = false; - sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); - break; - case GOODBYE: - break; - default: - break; + switch (result.getResultType()) { + case CHANGE_SET: + ChangeSet> changeSet = result.getChangeSet(); + if (changeSet != null) { + sink.apply(context, changeSet); + sink.setStatus(DataSourceState.VALID, null); + tryCompleteStart(true, null); } - } - break; + break; + case STATUS: + FDv2SourceResult.Status status = result.getStatus(); + if (status != null) { + switch (status.getState()) { + case INTERRUPTED: + sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); + break; + case SHUTDOWN: + // This synchronizer is shutting down cleanly/intentionally + running = false; + break; + case TERMINAL_ERROR: + // This synchronizer cannot recover; block it so the outer + // loop advances to the next available synchronizer. + sourceManager.blockCurrentSynchronizer(); + running = false; + sink.setStatus(DataSourceState.INTERRUPTED, status.getError()); + break; + case GOODBYE: + // We let the synchronizer handle this internally. + break; + default: + break; + } + } + break; + } } } + } catch (ExecutionException e) { + logger.warn("Synchronizer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); + } catch (CancellationException e) { + logger.warn("Synchronizer cancelled: {}", e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e); + } catch (InterruptedException e) { + logger.warn("Synchronizer interrupted: {}", e.toString()); + sink.setStatus(DataSourceState.INTERRUPTED, e); + return; } - } catch (ExecutionException e) { - logger.warn("Synchronizer error: {}", e.getCause() != null ? e.getCause().toString() : e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e.getCause() != null ? e.getCause() : e); - } catch (CancellationException e) { - logger.warn("Synchronizer cancelled: {}", e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e); - } catch (InterruptedException e) { - logger.warn("Synchronizer interrupted: {}", e.toString()); - sink.setStatus(DataSourceState.INTERRUPTED, e); - return; + synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); } - synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive(); + } finally { + sourceManager.close(); } } }