From 1f6b36808bd213d23c47c1e88672d950e314dfb1 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Jun 2026 14:57:10 +0000 Subject: [PATCH] docs: add concurrency & feature-flags architecture docs Extends the docs/architecture/ suite with the two remaining topics: - 17-concurrency: dispatcher injection (DispatcherProvider), scope ownership (viewModelScope vs IO+SupervisorJob singletons, ProcessLifecycleOwner / DefaultLifecycleObserver), the StateFlow/SharedFlow + stateIn(WhileSubscribed) + callbackFlow/shareIn conventions, and lifecycle-aware collection. - 18-feature-flags: FeatureFlag @FeatureFlagMarker definitions + KSP-generated entries, FeatureFlagController/LocalFeatureFlags, DataStore storage, beta override + staff gating, and the feature-flags-vs-user-flags distinction. Trims doc 08's async section to a summary linking to 17, corrects its build-config key list (Coinbase on-ramp, not Fingerprint), and adds 17/18 to the README index and reading paths. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01JRVfsXp4HDrDMy7Pbmw9fD --- .../architecture/08-cross-cutting-concerns.md | 23 ++- docs/architecture/17-concurrency.md | 141 +++++++++++++++++ docs/architecture/18-feature-flags.md | 146 ++++++++++++++++++ docs/architecture/README.md | 5 +- 4 files changed, 302 insertions(+), 13 deletions(-) create mode 100644 docs/architecture/17-concurrency.md create mode 100644 docs/architecture/18-feature-flags.md diff --git a/docs/architecture/08-cross-cutting-concerns.md b/docs/architecture/08-cross-cutting-concerns.md index 802a61be3..a59b928d7 100644 --- a/docs/architecture/08-cross-cutting-concerns.md +++ b/docs/architecture/08-cross-cutting-concerns.md @@ -83,22 +83,21 @@ backgrounds), and applies a short cooldown so it doesn't re-prompt on every resu ## Async model -- **`DispatcherProvider`** (`libs/coroutines`) abstracts `Default` / `Main` / `IO` - dispatchers and is injected (rather than referencing `Dispatchers.*` directly) so - code is testable. -- **Controllers** hold a `CoroutineScope(Dispatchers.IO + SupervisorJob())` and - expose state as `StateFlow`. -- **Networking** uses `suspend` + `Result` for unary calls and `Flow` for - streams. -- **Compose** drives side effects with `LaunchedEffect` / `rememberCoroutineScope` - and collects state with `collectAsStateWithLifecycle()`. +The app is **Coroutines + Flow** throughout (no RxJava): `DispatcherProvider` +(`libs/coroutines`) is injected instead of touching `Dispatchers.*`, singletons own +an `IO + SupervisorJob` scope and expose `StateFlow`, networking uses `suspend` + +`Result` (and `Flow` for streams), and Compose collects with +`collectAsStateWithLifecycle()`. The full conventions — scope ownership, the +Flow-exposure pattern, and lifecycle-aware streaming — are in +[17 — Concurrency](17-concurrency.md). ## Build configuration `compileSdk 36`, `minSdk 29`, **Java/Kotlin 21**, all set by the convention plugins -(see [01](01-modules-and-boundaries.md)). API keys (Bugsnag, Fingerprint, Mixpanel, -Google Cloud project number) are read from `local.properties` and surfaced through -`BuildConfig`; `google-services.json` lives under `apps/flipcash/app/src/`. +(see [01](01-modules-and-boundaries.md)). API keys (Bugsnag, Mixpanel, Coinbase +on-ramp, Google Cloud project number) are read from `local.properties` and surfaced +through `BuildConfig`; `google-services.json` lives under `apps/flipcash/app/src/`. +See [10 — Build & run](10-build-and-run.md) for the full list. ## Why this matters diff --git a/docs/architecture/17-concurrency.md b/docs/architecture/17-concurrency.md new file mode 100644 index 000000000..e6b3e6bdf --- /dev/null +++ b/docs/architecture/17-concurrency.md @@ -0,0 +1,141 @@ +# 17 — Concurrency + +Flipcash is **Coroutines + Flow** end to end (no RxJava). This doc covers the +conventions that keep that manageable: who owns a `CoroutineScope`, when to inject +a dispatcher vs. use `viewModelScope`, how state is exposed as `Flow`, and how +collection stays lifecycle-aware. + +```mermaid +graph TD + DP["DispatcherProvider (Default / Main / IO)
injected via DispatcherModule"] + VM["ViewModel
viewModelScope (auto-cancel)"] + Coord["Coordinator / Controller (@Singleton)
CoroutineScope(IO + SupervisorJob)"] + PLO["ProcessLifecycleOwner + DefaultLifecycleObserver
onStart/onStop start/cancel stream Jobs"] + Flow["StateFlow / SharedFlow
stateIn(WhileSubscribed) · callbackFlow+shareIn"] + UI["Compose
collectAsStateWithLifecycle()"] + + DP --> VM + DP --> Coord + Coord --> PLO + VM --> Flow + Coord --> Flow + Flow --> UI +``` + +## Dispatchers are injected, not referenced + +Code does not call `Dispatchers.IO`/`Default`/`Main` directly; it depends on +`DispatcherProvider` +([`libs/coroutines/.../DispatcherProvider.kt`](../../libs/coroutines/src/main/kotlin/com/flipcash/libs/coroutines/DispatcherProvider.kt)): + +```kotlin +interface DispatcherProvider { + val Default: CoroutineDispatcher + val Main: CoroutineDispatcher + val IO: CoroutineDispatcher +} +``` + +The production binding is `DefaultDispatcherProvider` (just delegates to +`Dispatchers.*`), provided `@Singleton` by `DispatcherModule` +([`apps/flipcash/app/.../inject/DispatcherModule.kt`](../../apps/flipcash/app/src/main/kotlin/com/flipcash/app/inject/DispatcherModule.kt)). +Injecting it is what makes coroutine code deterministic in tests — swap in +`TestDispatchers` ([12 — Testing](12-testing.md)). + +- **CPU-bound work** → `dispatchers.Default` (also the default for `BaseViewModel` + event dispatch, [02](02-state-and-dependency-injection.md)). +- **Network / disk / DataStore / Room** → `dispatchers.IO`. +- **UI-touching work** → `dispatchers.Main` (rarely needed explicitly; Compose + collection is already on the main thread). + +## Scope ownership + +Every coroutine belongs to a scope owned by exactly one thing: + +| Owner | Scope | Cancellation | +|-------|-------|--------------| +| **ViewModel** | `viewModelScope` | Automatic on `onCleared()`. | +| **Coordinator / Controller** (`@Singleton`) | `CoroutineScope(Dispatchers.IO + SupervisorJob())` | Lives for the process; child failures are isolated by `SupervisorJob`. | +| **Composable** | `rememberCoroutineScope()` / `LaunchedEffect` | Tied to composition. | + +ViewModels never create their own scope — they use `viewModelScope` so work dies +with the screen. Long-lived singletons (e.g. `TransactionController`, +`TokenCoordinator`, `ChatCoordinator`, `UserFlagsCoordinator`) own one +`IO + SupervisorJob` scope and launch their flows into it. + +### App-lifecycle awareness + +There is **no custom `@ApplicationScope`**. Instead, singletons that stream from the +backend implement `DefaultLifecycleObserver` and register with the process lifecycle, +starting/cancelling streaming `Job`s as the app foregrounds/backgrounds: + +```kotlin +@Singleton +class TokenCoordinator @Inject constructor(/* ... */) : + SessionListener, DefaultLifecycleObserver, /* ... */ { + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private var streamReserveStateJob: Job? = null + + init { ProcessLifecycleOwner.get().lifecycle.addObserver(this) } + + override fun onStart(owner: LifecycleOwner) { streamReserveStates() } // foreground + override fun onStop(owner: LifecycleOwner) { streamReserveStateJob?.cancel() } // background +} +``` + +This keeps background work off when the app isn't visible, and resumes (re-syncing) +on return. Session transitions (login/logout) are handled the same way via +`SessionListener` — see the coordinator role in +[02](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services). + +## Flow conventions + +State is exposed as **read-only `Flow`**, mutated only internally: + +- Hold internal state in `MutableStateFlow` / `MutableSharedFlow`; expose it as + `StateFlow` / `SharedFlow` via `asStateFlow()` / `asSharedFlow()` (the + `BaseViewModel` pattern, [02](02-state-and-dependency-injection.md)). +- Derive hot state with + `stateIn(scope, SharingStarted.WhileSubscribed(5_000), default)` — shares one + upstream across collectors and stops it shortly after the last unsubscribes. + `SharingStarted.Eagerly` is used for always-on state (e.g. feature-flag overrides). +- Bridge callback APIs with `callbackFlow { … awaitClose { unregister } }`, then + `debounce` / `distinctUntilChanged` / `shareIn`. The connectivity observer + ([`Api24NetworkObserver`](../../libs/network/connectivity/impl/src/main/kotlin/com/getcode/utils/network/Api24NetworkObserver.kt)) + is the canonical example. +- Combine and switch with `combine`, `flatMapLatest`, `mapNotNull`, + `filterIsInstance`; attach side effects with `…onEach { }.launchIn(scope)`. + +## Lifecycle-aware collection + +UI collects with **`collectAsStateWithLifecycle()`** so collection pauses below +`STARTED` and resumes without re-subscribing churn: + +```kotlin +val state by viewModel.stateFlow.collectAsStateWithLifecycle() +``` + +For non-state work that must run only while a lifecycle is at a given state, use the +`RepeatOnLifecycle` helper +([`ui/navigation/.../utils/lifecycle/RepeatOnLifecycle.kt`](../../ui/navigation/src/main/kotlin/com/getcode/navigation/utils/lifecycle/RepeatOnLifecycle.kt)), +which wraps `repeatOnLifecycle`. One-shot effects (navigation, showing a bill) are +collected off `eventFlow` inside a `LaunchedEffect` ([02](02-state-and-dependency-injection.md)). + +## Testing + +Because dispatchers are injected, tests pass `TestDispatchers` (all of +`IO`/`Main`/`Default` mapped to one `StandardTestDispatcher`) and use +`MainCoroutineRule` to drive `viewModelScope`. Assert on flows with Turbine inside +`runTest`. See [12 — Testing](12-testing.md). + +## Guidance + +- **Own your scope.** ViewModel → `viewModelScope`; singleton → one + `IO + SupervisorJob` scope; never leak a `GlobalScope`. +- **Inject `DispatcherProvider`** instead of touching `Dispatchers.*` — it's the + testability seam. +- **Expose `StateFlow`/`SharedFlow`, never the mutable type.** +- **Gate streaming on lifecycle** (`ProcessLifecycleOwner` + `onStart`/`onStop`) so + background work stops. +- **Collect with `collectAsStateWithLifecycle()`** in Compose. diff --git a/docs/architecture/18-feature-flags.md b/docs/architecture/18-feature-flags.md new file mode 100644 index 000000000..cc2d6f945 --- /dev/null +++ b/docs/architecture/18-feature-flags.md @@ -0,0 +1,146 @@ +# 18 — Feature flags + +Flipcash has **two** independent toggle systems, and conflating them is the most +common mistake: + +- **Feature flags** — *client-side* toggles for rolling out / experimenting with app + features. Defined in code, stored locally, flipped by developers/staff. +- **User flags** — *server-driven, per-account* configuration and entitlements + (e.g. `isStaff`). Delivered with the account, optionally overridden locally. + +This doc covers feature flags first, then the distinction. + +```mermaid +graph TD + Def["FeatureFlag @FeatureFlagMarker data objects"] + KSP["KSP FeatureFlagProcessor -> FeatureFlag.entries"] + Ctrl["FeatureFlagController (InternalFeatureFlagController)"] + DS["DataStore 'beta-flags'"] + Local["LocalFeatureFlags (Compose)"] + Feat["Feature reads observe(flag)"] + User["UserFlags (server) via UserManager / UserFlagsCoordinator"] + Gate["combine(flag, userFlag) -> enabled"] + + Def --> KSP --> Ctrl + Ctrl --> DS + Ctrl --> Local --> Feat + Ctrl --> Gate + User --> Gate +``` + +## What a feature flag is + +A feature flag is a typed toggle behind the `FeatureFlagController` interface +([`apps/flipcash/shared/featureflags/.../FeatureFlagController.kt`](../../apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlagController.kt)): + +```kotlin +interface FeatureFlagController { + fun observe(): StateFlow> + fun observe(flag: FeatureFlag<*>): StateFlow + suspend fun get(flag: FeatureFlag<*>): Boolean + fun set(flag: FeatureFlag<*>, value: Boolean) + fun setOption(flag: FeatureFlag<*>, optionKey: String) + fun getOption(flag: FeatureFlag<*>): StateFlow + fun observeOverride(): StateFlow // global beta unlock + fun enableBetaFeatures(); fun disableBetaFeatures() + fun reset(flag: FeatureFlag<*>); fun reset() +} +``` + +The implementation `InternalFeatureFlagController` persists values in a DataStore +(`beta-flags`). It's provided `@Singleton` by `FeatureFlagModule` and surfaced to +Compose as `LocalFeatureFlags` (provided in `MainActivity`, +[02](02-state-and-dependency-injection.md)); outside a provider the default is the +inert `NoOpFeatureFlagController`. + +## Defining / adding a flag + +Flags are `@FeatureFlagMarker data object`s implementing `FeatureFlag` +([`FeatureFlag.kt`](../../apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt)): + +```kotlin +@FeatureFlagMarker +data object CredentialManager : FeatureFlag { + override val key = "credential_manager_enabled" // DataStore key + override val default = false + override val launched = false // true => fully shipped, no longer toggleable + override val visible = true // shows in debug menus + override val persistLogOut = true // survives logout +} +``` + +Boolean flags are the norm (`VibrateOnScan`, `BillCustomizer`, `CurrencyCreator`, +`Messenger`, …); option flags carry a list of choices (e.g. `BackgroundReset`). A +**KSP processor** (`FeatureFlagProcessor`, `apps/flipcash/shared/ksp/`) discovers +every `@FeatureFlagMarker` and generates the `FeatureFlag.entries` registry the +debug UI iterates. + +**To add a flag:** declare a new `@FeatureFlagMarker data object` in `FeatureFlag.kt` +with a unique `key` and `default` (plus title/description text alongside the other +flags). KSP regenerates `entries`; no manual registration. Read it where needed. + +## Reading / observing a flag + +```kotlin +// In a coordinator/controller (inject FeatureFlagController): +featureFlags.observe(FeatureFlag.BillTextures) + .onEach { enabled -> _state.update { it.copy(enabled = enabled) } } + .launchIn(scope) + +// In Compose: +val flags = LocalFeatureFlags.current +``` + +Prefer `observe(flag)` (reactive `StateFlow`) so the UI updates when a flag flips; +use `get(flag)` for a one-shot read. + +## Beta override & staff gating + +Flags that aren't `launched` are hidden unless **beta features** are unlocked, via +either `observeOverride()` (a debug-menu toggle) or staff status from the user's +account flags. Features combine the two: + +```kotlin +combine( + featureFlagController.observeOverride(), + userManager.state.map { it.flags?.isStaff == true }, +) { override, isStaff -> override || isStaff } + .onEach { dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) } + .launchIn(viewModelScope) +``` + +(See `MyAccountScreenViewModel` and `LabsScreenViewModel`.) + +## Feature flags vs user flags + +| | Feature flags | User flags | +|---|---------------|-----------| +| **Module** | `:apps:flipcash:shared:featureflags` | `:apps:flipcash:shared:userflags` | +| **Owner** | `FeatureFlagController` | `UserFlagsCoordinator` | +| **Source** | Client; defined in `FeatureFlag.kt` | **Server**, per account (`UserFlags`) | +| **Storage** | Local DataStore (`beta-flags`) | Account state + local override DataStore | +| **Purpose** | Feature rollout / experiments | Entitlements & account config (`isStaff`, `enablePhoneNumberSend`, `preferredOnRampProvider`) | +| **Read via** | `observe(flag)` / `LocalFeatureFlags` | `userManager.state.flags` / `UserFlagsCoordinator.resolvedFlags` | + +`UserFlags` lives in +[`services/flipcash/.../models/UserFlags.kt`](../../services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt); +`UserFlagsCoordinator` resolves each as a `ResolvedFlag` whose `effectiveValue` is the +server value unless a local override is set (staff/debug). A feature often gates on +**either** signal — e.g. `ChatCoordinator` enables messaging when +`FeatureFlag.PhoneNumberSend` **or** the server's `enablePhoneNumberSend` is on: + +```kotlin +combine( + featureFlags.observe(FeatureFlag.PhoneNumberSend), + userManager.state.map { it.flags?.enablePhoneNumberSend == true }, +) { feature, server -> feature || server } +``` + +## Guidance + +- **Pick the right system:** a client rollout toggle → feature flag; an + account-level entitlement from the backend → user flag. +- **Add flags in `FeatureFlag.kt`** and let KSP register them; don't maintain a + manual list. +- **Observe, don't poll** — `observe(flag)` so UI reacts to changes. +- **Mark a flag `launched`** once it ships to retire the toggle. diff --git a/docs/architecture/README.md b/docs/architecture/README.md index e068f0544..655914602 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -80,12 +80,15 @@ depend on app modules.* See [01 — Modules & boundaries](01-modules-and-boundar | 14 | [Error handling](14-error-handling.md) | `Result`, typed sealed errors, `NotifiableError`, `retryable` | | 15 | [CI & release](15-ci-and-release.md) | The CI check, Fastlane lanes, release workflows, helper skills | | 16 | [Agents & skills](16-agents-and-skills.md) | The repo's Claude Code agents/skills and which task each one fits | +| 17 | [Concurrency](17-concurrency.md) | Dispatchers, scope ownership, Flow conventions, lifecycle-aware collection | +| 18 | [Feature flags](18-feature-flags.md) | Defining/observing flags, beta & staff gating, feature flags vs user flags | | — | [Glossary](glossary.md) | Domain & architecture terms (USDF, intent, timelock, coordinator, …) | ## Suggested reading paths - **New to the codebase** → 10 (build & run) → README → 01 → 02 → 09, then the feature catalog. -- **Building a feature** → 11 (adding a feature) → feature catalog → 03 → 07 → 02. +- **Building a feature** → 11 (adding a feature) → feature catalog → 03 → 07 → 02 → 18 (feature flags). +- **Writing async code** → 17 (concurrency) → 02 → 12 (testing). - **Writing tests** → 12 (testing) → [Compose UI Testing Guide](../compose-ui-testing.md). - **Working on payments** → 06 → 04 → 05 → 14 (error handling). - **Backend / proto changes** → 13 (protobuf & codegen) → 04 → 05 → 14.