diff --git a/docs/SDK-1956-development-plan.md b/docs/SDK-1956-development-plan.md new file mode 100644 index 00000000..a28f071e --- /dev/null +++ b/docs/SDK-1956-development-plan.md @@ -0,0 +1,525 @@ +# 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 { + 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. + +`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 + +```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 { + private final boolean foreground; + private final boolean networkAvailable; + // + getters: isForeground(), isNetworkAvailable() +} +``` + +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. + +### 5. `ModeResolutionEntry` + +```java +final class ModeResolutionEntry { + // 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() +} +``` + +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) + +```java +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, // catch-all + 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. 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 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) + +```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 `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). +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` (public API) +- `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 (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` + `switchMode()` implementation on `FDv2DataSource` + +| File | Description | +|------|-------------| +| `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. `switchMode()` activates new mode synchronizers, skips initializers, handles offline/round-trip/no-mode-table/after-stop scenarios. + +### Commit 3: `FDv2DataSourceBuilder` (stub resolution) + +| 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`). + +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: Wire real `ComponentConfigurer` implementations + +| File | Description | +|------|-------------| +| `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 | + +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) | 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 + +| 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*. `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. + +**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–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. + +--- + +## 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.** `FDv2DataSourceBuilder` has two build paths: + +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 builder's default path (from Todd's PR #325): + +| 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`. + +### 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. + +**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. + +--- + +## 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 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..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 @@ -4,10 +4,13 @@ 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. @@ -33,7 +36,10 @@ final class ClientContextImpl extends ClientContext { private final PlatformState platformState; private final TaskExecutor taskExecutor; private final PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData; + @Nullable + private final TransactionalDataStore transactionalDataStore; + /** Used by FDv1 code paths that do not need a {@link TransactionalDataStore}. */ ClientContextImpl( ClientContext base, DiagnosticStore diagnosticStore, @@ -41,6 +47,23 @@ final class ClientContextImpl extends ClientContext { PlatformState platformState, TaskExecutor taskExecutor, PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData + ) { + 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, + FeatureFetcher fetcher, + PlatformState platformState, + TaskExecutor taskExecutor, + PersistentDataStoreWrapper.PerEnvironmentData perEnvironmentData, + @Nullable TransactionalDataStore transactionalDataStore ) { super(base); this.diagnosticStore = diagnosticStore; @@ -48,6 +71,7 @@ final class ClientContextImpl extends ClientContext { this.platformState = platformState; this.taskExecutor = taskExecutor; this.perEnvironmentData = perEnvironmentData; + this.transactionalDataStore = transactionalDataStore; } static ClientContextImpl fromConfig( @@ -95,12 +119,30 @@ 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, LDContext newEvaluationContext, boolean newInBackground, Boolean previouslyInBackground + ) { + return forDataSource(baseClientContext, dataSourceUpdateSink, newEvaluationContext, + 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, + LDContext newEvaluationContext, + boolean newInBackground, + Boolean previouslyInBackground, + @Nullable TransactionalDataStore transactionalDataStore ) { ClientContextImpl baseContextImpl = ClientContextImpl.get(baseClientContext); return new ClientContextImpl( @@ -123,7 +165,8 @@ public static ClientContextImpl forDataSource( baseContextImpl.getFetcher(), baseContextImpl.getPlatformState(), baseContextImpl.getTaskExecutor(), - baseContextImpl.getPerEnvironmentData() + baseContextImpl.getPerEnvironmentData(), + transactionalDataStore ); } @@ -139,7 +182,8 @@ public ClientContextImpl setEvaluationContext(LDContext context) { this.fetcher, this.platformState, this.taskExecutor, - this.perEnvironmentData + this.perEnvironmentData, + this.transactionalDataStore ); } @@ -163,6 +207,11 @@ public PersistentDataStoreWrapper.PerEnvironmentData getPerEnvironmentData() { return throwExceptionIfNull(perEnvironmentData); } + @Nullable + 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/Components.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java index 2506740b..1d00ea5e 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/Components.java @@ -1,6 +1,7 @@ package com.launchdarkly.sdk.android; import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; +import com.launchdarkly.sdk.android.integrations.DataSystemBuilder; import com.launchdarkly.sdk.android.integrations.EventProcessorBuilder; import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.HttpConfigurationBuilder; @@ -221,4 +222,73 @@ public static HooksConfigurationBuilder hooks() { public static PluginsConfigurationBuilder plugins() { return new ComponentsImpl.PluginsConfigurationBuilderImpl(); } + + /** + * Returns a builder for configuring the data system. + *

+ * The data system controls how the SDK acquires and maintains feature flag data + * across different platform states (foreground, background, offline). It uses + * connection modes, each with its own pipeline of initializers and synchronizers. + *

+ * When called with no further customization, the data system uses sensible defaults: + * streaming with polling fallback in the foreground and low-frequency polling in the + * background. + *

+ * Example — opting in to use the default data system: + *


+     *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+     *         .mobileKey("my-key")
+     *         .dataSystem(Components.dataSystem())
+     *         .build();
+     * 
+ *

+ * Example — customize background polling to once every 6 hours: + *


+     *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+     *         .mobileKey("my-key")
+     *         .dataSystem(
+     *             Components.dataSystem()
+     *                 .customizeConnectionMode(ConnectionMode.BACKGROUND,
+     *                     DataSystemComponents.customMode()
+     *                         .initializers(DataSystemComponents.pollingInitializer())
+     *                         .synchronizers(
+     *                             DataSystemComponents.pollingSynchronizer()
+     *                                 .pollIntervalMillis(21_600_000))))
+     *         .build();
+     * 
+ *

+ * Example — use polling instead of streaming in the foreground: + *


+     *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+     *         .mobileKey("my-key")
+     *         .dataSystem(
+     *             Components.dataSystem()
+     *                 .foregroundConnectionMode(ConnectionMode.POLLING))
+     *         .build();
+     * 
+ *

+ * Example — disable automatic mode switching: + *


+     *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+     *         .mobileKey("my-key")
+     *         .dataSystem(
+     *             Components.dataSystem()
+     *                 .automaticModeSwitching(false)
+     *                 .foregroundConnectionMode(ConnectionMode.STREAMING))
+     *         .build();
+     * 
+ *

+ * Setting {@link LDConfig.Builder#dataSystem(DataSystemBuilder)} is mutually exclusive + * with {@link LDConfig.Builder#dataSource(ComponentConfigurer)}. The data system uses + * the FDv2 protocol, while {@code dataSource()} uses the legacy FDv1 protocol. + * + * @return a builder for configuring the data system + * @see DataSystemBuilder + * @see DataSystemComponents + * @see LDConfig.Builder#dataSystem(DataSystemBuilder) + */ + public static DataSystemBuilder dataSystem() { + return new DataSystemBuilder(); + } + } 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..829a2056 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -0,0 +1,68 @@ +package com.launchdarkly.sdk.android; + +/** + * Enumerates the built-in FDv2 connection modes. Each mode maps to a + * pipeline of initializers and synchronizers that 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. + *

+ * The SDK's {@link com.launchdarkly.sdk.android.integrations.DataSystemBuilder} + * allows you to customize which initializers and synchronizers run in each mode. + *

+ * On mobile, the SDK automatically transitions between modes based on + * platform state (foreground/background, network availability). The default + * resolution is: + *

    + *
  • No network → {@link #OFFLINE}
  • + *
  • Background → {@link #BACKGROUND}
  • + *
  • Foreground → {@link #STREAMING}
  • + *
+ * + * @see com.launchdarkly.sdk.android.integrations.DataSystemBuilder + * @see com.launchdarkly.sdk.android.integrations.ConnectionModeBuilder + */ +public final class ConnectionMode { + + /** + * The SDK uses a streaming connection in the foreground, with polling as a fallback. + */ + public static final ConnectionMode STREAMING = new ConnectionMode("streaming"); + + /** + * The SDK polls for updates at a regular interval. + */ + public static final ConnectionMode POLLING = new ConnectionMode("polling"); + + /** + * The SDK does not make any network requests. It may still serve cached data. + */ + public static final ConnectionMode OFFLINE = new ConnectionMode("offline"); + + /** + * The SDK makes a single poll request and then stops. + */ + public static final ConnectionMode ONE_SHOT = new ConnectionMode("one-shot"); + + /** + * The SDK polls at a low frequency while the application is in the background. + */ + public 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 22b09e23..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 @@ -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; @@ -15,7 +14,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.fdv2.Selector; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; import java.lang.ref.WeakReference; import java.util.ArrayList; @@ -25,8 +24,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: // @@ -60,6 +57,7 @@ class ConnectivityManager { 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; @@ -74,6 +72,8 @@ class ConnectivityManager { private final AtomicReference previouslyInBackground = new AtomicReference<>(); private final LDLogger logger; private volatile boolean initialized = false; + private final boolean useFDv2ModeResolution; + 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 @@ -105,7 +105,7 @@ public void apply(@NonNull LDContext context, @NonNull ChangeSet { - updateDataSource(false, LDUtil.noOpCallback()); - }; + connectivityChangeListener = networkAvailable -> handleModeStateChange(); platformState.addConnectivityChangeListener(connectivityChangeListener); - foregroundListener = foreground -> { - DataSource dataSource = currentDataSource.get(); - if (dataSource == null || dataSource.needsRefresh(!foreground, - currentContext.get())) { - updateDataSource(true, LDUtil.noOpCallback()); - } - }; + foregroundListener = foreground -> handleModeStateChange(); platformState.addForegroundChangeListener(foregroundListener); } @@ -180,6 +174,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); @@ -195,25 +190,59 @@ private synchronized boolean updateDataSource( return false; } + DataSource existingDataSource = currentDataSource.get(); + boolean isFDv2ModeSwitch = 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. + 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(); LDContext context = currentContext.get(); - eventProcessor.setOffline(forceOffline || !networkEnabled); - eventProcessor.setInBackground(inBackground); - 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(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; @@ -237,8 +266,15 @@ private synchronized boolean updateDataSource( dataSourceUpdateSink, context, inBackground, - previouslyInBackground.get() + previouslyInBackground.get(), + transactionalDataStore ); + + if (useFDv2ModeResolution) { + // CONNMODE 2.0.1: mode switches only transition synchronizers, not initializers. + ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, !isFDv2ModeSwitch); + } + DataSource dataSource = dataSourceFactory.build(clientContext); currentDataSource.set(dataSource); previouslyInBackground.set(Boolean.valueOf(inBackground)); @@ -247,16 +283,12 @@ private synchronized boolean updateDataSource( @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); } @@ -293,7 +325,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); @@ -318,7 +350,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) { @@ -403,6 +435,13 @@ synchronized boolean startUp(@NonNull Callback onCompletion) { return false; } initialized = false; + + if (useFDv2ModeResolution) { + currentFDv2Mode = resolveMode(); + ((FDv2DataSourceBuilder) dataSourceFactory).setActiveMode(currentFDv2Mode, true); + } + + updateEventProcessor(forcedOffline.get(), platformState.isNetworkAvailable(), platformState.isForeground()); return updateDataSource(true, onCompletion); } @@ -425,7 +464,7 @@ void shutDown() { void setForceOffline(boolean forceOffline) { boolean wasForcedOffline = forcedOffline.getAndSet(forceOffline); if (forceOffline != wasForcedOffline) { - updateDataSource(false, LDUtil.noOpCallback()); + handleModeStateChange(); } } @@ -433,6 +472,41 @@ 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); + updateDataSource(false, LDUtil.noOpCallback()); + } + + /** + * Resolves the current platform state to a {@link ConnectionMode} via the + * {@link ModeResolutionTable}. Force-offline is handled as a short-circuit + * so that {@link ModeState} faithfully represents actual platform state. + */ + private ConnectionMode resolveMode() { + if (forcedOffline.get()) { + return ConnectionMode.OFFLINE; + } + ModeState state = new ModeState( + platformState.isForeground(), + platformState.isNetworkAvailable() + ); + return ModeResolutionTable.MOBILE.resolve(state); + } + synchronized ConnectionInformation getConnectionInformation() { return connectionInformation; } diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java new file mode 100644 index 00000000..6c0bf270 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/DataSystemComponents.java @@ -0,0 +1,212 @@ +package com.launchdarkly.sdk.android; + +import com.launchdarkly.sdk.android.integrations.ConnectionModeBuilder; +import com.launchdarkly.sdk.android.integrations.DataSystemBuilder; +import com.launchdarkly.sdk.android.integrations.PollingInitializerBuilder; +import com.launchdarkly.sdk.android.integrations.PollingSynchronizerBuilder; +import com.launchdarkly.sdk.android.integrations.StreamingSynchronizerBuilder; +import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.subsystems.ClientContext; +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.http.HttpProperties; + +import java.net.URI; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; + +/** + * Factory methods for FDv2 data source components used with the + * {@link com.launchdarkly.sdk.android.integrations.DataSystemBuilder}. + *

+ * Each factory method returns a builder that implements + * {@link com.launchdarkly.sdk.android.subsystems.ComponentConfigurer} for the + * appropriate type ({@link Initializer} or {@link Synchronizer}). You may + * configure properties on the builder and then pass it to + * {@link com.launchdarkly.sdk.android.integrations.ConnectionModeBuilder#initializers} + * or {@link com.launchdarkly.sdk.android.integrations.ConnectionModeBuilder#synchronizers}. + *

+ * Example: + *


+ *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+ *         .mobileKey("my-key")
+ *         .dataSystem(
+ *             Components.dataSystem()
+ *                 .customizeConnectionMode(ConnectionMode.STREAMING,
+ *                     DataSystemComponents.customMode()
+ *                         .initializers(DataSystemComponents.pollingInitializer())
+ *                         .synchronizers(
+ *                             DataSystemComponents.streamingSynchronizer()
+ *                                 .initialReconnectDelayMillis(500),
+ *                             DataSystemComponents.pollingSynchronizer()
+ *                                 .pollIntervalMillis(300_000))))
+ *         .build();
+ * 
+ * + * @see com.launchdarkly.sdk.android.integrations.DataSystemBuilder + * @see com.launchdarkly.sdk.android.integrations.ConnectionModeBuilder + */ +public abstract class DataSystemComponents { + + private DataSystemComponents() {} + + static final class PollingInitializerBuilderImpl extends PollingInitializerBuilder { + @Override + public Initializer build(ClientContext clientContext) { + ClientContextImpl impl = ClientContextImpl.get(clientContext); + SelectorSource selectorSource = makeSelectorSource(impl); + ServiceEndpoints endpoints = serviceEndpointsOverride != null + ? serviceEndpointsOverride + : clientContext.getServiceEndpoints(); + URI pollingBase = StandardEndpoints.selectBaseUri( + endpoints.getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", clientContext.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(clientContext); + FDv2Requestor requestor = new DefaultFDv2Requestor( + clientContext.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, clientContext.getHttp().isUseReport(), + clientContext.isEvaluationReasons(), null, + clientContext.getBaseLogger()); + return new FDv2PollingInitializer(requestor, selectorSource, + Executors.newSingleThreadExecutor(), clientContext.getBaseLogger()); + } + } + + static final class PollingSynchronizerBuilderImpl extends PollingSynchronizerBuilder { + @Override + public Synchronizer build(ClientContext clientContext) { + ClientContextImpl impl = ClientContextImpl.get(clientContext); + SelectorSource selectorSource = makeSelectorSource(impl); + ServiceEndpoints endpoints = serviceEndpointsOverride != null + ? serviceEndpointsOverride + : clientContext.getServiceEndpoints(); + URI pollingBase = StandardEndpoints.selectBaseUri( + endpoints.getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", clientContext.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(clientContext); + FDv2Requestor requestor = new DefaultFDv2Requestor( + clientContext.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, clientContext.getHttp().isUseReport(), + clientContext.isEvaluationReasons(), null, + clientContext.getBaseLogger()); + ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); + return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + 0, pollIntervalMillis, clientContext.getBaseLogger()); + } + } + + static final class StreamingSynchronizerBuilderImpl extends StreamingSynchronizerBuilder { + @Override + public Synchronizer build(ClientContext clientContext) { + ClientContextImpl impl = ClientContextImpl.get(clientContext); + SelectorSource selectorSource = makeSelectorSource(impl); + ServiceEndpoints endpoints = serviceEndpointsOverride != null + ? serviceEndpointsOverride + : clientContext.getServiceEndpoints(); + URI streamBase = StandardEndpoints.selectBaseUri( + endpoints.getStreamingBaseUri(), + StandardEndpoints.DEFAULT_STREAMING_BASE_URI, + "streaming", clientContext.getBaseLogger()); + URI pollingBase = StandardEndpoints.selectBaseUri( + endpoints.getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", clientContext.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(clientContext); + FDv2Requestor requestor = new DefaultFDv2Requestor( + clientContext.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, clientContext.getHttp().isUseReport(), + clientContext.isEvaluationReasons(), null, + clientContext.getBaseLogger()); + return new FDv2StreamingSynchronizer( + clientContext.getEvaluationContext(), selectorSource, streamBase, + StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, + requestor, + initialReconnectDelayMillis, + clientContext.isEvaluationReasons(), clientContext.getHttp().isUseReport(), + httpProps, Executors.newSingleThreadExecutor(), + clientContext.getBaseLogger(), null); + } + } + + /** + * Returns a builder for a polling initializer. + *

+ * A polling initializer makes a single poll request to obtain the initial feature + * flag data set. + * + * @return a polling initializer builder + */ + public static PollingInitializerBuilder pollingInitializer() { + return new PollingInitializerBuilderImpl(); + } + + /** + * Returns a builder for a polling synchronizer. + *

+ * A polling synchronizer periodically polls LaunchDarkly for feature flag updates. + * The poll interval can be configured via + * {@link PollingSynchronizerBuilder#pollIntervalMillis(int)}. + * + * @return a polling synchronizer builder + */ + public static PollingSynchronizerBuilder pollingSynchronizer() { + return new PollingSynchronizerBuilderImpl(); + } + + /** + * Returns a builder for a streaming synchronizer. + *

+ * A streaming synchronizer maintains a persistent connection to LaunchDarkly + * and receives real-time feature flag updates. The initial reconnect delay + * can be configured via + * {@link StreamingSynchronizerBuilder#initialReconnectDelayMillis(int)}. + * + * @return a streaming synchronizer builder + */ + public static StreamingSynchronizerBuilder streamingSynchronizer() { + return new StreamingSynchronizerBuilderImpl(); + } + + /** + * Returns a builder for configuring a custom data pipeline for a connection mode. + *

+ * Use this to specify which initializers and synchronizers should run when the + * SDK is operating in a particular {@link ConnectionMode}. Pass the result to + * {@link DataSystemBuilder#customizeConnectionMode(ConnectionMode, ConnectionModeBuilder)}. + *


+     *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+     *         .mobileKey("my-key")
+     *         .dataSystem(
+     *             Components.dataSystem()
+     *                 .customizeConnectionMode(ConnectionMode.BACKGROUND,
+     *                     DataSystemComponents.customMode()
+     *                         .synchronizers(
+     *                             DataSystemComponents.pollingSynchronizer()
+     *                                 .pollIntervalMillis(21_600_000))))
+     *         .build();
+     * 
+ * + * @return a builder for configuring a custom connection mode pipeline + * @see ConnectionModeBuilder + * @see DataSystemBuilder#customizeConnectionMode(ConnectionMode, ConnectionModeBuilder) + */ + public static ConnectionModeBuilder customMode() { + return new ConnectionModeBuilder(); + } + + private static SelectorSource makeSelectorSource(ClientContextImpl impl) { + TransactionalDataStore store = impl.getTransactionalDataStore(); + return store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + } +} 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..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 @@ -7,17 +7,17 @@ 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; 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; -import java.util.Map; import java.util.Collections; import java.util.List; +import java.util.Map; import java.util.concurrent.CancellationException; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; @@ -50,7 +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); - /** 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; @@ -215,6 +214,13 @@ public void stop(@NonNull Callback completionCallback) { completionCallback.onSuccess(null); } + @Override + public boolean needsRefresh(boolean newInBackground, @NonNull LDContext newEvaluationContext) { + // 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); + } + 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 new file mode 100644 index 00000000..52ca9719 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -0,0 +1,172 @@ +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.DataSourceUpdateSink; +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.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.Executors; +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-to-definition lookups when switching modes. + *

+ * Package-private — not part of the public SDK API. + */ +class FDv2DataSourceBuilder implements ComponentConfigurer { + + private final Map modeTable; + private final ConnectionMode startingMode; + private final ModeResolutionTable resolutionTable; + private final boolean automaticModeSwitching; + + 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, ModeResolutionTable.MOBILE, true); + } + + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode + ) { + this(modeTable, startingMode, ModeResolutionTable.MOBILE, true); + } + + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode, + @NonNull ModeResolutionTable resolutionTable + ) { + this(modeTable, startingMode, resolutionTable, true); + } + + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode, + @NonNull ModeResolutionTable resolutionTable, + boolean automaticModeSwitching + ) { + this.modeTable = Collections.unmodifiableMap(new LinkedHashMap<>(modeTable)); + this.startingMode = startingMode; + this.resolutionTable = resolutionTable; + this.automaticModeSwitching = automaticModeSwitching; + } + + /** + * Returns the mode resolution table used to map platform state to connection modes. + * + * @return the resolution table + */ + @NonNull + ModeResolutionTable getResolutionTable() { + return resolutionTable; + } + + /** + * Returns whether automatic mode switching is enabled. + * + * @return true if automatic mode switching is enabled + */ + boolean isAutomaticModeSwitching() { + return automaticModeSwitching; + } + + @NonNull + ConnectionMode getStartingMode() { + return startingMode; + } + + /** + * 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) { + ConnectionMode mode = activeMode != null ? activeMode : startingMode; + + ModeDefinition modeDef = modeTable.get(mode); + if (modeDef == null) { + throw new IllegalStateException( + "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"); + } + + 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(), + initFactories, + resolved.getSynchronizerFactories(), + (DataSourceUpdateSinkV2) baseSink, + sharedExecutor, + clientContext.getBaseLogger() + ); + } + + private static ResolvedModeDefinition resolve( + ModeDefinition def, ClientContext clientContext + ) { + 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); + } + + private static Map makeDefaultModeTable() { + return new com.launchdarkly.sdk.android.integrations.DataSystemBuilder() + .buildModeTable(false); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java index 42b827eb..7422e404 100644 --- a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/LDConfig.java @@ -8,6 +8,7 @@ import com.launchdarkly.sdk.ContextKind; import com.launchdarkly.sdk.LDContext; import com.launchdarkly.sdk.android.integrations.ApplicationInfoBuilder; +import com.launchdarkly.sdk.android.integrations.DataSystemBuilder; import com.launchdarkly.sdk.android.integrations.HooksConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.PluginsConfigurationBuilder; import com.launchdarkly.sdk.android.integrations.ServiceEndpointsBuilder; @@ -228,6 +229,7 @@ public enum AutoEnvAttributes { private ApplicationInfoBuilder applicationInfoBuilder = null; private ComponentConfigurer dataSource = null; + private DataSystemBuilder dataSystemBuilder = null; private ComponentConfigurer events = null; private HooksConfigurationBuilder hooksConfigurationBuilder = null; private PluginsConfigurationBuilder pluginsConfigurationBuilder = null; @@ -383,6 +385,43 @@ public Builder applicationInfo(ApplicationInfoBuilder applicationInfoBuilder) { */ public Builder dataSource(ComponentConfigurer dataSourceConfigurer) { this.dataSource = dataSourceConfigurer; + this.dataSystemBuilder = null; + return this; + } + + /** + * Configures the SDK's data system, which controls how the SDK acquires and + * maintains feature flag data using the FDv2 protocol. + *

+ * The data system supports per-mode customization of initializers and synchronizers + * for foreground, background, and other platform states. This is the recommended + * way to configure FDv2 data sources. + *

+ * This is mutually exclusive with {@link #dataSource(ComponentConfigurer)}. If both + * are called, the last one wins. + *


+         *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+         *         .mobileKey("my-key")
+         *         .dataSystem(
+         *             Components.dataSystem()
+         *                 .customizeConnectionMode(ConnectionMode.STREAMING,
+ *                     DataSystemComponents.customMode()
+ *                         .initializers(DataSystemComponents.pollingInitializer())
+         *                         .synchronizers(
+         *                             DataSystemComponents.streamingSynchronizer()
+         *                                 .initialReconnectDelayMillis(500),
+         *                             DataSystemComponents.pollingSynchronizer())))
+         *         .build();
+         * 
+ * + * @param dataSystemBuilder the data system configuration builder + * @return the main configuration builder + * @see Components#dataSystem() + * @see DataSystemBuilder + */ + public Builder dataSystem(DataSystemBuilder dataSystemBuilder) { + this.dataSystemBuilder = dataSystemBuilder; + this.dataSource = null; return this; } @@ -722,11 +761,27 @@ public LDConfig build() { null : applicationInfoBuilder.createApplicationInfo(); + ComponentConfigurer effectiveDataSource; + if (this.dataSystemBuilder != null) { + Map modeTable = + this.dataSystemBuilder.buildModeTable(disableBackgroundUpdating); + ConnectionMode startingMode = this.dataSystemBuilder.getForegroundConnectionMode(); + ConnectionMode backgroundMode = this.dataSystemBuilder.getBackgroundConnectionMode(); + ModeResolutionTable resolutionTable = ModeResolutionTable.createMobile( + startingMode, backgroundMode); + boolean autoSwitch = this.dataSystemBuilder.isAutomaticModeSwitching(); + effectiveDataSource = new FDv2DataSourceBuilder( + modeTable, startingMode, resolutionTable, autoSwitch); + } else { + effectiveDataSource = this.dataSource == null + ? Components.streamingDataSource() : this.dataSource; + } + return new LDConfig( mobileKeys, serviceEndpoints, applicationInfo, - this.dataSource == null ? Components.streamingDataSource() : this.dataSource, + effectiveDataSource, this.events == null ? Components.sendEvents() : this.events, (this.hooksConfigurationBuilder == null ? Components.hooks() : this.hooksConfigurationBuilder).build(), (this.pluginsConfigurationBuilder == null ? Components.plugins() : this.pluginsConfigurationBuilder).build(), 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..1330b322 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -0,0 +1,62 @@ +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.Collections; +import java.util.List; + +/** + * 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. + *

+ * 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}. + * + * @see ConnectionMode + * @see ResolvedModeDefinition + */ +public final class ModeDefinition { + + private final List> initializers; + private final List> synchronizers; + + /** + * Constructs a mode definition with the given initializers and synchronizers. + * + * @param initializers the initializer configurers, in priority order + * @param synchronizers the synchronizer configurers, in priority order + */ + public ModeDefinition( + @NonNull List> initializers, + @NonNull List> synchronizers + ) { + this.initializers = Collections.unmodifiableList(initializers); + this.synchronizers = Collections.unmodifiableList(synchronizers); + } + + /** + * Returns the initializer configurers for this mode. + * + * @return an unmodifiable list of initializer configurers + */ + @NonNull + public List> getInitializers() { + return initializers; + } + + /** + * Returns the synchronizer configurers for this mode. + * + * @return an unmodifiable list of synchronizer configurers + */ + @NonNull + public 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..80fd1982 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java @@ -0,0 +1,43 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +/** + * 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 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); + } + + 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..1d6dcb67 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java @@ -0,0 +1,86 @@ +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 = createMobile( + ConnectionMode.STREAMING, ConnectionMode.BACKGROUND); + + /** + * Creates a mobile resolution table with configurable foreground and background modes. + * The resolution order is: + *

    + *
  1. No network → OFFLINE
  2. + *
  3. Background → {@code backgroundMode}
  4. + *
  5. Foreground (catch-all) → {@code foregroundMode}
  6. + *
+ * + * @param foregroundMode the mode to use when in the foreground + * @param backgroundMode the mode to use when in the background + * @return a new resolution table + */ + static ModeResolutionTable createMobile( + ConnectionMode foregroundMode, + ConnectionMode backgroundMode + ) { + return new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry( + state -> !state.isNetworkAvailable(), + ConnectionMode.OFFLINE), + new ModeResolutionEntry( + state -> !state.isForeground(), + backgroundMode), + new ModeResolutionEntry( + state -> true, + foregroundMode) + )); + } + + 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..1450f052 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeState.java @@ -0,0 +1,31 @@ +package com.launchdarkly.sdk.android; + +/** + * Snapshot of the current platform state used as input to + * {@link ModeResolutionTable#resolve(ModeState)}. + *

+ * 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 { + + 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/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..e404a5ac --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ResolvedModeDefinition.java @@ -0,0 +1,45 @@ +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. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + */ +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 c9c395e3..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 @@ -17,6 +17,10 @@ private StandardEndpoints() {} static final String ANALYTICS_EVENTS_REQUEST_PATH = "/mobile/events/bulk"; static final String DIAGNOSTIC_EVENTS_REQUEST_PATH = "/mobile/events/diagnostic"; + 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"; + /** * Internal method to decide which URI a given component should connect to. *

diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/ConnectionModeBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/ConnectionModeBuilder.java new file mode 100644 index 00000000..97a2c128 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/ConnectionModeBuilder.java @@ -0,0 +1,112 @@ +package com.launchdarkly.sdk.android.integrations; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.ConnectionMode; +import com.launchdarkly.sdk.android.DataSystemComponents; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * Configures the data pipeline (initializers and synchronizers) for a single + * {@link ConnectionMode}. + *

+ * Initializers are one-shot data sources that run in order at startup to + * obtain an initial set of feature flag data. The SDK tries each initializer in + * sequence until one succeeds. + *

+ * Synchronizers are long-lived data sources that keep the feature flag data + * up to date after initialization. The SDK uses the first synchronizer and falls + * back to subsequent ones if it encounters errors. + *

+ * Obtain an instance from {@link com.launchdarkly.sdk.android.DataSystemComponents#customMode()}, + * configure it, and pass it to + * {@link DataSystemBuilder#customizeConnectionMode(ConnectionMode, ConnectionModeBuilder)}: + *


+ *     DataSystemComponents.customMode()
+ *         .initializers(DataSystemComponents.pollingInitializer())
+ *         .synchronizers(
+ *             DataSystemComponents.streamingSynchronizer(),
+ *             DataSystemComponents.pollingSynchronizer())
+ * 
+ * + * @see DataSystemBuilder + * @see DataSystemComponents + */ +public class ConnectionModeBuilder { + + private final List> initializers = new ArrayList<>(); + private final List> synchronizers = new ArrayList<>(); + + /** + * Sets the initializers for this connection mode. + *

+ * Initializers run in order. The SDK advances to the next initializer if one + * fails or returns partial data. Any previously configured initializers are + * replaced. + *

+ * Use factory methods in {@link DataSystemComponents} to obtain builder instances: + *


+     *     builder.initializers(DataSystemComponents.pollingInitializer())
+     * 
+ * + * @param initializers the initializer configurers, in priority order + * @return this builder + */ + @SafeVarargs + public final ConnectionModeBuilder initializers(@NonNull ComponentConfigurer... initializers) { + this.initializers.clear(); + this.initializers.addAll(Arrays.asList(initializers)); + return this; + } + + /** + * Sets the synchronizers for this connection mode. + *

+ * Synchronizers keep data up to date after initialization. The SDK uses the + * first synchronizer and falls back to subsequent ones on error. Any previously + * configured synchronizers are replaced. + *

+ * Use factory methods in {@link DataSystemComponents} to obtain builder instances: + *


+     *     builder.synchronizers(
+     *         DataSystemComponents.streamingSynchronizer(),
+     *         DataSystemComponents.pollingSynchronizer())
+     * 
+ * + * @param synchronizers the synchronizer configurers, in priority order + * @return this builder + */ + @SafeVarargs + public final ConnectionModeBuilder synchronizers(@NonNull ComponentConfigurer... synchronizers) { + this.synchronizers.clear(); + this.synchronizers.addAll(Arrays.asList(synchronizers)); + return this; + } + + /** + * Returns the configured initializers as an unmodifiable list. + * + * @return the initializer configurers + */ + @NonNull + public List> getInitializers() { + return Collections.unmodifiableList(initializers); + } + + /** + * Returns the configured synchronizers as an unmodifiable list. + * + * @return the synchronizer configurers + */ + @NonNull + public List> getSynchronizers() { + return Collections.unmodifiableList(synchronizers); + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java new file mode 100644 index 00000000..f2ff81ba --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/DataSystemBuilder.java @@ -0,0 +1,274 @@ +package com.launchdarkly.sdk.android.integrations; + +import androidx.annotation.NonNull; + +import com.launchdarkly.sdk.android.ConnectionMode; +import com.launchdarkly.sdk.android.DataSystemComponents; +import com.launchdarkly.sdk.android.LDConfig; +import com.launchdarkly.sdk.android.ModeDefinition; +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.LinkedHashMap; +import java.util.Map; + +/** + * Configures the SDK's data system: how and when the SDK acquires feature flag + * data across different platform states (foreground, background, offline, etc.). + *

+ * The data system is organized around {@link ConnectionMode connection modes}. Each + * mode has a data pipeline consisting of initializers (one-shot data loads) + * and synchronizers (ongoing data updates). The SDK automatically transitions + * between modes based on platform state (foreground/background, network availability). + *

+ * Quick start — use defaults: + *


+ *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+ *         .mobileKey("my-key")
+ *         .dataSystem(Components.dataSystem())
+ *         .build();
+ * 
+ *

+ * Custom mode pipelines — background polling once every 6 hours: + *


+ *     LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Enabled)
+ *         .mobileKey("my-key")
+ *         .dataSystem(
+ *             Components.dataSystem()
+ *                 .customizeConnectionMode(ConnectionMode.BACKGROUND,
+ *                     DataSystemComponents.customMode()
+ *                         .initializers(DataSystemComponents.pollingInitializer())
+ *                         .synchronizers(
+ *                             DataSystemComponents.pollingSynchronizer()
+ *                                 .pollIntervalMillis(21_600_000))))
+ *         .build();
+ * 
+ *

+ * Change the foreground mode to polling: + *


+ *     Components.dataSystem()
+ *         .foregroundConnectionMode(ConnectionMode.POLLING)
+ * 
+ *

+ * Disable automatic mode switching: + *


+ *     Components.dataSystem()
+ *         .automaticModeSwitching(false)
+ *         .foregroundConnectionMode(ConnectionMode.STREAMING)
+ * 
+ * When automatic mode switching is disabled, the SDK stays in the + * {@link #foregroundConnectionMode foreground connection mode} and does not react to + * platform state changes (foreground/background, network availability). This can be + * useful when you want full control over which mode the SDK uses. + *

+ * Obtain an instance from {@link com.launchdarkly.sdk.android.Components#dataSystem()}. + * + * @see ConnectionMode + * @see ConnectionModeBuilder + * @see DataSystemComponents + */ +public class DataSystemBuilder { + + private ConnectionMode foregroundConnectionMode = ConnectionMode.STREAMING; + private ConnectionMode backgroundConnectionMode = ConnectionMode.BACKGROUND; + private boolean automaticModeSwitching = true; + private final Map connectionModeOverrides = new LinkedHashMap<>(); + + /** + * Sets the connection mode used when the application is in the foreground. + *

+ * This determines which entry in the mode table is used when the SDK resolves + * the foreground platform state. For instance, setting this to + * {@link ConnectionMode#POLLING} means the SDK will use the polling mode + * pipeline when the app is in the foreground. + *

+ * The default is {@link ConnectionMode#STREAMING}. + * + * @param mode the foreground connection mode + * @return this builder + */ + public DataSystemBuilder foregroundConnectionMode(@NonNull ConnectionMode mode) { + this.foregroundConnectionMode = mode; + return this; + } + + /** + * Sets the connection mode used when the application is in the background. + *

+ * The default is {@link ConnectionMode#BACKGROUND}. + * + * @param mode the background connection mode + * @return this builder + */ + public DataSystemBuilder backgroundConnectionMode(@NonNull ConnectionMode mode) { + this.backgroundConnectionMode = mode; + return this; + } + + /** + * Enables or disables automatic mode switching based on platform state. + *

+ * When enabled (the default), the SDK automatically transitions between connection + * modes as the platform state changes (e.g., foreground to background, network loss). + *

+ * When disabled, the SDK stays in the {@link #foregroundConnectionMode foreground connection + * mode} for its entire lifecycle and ignores platform state changes. This is useful + * when you want explicit control over data acquisition behavior regardless of whether + * the app is foregrounded, backgrounded, or experiencing network changes. + *

+ * Note that {@link com.launchdarkly.sdk.android.LDClient#setForceOffline(boolean)} + * still works independently of this setting. + * + * @param enabled true to enable automatic mode switching (default), false to disable + * @return this builder + */ + public DataSystemBuilder automaticModeSwitching(boolean enabled) { + this.automaticModeSwitching = enabled; + return this; + } + + /** + * Overrides the data pipeline for a specific connection mode. + *

+ * This only affects the specified mode. All other connection modes that are not + * customized continue to use their default pipelines. For example, customizing + * {@link ConnectionMode#BACKGROUND} does not change the behavior of + * {@link ConnectionMode#STREAMING} or any other mode. + *

+ * Example — set background polling to once every 6 hours: + *


+     *     Components.dataSystem()
+     *         .customizeConnectionMode(ConnectionMode.BACKGROUND,
+     *             DataSystemComponents.customMode()
+     *                 .initializers(DataSystemComponents.pollingInitializer())
+     *                 .synchronizers(
+     *                     DataSystemComponents.pollingSynchronizer()
+     *                         .pollIntervalMillis(21_600_000)))
+     * 
+ * + * @param mode the connection mode to customize + * @param builder the pipeline configuration for this mode + * @return this builder + */ + public DataSystemBuilder customizeConnectionMode( + @NonNull ConnectionMode mode, + @NonNull ConnectionModeBuilder builder + ) { + connectionModeOverrides.put(mode, builder); + return this; + } + + /** + * Returns the configured foreground connection mode. + * + * @return the foreground connection mode + */ + @NonNull + public ConnectionMode getForegroundConnectionMode() { + return foregroundConnectionMode; + } + + /** + * Returns the configured background connection mode. + * + * @return the background connection mode + */ + @NonNull + public ConnectionMode getBackgroundConnectionMode() { + return backgroundConnectionMode; + } + + /** + * Returns whether automatic mode switching is enabled. + * + * @return true if automatic mode switching is enabled + */ + public boolean isAutomaticModeSwitching() { + return automaticModeSwitching; + } + + /** + * Returns any user-specified mode overrides. + * + * @return an unmodifiable map of overridden connection modes + */ + @NonNull + public Map getConnectionModeOverrides() { + return Collections.unmodifiableMap(connectionModeOverrides); + } + + /** + * Builds the full mode table by starting with defaults and applying any user + * overrides and LDConfig-level settings. + *

+ * If {@code disableBackgroundUpdating} is true, the background mode entry + * is replaced with an empty pipeline (no initializers or synchronizers). + * + * @param disableBackgroundUpdating whether background updates are disabled + * @return the complete mode table + */ + @NonNull + public Map buildModeTable(boolean disableBackgroundUpdating) { + Map table = makeDefaultModeTable(); + + for (Map.Entry entry : connectionModeOverrides.entrySet()) { + ConnectionModeBuilder cmb = entry.getValue(); + table.put(entry.getKey(), new ModeDefinition( + cmb.getInitializers(), + cmb.getSynchronizers() + )); + } + + if (disableBackgroundUpdating) { + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.>emptyList(), + Collections.>emptyList() + )); + } + + return table; + } + + /** + * Produces the default mode table. This matches the pipelines previously + * hardcoded in {@code FDv2DataSourceBuilder.makeDefaultModeTable()}. + */ + @NonNull + private static Map makeDefaultModeTable() { + ComponentConfigurer pollingInitializer = DataSystemComponents.pollingInitializer(); + + ComponentConfigurer pollingSynchronizer = DataSystemComponents.pollingSynchronizer(); + + ComponentConfigurer streamingSynchronizer = DataSystemComponents.streamingSynchronizer(); + + ComponentConfigurer backgroundPollingSynchronizer = + DataSystemComponents.pollingSynchronizer() + .pollIntervalMillis(LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS); + + 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; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingInitializerBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingInitializerBuilder.java new file mode 100644 index 00000000..26e8e2db --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingInitializerBuilder.java @@ -0,0 +1,51 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.Initializer; + +/** + * Contains methods for configuring the polling initializer. + *

+ * A polling initializer makes a single poll request to retrieve the initial set + * of feature flag data. It is typically used as the first step in a connection mode's + * data pipeline. + *

+ * Obtain an instance from {@link com.launchdarkly.sdk.android.DataSystemComponents#pollingInitializer()}, + * configure it, and pass it to + * {@link ConnectionModeBuilder#initializers(ComponentConfigurer[])}: + *


+ *     DataSystemComponents.customMode()
+ *         .initializers(DataSystemComponents.pollingInitializer())
+ * 
+ *

+ * Note that this class is abstract; the actual implementation is created by calling + * {@link com.launchdarkly.sdk.android.DataSystemComponents#pollingInitializer()}. + * + * @see com.launchdarkly.sdk.android.DataSystemComponents + * @see ConnectionModeBuilder + */ +public abstract class PollingInitializerBuilder implements ComponentConfigurer { + + /** + * Per-source service endpoint override, or null to use the SDK-level endpoints. + */ + protected ServiceEndpoints serviceEndpointsOverride; + + /** + * Sets overrides for the service endpoints used by this initializer. + *

+ * In typical usage, the initializer uses the service endpoints configured at the + * SDK level via + * {@link com.launchdarkly.sdk.android.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. + * Use this method only when you need a specific initializer to connect to different + * endpoints than the rest of the SDK. + * + * @param serviceEndpointsBuilder the service endpoints override + * @return this builder + */ + public PollingInitializerBuilder serviceEndpointsOverride(ServiceEndpointsBuilder serviceEndpointsBuilder) { + this.serviceEndpointsOverride = serviceEndpointsBuilder.createServiceEndpoints(); + return this; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java new file mode 100644 index 00000000..623fc238 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/PollingSynchronizerBuilder.java @@ -0,0 +1,77 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +/** + * Contains methods for configuring the polling synchronizer. + *

+ * A polling synchronizer periodically polls LaunchDarkly for feature flag updates. + * It can be used as a primary synchronizer or as a fallback when the streaming + * connection is unavailable. + *

+ * Obtain an instance from {@link com.launchdarkly.sdk.android.DataSystemComponents#pollingSynchronizer()}, + * configure it, and pass it to + * {@link ConnectionModeBuilder#synchronizers(ComponentConfigurer[])}: + *


+ *     DataSystemComponents.customMode()
+ *         .synchronizers(
+ *             DataSystemComponents.pollingSynchronizer()
+ *                 .pollIntervalMillis(60_000))
+ * 
+ *

+ * Note that this class is abstract; the actual implementation is created by calling + * {@link com.launchdarkly.sdk.android.DataSystemComponents#pollingSynchronizer()}. + * + * @see com.launchdarkly.sdk.android.DataSystemComponents + * @see ConnectionModeBuilder + */ +public abstract class PollingSynchronizerBuilder implements ComponentConfigurer { + + /** + * The default value for {@link #pollIntervalMillis(int)}: 5 minutes (300,000 ms). + */ + public static final int DEFAULT_POLL_INTERVAL_MILLIS = 300_000; + + /** + * The polling interval in milliseconds. + */ + protected int pollIntervalMillis = DEFAULT_POLL_INTERVAL_MILLIS; + + /** + * Per-source service endpoint override, or null to use the SDK-level endpoints. + */ + protected ServiceEndpoints serviceEndpointsOverride; + + /** + * Sets the interval at which the SDK will poll for feature flag updates. + *

+ * The default and minimum value is {@link #DEFAULT_POLL_INTERVAL_MILLIS}. Values + * less than this will be set to the default. + * + * @param pollIntervalMillis the polling interval in milliseconds + * @return this builder + */ + public PollingSynchronizerBuilder pollIntervalMillis(int pollIntervalMillis) { + this.pollIntervalMillis = Math.max(pollIntervalMillis, DEFAULT_POLL_INTERVAL_MILLIS); + return this; + } + + /** + * Sets overrides for the service endpoints used by this synchronizer. + *

+ * In typical usage, the synchronizer uses the service endpoints configured at the + * SDK level via + * {@link com.launchdarkly.sdk.android.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. + * Use this method only when you need a specific synchronizer to connect to different + * endpoints than the rest of the SDK. + * + * @param serviceEndpointsBuilder the service endpoints override + * @return this builder + */ + public PollingSynchronizerBuilder serviceEndpointsOverride(ServiceEndpointsBuilder serviceEndpointsBuilder) { + this.serviceEndpointsOverride = serviceEndpointsBuilder.createServiceEndpoints(); + return this; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/StreamingSynchronizerBuilder.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/StreamingSynchronizerBuilder.java new file mode 100644 index 00000000..b0a4b792 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/integrations/StreamingSynchronizerBuilder.java @@ -0,0 +1,83 @@ +package com.launchdarkly.sdk.android.integrations; + +import com.launchdarkly.sdk.android.interfaces.ServiceEndpoints; +import com.launchdarkly.sdk.android.subsystems.ComponentConfigurer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +/** + * Contains methods for configuring the streaming synchronizer. + *

+ * A streaming synchronizer maintains a persistent connection to LaunchDarkly's + * Flag Delivery service and receives real-time feature flag updates. It is + * typically the primary synchronizer for the foreground streaming mode. + *

+ * Obtain an instance from {@link com.launchdarkly.sdk.android.DataSystemComponents#streamingSynchronizer()}, + * configure it, and pass it to + * {@link ConnectionModeBuilder#synchronizers(ComponentConfigurer[])}: + *


+ *     DataSystemComponents.customMode()
+ *         .synchronizers(
+ *             DataSystemComponents.streamingSynchronizer()
+ *                 .initialReconnectDelayMillis(500))
+ * 
+ *

+ * Note that this class is abstract; the actual implementation is created by calling + * {@link com.launchdarkly.sdk.android.DataSystemComponents#streamingSynchronizer()}. + * + * @see com.launchdarkly.sdk.android.DataSystemComponents + * @see ConnectionModeBuilder + */ +public abstract class StreamingSynchronizerBuilder implements ComponentConfigurer { + + /** + * The default value for {@link #initialReconnectDelayMillis(int)}: 1000 milliseconds. + */ + public static final int DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS = 1_000; + + /** + * The initial reconnection delay in milliseconds. + */ + protected int initialReconnectDelayMillis = DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS; + + /** + * Per-source service endpoint override, or null to use the SDK-level endpoints. + */ + protected ServiceEndpoints serviceEndpointsOverride; + + /** + * Sets the initial reconnect delay for the streaming connection. + *

+ * The streaming service uses a backoff algorithm (with jitter) every time the + * connection needs to be reestablished. The delay for the first reconnection will + * start near this value, and then increase exponentially for any subsequent + * connection failures. + *

+ * The default value is {@link #DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS}. + * + * @param initialReconnectDelayMillis the reconnect time base value in milliseconds + * @return this builder + */ + public StreamingSynchronizerBuilder initialReconnectDelayMillis(int initialReconnectDelayMillis) { + this.initialReconnectDelayMillis = initialReconnectDelayMillis <= 0 + ? DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS + : initialReconnectDelayMillis; + return this; + } + + /** + * Sets overrides for the service endpoints used by this synchronizer. + *

+ * In typical usage, the synchronizer uses the service endpoints configured at the + * SDK level via + * {@link com.launchdarkly.sdk.android.LDConfig.Builder#serviceEndpoints(ServiceEndpointsBuilder)}. + * Use this method only when you need a specific synchronizer to connect to different + * endpoints than the rest of the SDK. + * + * @param serviceEndpointsBuilder the service endpoints override + * @return this builder + */ + public StreamingSynchronizerBuilder serviceEndpointsOverride(ServiceEndpointsBuilder serviceEndpointsBuilder) { + this.serviceEndpointsOverride = serviceEndpointsBuilder.createServiceEndpoints(); + return this; + } +} 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..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,8 +37,14 @@ import org.junit.Test; import org.junit.rules.Timeout; +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; @@ -657,4 +663,223 @@ private void verifyNoMoreDataSourcesWereCreated() { private void verifyNoMoreDataSourcesWereStopped() { requireNoMoreValues(stoppedDataSources, 1, TimeUnit.SECONDS, "stopping of data source"); } + + // ==== FDv2 mode resolution tests ==== + + /** + * 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 FDv2DataSourceBuilder makeFDv2DataSourceFactory() { + 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) { + receivedClientContexts.add(clientContext); + return MockComponents.successfulDataSource(clientContext, DATA, + ConnectionMode.STREAMING, startedDataSources, stoppedDataSources); + } + }; + } + + @Test + public void fdv2_foregroundToBackground_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "new data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "new data source started"); + verifyAll(); + } + + @Test + public void fdv2_backgroundToForeground_rebuildsDataSource() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(true); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "bg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "bg data source started"); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "fg data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "fg data source started"); + + verifyAll(); + } + + @Test + public void fdv2_networkLost_rebuildsToOffline() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyConnectivityChangeListeners(false); + + 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(); + } + + @Test + public void fdv2_forceOffline_rebuildsToOffline() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(true); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + connectivityManager.setForceOffline(true); + + verifyDataSourceWasStopped(); + requireValue(receivedClientContexts, 1, TimeUnit.SECONDS, "offline data source creation"); + requireValue(startedDataSources, 1, TimeUnit.SECONDS, "offline data source started"); + verifyAll(); + } + + @Test + public void fdv2_sameModeDoesNotRebuild() throws Exception { + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + eventProcessor.setOffline(false); + eventProcessor.setInBackground(false); + replayAll(); + + createTestManager(false, false, makeFDv2DataSourceFactory()); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(true); + + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); + verifyAll(); + } + + @Test + 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(true); + replayAll(); + + createTestManager(false, false, builder); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + // STREAMING and BACKGROUND share the same ModeDefinition object, so 5.3.8 says no rebuild + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + verifyNoMoreDataSourcesWereCreated(); + verifyNoMoreDataSourcesWereStopped(); + verifyAll(); + } + + @Test + 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(); + + createTestManager(false, false, builder); + awaitStartUp(); + verifyForegroundDataSourceWasCreatedAndStarted(CONTEXT); + + mockPlatformState.setAndNotifyForegroundChangeListeners(false); + + 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 new file mode 100644 index 00000000..fe5928ce --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilderTest.java @@ -0,0 +1,192 @@ +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 static org.junit.Assert.fail; + +import com.launchdarkly.sdk.LDContext; +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.Initializer; +import com.launchdarkly.sdk.android.subsystems.Synchronizer; + +import org.junit.Rule; +import org.junit.Test; + +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.Map; + +public class FDv2DataSourceBuilderTest { + + private static final LDContext CONTEXT = LDContext.create("test-context"); + private static final IEnvironmentReporter ENV_REPORTER = new EnvironmentReporterBuilder().build(); + + @Rule + public LogCaptureRule logging = new LogCaptureRule(); + + private ClientContext makeClientContext() { + LDConfig config = new LDConfig.Builder(AutoEnvAttributes.Disabled).build(); + MockComponents.MockDataSourceUpdateSink sink = new MockComponents.MockDataSourceUpdateSink(); + return new ClientContext( + "mobile-key", + ENV_REPORTER, + logging.logger, + config, + sink, + "default", + false, + CONTEXT, + null, + false, + null, + config.serviceEndpoints, + false + ); + } + + @Test + public void defaultBuilder_buildsFDv2DataSource() { + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + assertTrue(ds instanceof FDv2DataSource); + } + + @Test + public void customModeTable_buildsCorrectly() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.POLLING); + DataSource ds = builder.build(makeClientContext()); + assertNotNull(ds); + } + + @Test + public void startingMode_notInTable_throws() { + Map customTable = new LinkedHashMap<>(); + customTable.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.>emptyList(), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + try { + builder.build(makeClientContext()); + fail("Expected IllegalStateException"); + } catch (IllegalStateException e) { + assertTrue(e.getMessage().contains("not found in mode table")); + } + } + + @Test + 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), + Collections.>singletonList(ctx -> null) + )); + + FDv2DataSourceBuilder builder = new FDv2DataSourceBuilder(customTable, ConnectionMode.STREAMING); + 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)); + } + + @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 ad1e91e3..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 @@ -1420,4 +1420,23 @@ public void stopReportsOffStatus() throws Exception { DataSourceState offStatus = sink.awaitStatus(AWAIT_TIMEOUT_SECONDS, TimeUnit.SECONDS); assertEquals(DataSourceState.OFF, offStatus); } + + @Test + public void needsRefresh_sameContext_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()); + 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 new file mode 100644 index 00000000..b4c559fc --- /dev/null +++ b/launchdarkly-android-client-sdk/src/test/java/com/launchdarkly/sdk/android/ModeResolutionTableTest.java @@ -0,0 +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; + +public class ModeResolutionTableTest { + + // ==== MOBILE table tests ==== + + @Test + public void mobile_foregroundWithNetwork_resolvesToStreaming() { + ModeState state = new ModeState(true, true); + assertSame(ConnectionMode.STREAMING, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundWithNetwork_resolvesToBackground() { + ModeState state = new ModeState(false, true); + assertSame(ConnectionMode.BACKGROUND, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_foregroundWithoutNetwork_resolvesToOffline() { + ModeState state = new ModeState(true, false); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + @Test + public void mobile_backgroundWithoutNetwork_resolvesToOffline() { + ModeState state = new ModeState(false, false); + assertSame(ConnectionMode.OFFLINE, ModeResolutionTable.MOBILE.resolve(state)); + } + + // ==== Custom table tests ==== + + @Test + public void customTable_firstMatchWins() { + ModeResolutionTable table = new ModeResolutionTable(Arrays.asList( + new ModeResolutionEntry(state -> true, ConnectionMode.POLLING), + new ModeResolutionEntry(state -> true, ConnectionMode.STREAMING) + )); + assertSame(ConnectionMode.POLLING, table.resolve(new ModeState(true, true))); + } + + @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 noMatch_throws() { + ModeResolutionTable table = new ModeResolutionTable(Collections.singletonList( + new ModeResolutionEntry(state -> false, ConnectionMode.STREAMING) + )); + 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()); + } + + // ==== ModeResolutionEntry tests ==== + + @Test + 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()); + } +} 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(() -> {