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/ConnectionMode.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java new file mode 100644 index 00000000..a256ec96 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectionMode.java @@ -0,0 +1,39 @@ +package com.launchdarkly.sdk.android; + +/** + * Enumerates the built-in FDv2 connection modes. Each mode maps to a + * {@link ModeDefinition} that specifies which initializers and synchronizers + * are active when the SDK is operating in that mode. + *

+ * 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. + *

+ * Package-private — not part of the public SDK API. + * + * @see ModeDefinition + * @see ModeResolutionTable + */ +final class ConnectionMode { + + static final ConnectionMode STREAMING = new ConnectionMode("streaming"); + static final ConnectionMode POLLING = new ConnectionMode("polling"); + static final ConnectionMode OFFLINE = new ConnectionMode("offline"); + static final ConnectionMode ONE_SHOT = new ConnectionMode("one-shot"); + static final ConnectionMode BACKGROUND = new ConnectionMode("background"); + + private final String name; + + private ConnectionMode(String name) { + this.name = name; + } + + @Override + public String toString() { + return name; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ConnectivityManager.java index 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/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..87bf7118 --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/FDv2DataSourceBuilder.java @@ -0,0 +1,254 @@ +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 com.launchdarkly.sdk.android.integrations.PollingDataSourceBuilder; +import com.launchdarkly.sdk.android.integrations.StreamingDataSourceBuilder; +import com.launchdarkly.sdk.android.subsystems.TransactionalDataStore; +import com.launchdarkly.sdk.internal.http.HttpProperties; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Arrays; +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} by resolving {@link ComponentConfigurer} factories + * into zero-arg {@link FDv2DataSource.DataSourceFactory} instances. The builder is the + * sole owner of mode resolution; {@link ConnectivityManager} configures the target mode + * via {@link #setActiveMode} before calling the standard {@link #build}. + *

+ * Package-private — not part of the public SDK API. + */ +class FDv2DataSourceBuilder implements ComponentConfigurer { + + private final Map modeTable; + private final ConnectionMode startingMode; + + 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); + } + + private static Map makeDefaultModeTable() { + ComponentConfigurer pollingInitializer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + return new FDv2PollingInitializer(requestor, selectorSource, + Executors.newSingleThreadExecutor(), ctx.getBaseLogger()); + }; + + ComponentConfigurer pollingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); + return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + 0, PollingDataSourceBuilder.DEFAULT_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); + }; + + ComponentConfigurer streamingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI streamBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getStreamingBaseUri(), + StandardEndpoints.DEFAULT_STREAMING_BASE_URI, + "streaming", ctx.getBaseLogger()); + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + return new FDv2StreamingSynchronizer( + ctx.getEvaluationContext(), selectorSource, streamBase, + StandardEndpoints.FDV2_STREAMING_REQUEST_BASE_PATH, + requestor, + StreamingDataSourceBuilder.DEFAULT_INITIAL_RECONNECT_DELAY_MILLIS, + ctx.isEvaluationReasons(), ctx.getHttp().isUseReport(), + httpProps, Executors.newSingleThreadExecutor(), + ctx.getBaseLogger(), null); + }; + + ComponentConfigurer backgroundPollingSynchronizer = ctx -> { + ClientContextImpl impl = ClientContextImpl.get(ctx); + TransactionalDataStore store = impl.getTransactionalDataStore(); + SelectorSource selectorSource = store != null + ? new SelectorSourceFacade(store) + : () -> com.launchdarkly.sdk.fdv2.Selector.EMPTY; + URI pollingBase = StandardEndpoints.selectBaseUri( + ctx.getServiceEndpoints().getPollingBaseUri(), + StandardEndpoints.DEFAULT_POLLING_BASE_URI, + "polling", ctx.getBaseLogger()); + HttpProperties httpProps = LDUtil.makeHttpProperties(ctx); + FDv2Requestor requestor = new DefaultFDv2Requestor( + ctx.getEvaluationContext(), pollingBase, + StandardEndpoints.FDV2_POLLING_REQUEST_GET_BASE_PATH, + StandardEndpoints.FDV2_POLLING_REQUEST_REPORT_BASE_PATH, + httpProps, ctx.getHttp().isUseReport(), + ctx.isEvaluationReasons(), null, ctx.getBaseLogger()); + ScheduledExecutorService exec = Executors.newScheduledThreadPool(1); + return new FDv2PollingSynchronizer(requestor, selectorSource, exec, + 0, LDConfig.DEFAULT_BACKGROUND_POLL_INTERVAL_MILLIS, ctx.getBaseLogger()); + }; + + Map table = new LinkedHashMap<>(); + table.put(ConnectionMode.STREAMING, new ModeDefinition( + Arrays.asList(pollingInitializer, pollingInitializer), + Arrays.asList(streamingSynchronizer, pollingSynchronizer) + )); + table.put(ConnectionMode.POLLING, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.singletonList(pollingSynchronizer) + )); + table.put(ConnectionMode.OFFLINE, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.>emptyList() + )); + table.put(ConnectionMode.ONE_SHOT, new ModeDefinition( + Arrays.asList(pollingInitializer, pollingInitializer, pollingInitializer), + Collections.>emptyList() + )); + table.put(ConnectionMode.BACKGROUND, new ModeDefinition( + Collections.singletonList(pollingInitializer), + Collections.singletonList(backgroundPollingSynchronizer) + )); + return table; + } + + FDv2DataSourceBuilder( + @NonNull Map modeTable, + @NonNull ConnectionMode startingMode + ) { + this.modeTable = Collections.unmodifiableMap(new LinkedHashMap<>(modeTable)); + this.startingMode = startingMode; + } + + @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); + } +} 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..81d69b8f --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeDefinition.java @@ -0,0 +1,48 @@ +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}. + *

+ * Package-private — not part of the public SDK API. + * + * @see ConnectionMode + * @see ResolvedModeDefinition + */ +final class ModeDefinition { + + private final List> initializers; + private final List> synchronizers; + + ModeDefinition( + @NonNull List> initializers, + @NonNull List> synchronizers + ) { + this.initializers = Collections.unmodifiableList(initializers); + this.synchronizers = Collections.unmodifiableList(synchronizers); + } + + @NonNull + List> getInitializers() { + return initializers; + } + + @NonNull + List> getSynchronizers() { + return synchronizers; + } +} diff --git a/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionEntry.java new file mode 100644 index 00000000..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..f6b1817a --- /dev/null +++ b/launchdarkly-android-client-sdk/src/main/java/com/launchdarkly/sdk/android/ModeResolutionTable.java @@ -0,0 +1,65 @@ +package com.launchdarkly.sdk.android; + +import androidx.annotation.NonNull; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** + * An ordered list of {@link ModeResolutionEntry} values that maps a {@link ModeState} + * to a {@link ConnectionMode}. The first entry whose condition matches wins. + *

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

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

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