Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 11 additions & 12 deletions docs/architecture/08-cross-cutting-concerns.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<T>` for unary calls and `Flow<T>` 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<T>` (and `Flow<T>` 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

Expand Down
141 changes: 141 additions & 0 deletions docs/architecture/17-concurrency.md
Original file line number Diff line number Diff line change
@@ -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)<br/>injected via DispatcherModule"]
VM["ViewModel<br/>viewModelScope (auto-cancel)"]
Coord["Coordinator / Controller (@Singleton)<br/>CoroutineScope(IO + SupervisorJob)"]
PLO["ProcessLifecycleOwner + DefaultLifecycleObserver<br/>onStart/onStop start/cancel stream Jobs"]
Flow["StateFlow / SharedFlow<br/>stateIn(WhileSubscribed) · callbackFlow+shareIn"]
UI["Compose<br/>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.
146 changes: 146 additions & 0 deletions docs/architecture/18-feature-flags.md
Original file line number Diff line number Diff line change
@@ -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<T> @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<List<BetaFeature>>
fun observe(flag: FeatureFlag<*>): StateFlow<Boolean>
suspend fun get(flag: FeatureFlag<*>): Boolean
fun set(flag: FeatureFlag<*>, value: Boolean)
fun setOption(flag: FeatureFlag<*>, optionKey: String)
fun getOption(flag: FeatureFlag<*>): StateFlow<String>
fun observeOverride(): StateFlow<Boolean> // 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<T>`
([`FeatureFlag.kt`](../../apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt)):

```kotlin
@FeatureFlagMarker
data object CredentialManager : FeatureFlag<Boolean> {
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.
5 changes: 4 additions & 1 deletion docs/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,12 +80,15 @@ depend on app modules.* See [01 — Modules & boundaries](01-modules-and-boundar
| 14 | [Error handling](14-error-handling.md) | `Result<T>`, 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.
Expand Down
Loading