|
| 1 | +# 18 — Feature flags |
| 2 | + |
| 3 | +Flipcash has **two** independent toggle systems, and conflating them is the most |
| 4 | +common mistake: |
| 5 | + |
| 6 | +- **Feature flags** — *client-side* toggles for rolling out / experimenting with app |
| 7 | + features. Defined in code, stored locally, flipped by developers/staff. |
| 8 | +- **User flags** — *server-driven, per-account* configuration and entitlements |
| 9 | + (e.g. `isStaff`). Delivered with the account, optionally overridden locally. |
| 10 | + |
| 11 | +This doc covers feature flags first, then the distinction. |
| 12 | + |
| 13 | +```mermaid |
| 14 | +graph TD |
| 15 | + Def["FeatureFlag<T> @FeatureFlagMarker data objects"] |
| 16 | + KSP["KSP FeatureFlagProcessor -> FeatureFlag.entries"] |
| 17 | + Ctrl["FeatureFlagController (InternalFeatureFlagController)"] |
| 18 | + DS["DataStore 'beta-flags'"] |
| 19 | + Local["LocalFeatureFlags (Compose)"] |
| 20 | + Feat["Feature reads observe(flag)"] |
| 21 | + User["UserFlags (server) via UserManager / UserFlagsCoordinator"] |
| 22 | + Gate["combine(flag, userFlag) -> enabled"] |
| 23 | +
|
| 24 | + Def --> KSP --> Ctrl |
| 25 | + Ctrl --> DS |
| 26 | + Ctrl --> Local --> Feat |
| 27 | + Ctrl --> Gate |
| 28 | + User --> Gate |
| 29 | +``` |
| 30 | + |
| 31 | +## What a feature flag is |
| 32 | + |
| 33 | +A feature flag is a typed toggle behind the `FeatureFlagController` interface |
| 34 | +([`apps/flipcash/shared/featureflags/.../FeatureFlagController.kt`](../../apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlagController.kt)): |
| 35 | + |
| 36 | +```kotlin |
| 37 | +interface FeatureFlagController { |
| 38 | + fun observe(): StateFlow<List<BetaFeature>> |
| 39 | + fun observe(flag: FeatureFlag<*>): StateFlow<Boolean> |
| 40 | + suspend fun get(flag: FeatureFlag<*>): Boolean |
| 41 | + fun set(flag: FeatureFlag<*>, value: Boolean) |
| 42 | + fun setOption(flag: FeatureFlag<*>, optionKey: String) |
| 43 | + fun getOption(flag: FeatureFlag<*>): StateFlow<String> |
| 44 | + fun observeOverride(): StateFlow<Boolean> // global beta unlock |
| 45 | + fun enableBetaFeatures(); fun disableBetaFeatures() |
| 46 | + fun reset(flag: FeatureFlag<*>); fun reset() |
| 47 | +} |
| 48 | +``` |
| 49 | + |
| 50 | +The implementation `InternalFeatureFlagController` persists values in a DataStore |
| 51 | +(`beta-flags`). It's provided `@Singleton` by `FeatureFlagModule` and surfaced to |
| 52 | +Compose as `LocalFeatureFlags` (provided in `MainActivity`, |
| 53 | +[02](02-state-and-dependency-injection.md)); outside a provider the default is the |
| 54 | +inert `NoOpFeatureFlagController`. |
| 55 | + |
| 56 | +## Defining / adding a flag |
| 57 | + |
| 58 | +Flags are `@FeatureFlagMarker data object`s implementing `FeatureFlag<T>` |
| 59 | +([`FeatureFlag.kt`](../../apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt)): |
| 60 | + |
| 61 | +```kotlin |
| 62 | +@FeatureFlagMarker |
| 63 | +data object CredentialManager : FeatureFlag<Boolean> { |
| 64 | + override val key = "credential_manager_enabled" // DataStore key |
| 65 | + override val default = false |
| 66 | + override val launched = false // true => fully shipped, no longer toggleable |
| 67 | + override val visible = true // shows in debug menus |
| 68 | + override val persistLogOut = true // survives logout |
| 69 | +} |
| 70 | +``` |
| 71 | + |
| 72 | +Boolean flags are the norm (`VibrateOnScan`, `BillCustomizer`, `CurrencyCreator`, |
| 73 | +`Messenger`, …); option flags carry a list of choices (e.g. `BackgroundReset`). A |
| 74 | +**KSP processor** (`FeatureFlagProcessor`, `apps/flipcash/shared/ksp/`) discovers |
| 75 | +every `@FeatureFlagMarker` and generates the `FeatureFlag.entries` registry the |
| 76 | +debug UI iterates. |
| 77 | + |
| 78 | +**To add a flag:** declare a new `@FeatureFlagMarker data object` in `FeatureFlag.kt` |
| 79 | +with a unique `key` and `default` (plus title/description text alongside the other |
| 80 | +flags). KSP regenerates `entries`; no manual registration. Read it where needed. |
| 81 | + |
| 82 | +## Reading / observing a flag |
| 83 | + |
| 84 | +```kotlin |
| 85 | +// In a coordinator/controller (inject FeatureFlagController): |
| 86 | +featureFlags.observe(FeatureFlag.BillTextures) |
| 87 | + .onEach { enabled -> _state.update { it.copy(enabled = enabled) } } |
| 88 | + .launchIn(scope) |
| 89 | + |
| 90 | +// In Compose: |
| 91 | +val flags = LocalFeatureFlags.current |
| 92 | +``` |
| 93 | + |
| 94 | +Prefer `observe(flag)` (reactive `StateFlow`) so the UI updates when a flag flips; |
| 95 | +use `get(flag)` for a one-shot read. |
| 96 | + |
| 97 | +## Beta override & staff gating |
| 98 | + |
| 99 | +Flags that aren't `launched` are hidden unless **beta features** are unlocked, via |
| 100 | +either `observeOverride()` (a debug-menu toggle) or staff status from the user's |
| 101 | +account flags. Features combine the two: |
| 102 | + |
| 103 | +```kotlin |
| 104 | +combine( |
| 105 | + featureFlagController.observeOverride(), |
| 106 | + userManager.state.map { it.flags?.isStaff == true }, |
| 107 | +) { override, isStaff -> override || isStaff } |
| 108 | + .onEach { dispatchEvent(Event.OnBetaFeaturesUnlocked(it)) } |
| 109 | + .launchIn(viewModelScope) |
| 110 | +``` |
| 111 | + |
| 112 | +(See `MyAccountScreenViewModel` and `LabsScreenViewModel`.) |
| 113 | + |
| 114 | +## Feature flags vs user flags |
| 115 | + |
| 116 | +| | Feature flags | User flags | |
| 117 | +|---|---------------|-----------| |
| 118 | +| **Module** | `:apps:flipcash:shared:featureflags` | `:apps:flipcash:shared:userflags` | |
| 119 | +| **Owner** | `FeatureFlagController` | `UserFlagsCoordinator` | |
| 120 | +| **Source** | Client; defined in `FeatureFlag.kt` | **Server**, per account (`UserFlags`) | |
| 121 | +| **Storage** | Local DataStore (`beta-flags`) | Account state + local override DataStore | |
| 122 | +| **Purpose** | Feature rollout / experiments | Entitlements & account config (`isStaff`, `enablePhoneNumberSend`, `preferredOnRampProvider`) | |
| 123 | +| **Read via** | `observe(flag)` / `LocalFeatureFlags` | `userManager.state.flags` / `UserFlagsCoordinator.resolvedFlags` | |
| 124 | + |
| 125 | +`UserFlags` lives in |
| 126 | +[`services/flipcash/.../models/UserFlags.kt`](../../services/flipcash/src/main/kotlin/com/flipcash/services/models/UserFlags.kt); |
| 127 | +`UserFlagsCoordinator` resolves each as a `ResolvedFlag` whose `effectiveValue` is the |
| 128 | +server value unless a local override is set (staff/debug). A feature often gates on |
| 129 | +**either** signal — e.g. `ChatCoordinator` enables messaging when |
| 130 | +`FeatureFlag.PhoneNumberSend` **or** the server's `enablePhoneNumberSend` is on: |
| 131 | + |
| 132 | +```kotlin |
| 133 | +combine( |
| 134 | + featureFlags.observe(FeatureFlag.PhoneNumberSend), |
| 135 | + userManager.state.map { it.flags?.enablePhoneNumberSend == true }, |
| 136 | +) { feature, server -> feature || server } |
| 137 | +``` |
| 138 | + |
| 139 | +## Guidance |
| 140 | + |
| 141 | +- **Pick the right system:** a client rollout toggle → feature flag; an |
| 142 | + account-level entitlement from the backend → user flag. |
| 143 | +- **Add flags in `FeatureFlag.kt`** and let KSP register them; don't maintain a |
| 144 | + manual list. |
| 145 | +- **Observe, don't poll** — `observe(flag)` so UI reacts to changes. |
| 146 | +- **Mark a flag `launched`** once it ships to retire the toggle. |
0 commit comments