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
14 changes: 8 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Code/Flipcash is a mobile wallet app for instant, global, private payments using
bundle exec fastlane android flipcash_tests
```

**Requirements**: Java 21 (Corretto), `google-services.json` in `apps/flipcash/app/src/`, API keys in `local.properties` (BUGSNAG_API_KEY, FINGERPRINT_API_KEY, GOOGLE_CLOUD_PROJECT_NUMBER, MIXPANEL_API_KEY).
**Requirements**: Java 21 (Corretto), `google-services.json` in `apps/flipcash/app/src/`, API keys in `local.properties` (BUGSNAG_API_KEY, MIXPANEL_API_KEY, COINBASE_ONRAMP_API_KEY, GOOGLE_CLOUD_PROJECT_NUMBER). Keys resolve via `tryReadProperty` (local.properties → env var → empty), so a missing key won't fail the build but disables the dependent feature. See `docs/architecture/10-build-and-run.md`.

## Module Structure

Expand Down Expand Up @@ -72,13 +72,15 @@ The feature plugin automatically includes `:libs:logging`, `:ui:core`, `:ui:comp

## Architecture

- **Pattern**: MVI/MVVM hybrid with Compose-driven UI and reactive state
- **Pattern**: MVI/MVVM hybrid with Compose-driven UI and reactive state (`BaseViewModel<State, Event>`)
- **DI**: Hilt — all feature modules get Hilt via the convention plugin
- **Navigation**: Jetpack Navigation + Voyager for compose-based screens; custom `Router` controller
- **Navigation**: Jetpack **Navigation 3** (`androidx.navigation3`) wrapped by a custom `CodeNavigator`; a custom `Router` resolves deeplinks. (No Voyager.)
- **Networking**: gRPC with Protobuf for backend services; Retrofit/OkHttp for REST
- **Async**: Kotlin Coroutines + RxJava 3 (both coexist)
- **Persistence**: Room (encrypted with SQLCipher), DataStore for preferences
- **Crypto**: Libsodium, Ed25519, Solana/Kin SDK for on-chain operations
- **Async**: Kotlin Coroutines + Flow (no RxJava); dispatchers injected via `DispatcherProvider`
- **Persistence**: Room with a per-user database name derived from account entropy (not SQLCipher-encrypted); DataStore for preferences
- **Crypto**: Ed25519, mnemonic/key derivation, Solana SDK for on-chain operations

> Full architecture documentation lives in `docs/architecture/` (modules, state & DI, navigation, networking, persistence, payments, testing, and a "build & run" / "adding a feature" guide).

## Key Patterns

Expand Down
141 changes: 141 additions & 0 deletions docs/architecture/01-modules-and-boundaries.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# 01 — Modules & boundaries

Flipcash Android is a multi-module Gradle project of roughly **132 modules**. The
module graph is the architecture: layering, ownership, and what may depend on what
are all enforced by where a module lives and which **convention plugin** it applies.
This document is the map.

## Module inventory

| Group | Path prefix | Count | Role |
|-------|-------------|-------|------|
| App | `:apps:flipcash:app` | 1 | Entry point — `FlipcashApp`, `MainActivity`, the navigation host, DI wiring. Assembles everything. |
| Core | `:apps:flipcash:core` | 1 | App-wide infrastructure: `AppRoute` graph, `Local*` composition locals, shared models. Auto-injected into every feature. |
| Features | `:apps:flipcash:features:*` | 26 | Self-contained screens (login, cash, balance, tokens, scanner, withdrawal, …). Each owns its state, ViewModels, and UI. |
| Shared | `:apps:flipcash:shared:*` | 34 | Cross-feature coordinators, controllers, and services (authentication, session, router, payments, persistence, …). Coordinators own a domain's cached, session-aware state; see [02 — Roles](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services). |
| Services | `:services:*` | 4 | gRPC wrappers: `flipcash`, `flipcash-compose`, `opencode`, `opencode-compose`. |
| Definitions | `:definitions:*` | 4 | Protobuf sources (`*/protos`) and generated models (`*/models`) for Flipcash and OCP. |
| UI | `:ui:*` | 9 | Compose layer: `theme`, `components`, `core`, `resources`, `navigation`, `scanner`, `biometrics`, `emojis`, `testing`. |
| Libs | `:libs:*` | 21 | Leaf utilities: crypto/encryption, network, logging, currency, coroutines, permissions, locale, … |
| Vendor | `:vendor:*` | 3 | Third-party SDKs wrapped as modules: Kik scanner, OpenCV, TipKit. |

Every module is declared in [`settings.gradle.kts`](../../settings.gradle.kts).

## Dependency graph

```mermaid
graph TD
App[":apps:flipcash:app"]
Feat[":apps:flipcash:features:*"]
Shared[":apps:flipcash:shared:*"]
Core[":apps:flipcash:core"]
Svc[":services:*"]
Defs[":definitions:*:models"]
UI[":ui:*"]
Libs[":libs:*"]
Vendor[":vendor:*"]

App --> Feat
App --> Shared
App --> Core
Feat --> Shared
Feat --> Core
Feat --> UI
Feat --> Libs
Shared --> Svc
Shared --> Core
Shared --> UI
Shared --> Libs
Svc --> Defs
Svc --> Libs
Core --> Svc
Core --> UI
UI --> Libs
Defs --> Libs
Libs --> Vendor
```

*Arrows point to dependencies; the graph is acyclic.*

## Convention plugins

All modules apply a convention plugin from
[`build-logic/convention`](../../build-logic/convention/src/main/kotlin/) rather
than repeating Gradle config. There are four:

| Plugin | Source | Applies | Notable injected deps |
|--------|--------|---------|-----------------------|
| `flipcash.android.library` | `AndroidLibraryConventionPlugin.kt` | `com.android.library`, serialization, Java/Kotlin 21 toolchain, unit-test defaults | `timber`, `kotlinx-coroutines-core`, test utils |
| `flipcash.android.library.compose` | `AndroidLibraryComposeConventionPlugin.kt` | the base library plugin + Compose compiler | Compose BOM, `compose-ui`, `compose-foundation` |
| `flipcash.android.feature` | `AndroidFeatureConventionPlugin.kt` | the compose plugin + KSP + Hilt + Parcelize | see below |
| `flipcash.android.ed25519.shadow` | `AndroidEd25519ShadowConventionPlugin.kt` | shadow-jar handling for the Ed25519 native lib | used by `:services:opencode` |

### What the feature plugin gives you for free

`AndroidFeatureConventionPlugin` is the workhorse — every feature and most shared
modules use it. From
[`AndroidFeatureConventionPlugin.kt`](../../build-logic/convention/src/main/kotlin/AndroidFeatureConventionPlugin.kt):

```kotlin
"implementation"(libs.findBundle("hilt").get())
"ksp"(libs.findBundle("hilt-compiler").get())
"implementation"(libs.findBundle("compose").get())
"implementation"(libs.findLibrary("rinku-compose").get())

"implementation"(project(":libs:logging"))
"implementation"(project(":ui:core"))
"implementation"(project(":ui:components"))
"implementation"(project(":ui:navigation"))
"implementation"(project(":ui:resources"))
"implementation"(project(":ui:theme"))

if (path != ":apps:flipcash:core") {
"implementation"(project(":apps:flipcash:core"))
}
```

So a feature module never declares Hilt, Compose, the UI layer, logging, or
`:apps:flipcash:core` by hand — it inherits them. Its `build.gradle.kts` only lists
the *additional* shared modules, libs, or sibling features it needs.

## The `public` / `impl` / `bindings` pattern

Several libs split into three modules so consumers depend on an interface, not an
implementation:

- `:libs:<name>:public` — pure API: interfaces and data classes, **no Hilt**.
- `:libs:<name>:impl` — the implementation, `api(public)`, Hilt bindings.
- `:libs:<name>:bindings` — composition layer: `implementation(impl)` +
`api(public)`, re-exporting the API and wiring Hilt.

Consumers depend on `:bindings` and receive the API plus injection. Examples:
`:libs:locale:*`, `:libs:permissions:*`, `:libs:network:connectivity:*`,
`:libs:vibrator:*`.

## Boundary rules (enforced by convention)

1. **`ui/*` and `libs/*` never depend on app modules.** They may depend on other
`ui/*` / `libs/*` and on external libraries only. `ui/components` knows about
`libs/currency` and `ui/theme`, never about a feature.
2. **`libs/*` is leaf-level.** It may depend on other libs, `vendor/*`, and
`definitions/*:models` (data only), but never on services or app modules.
3. **`services/*` wrap protobuf, nothing app-specific.** They depend on
`definitions/*:models`, libs, and the gRPC runtime; they re-export public
interfaces with `api(...)`. They never depend on features or shared modules.
4. **`definitions/*:models` is generated; don't hand-edit it.** Regenerate from the
`.proto` sources in `definitions/*/protos`.
5. **Features may depend on shared modules, libs, ui, core, and (occasionally)
other features** — e.g. `:features:login` pulls in `:features:purchase` for the
onboarding hand-off. Keep cross-feature edges rare and acyclic.
6. **Shared modules depend on services, libs, ui, core, and each other** — never on
features. This is where business logic that several features share lives.
7. **`:apps:flipcash:app` is the only universal collector.** It depends on every
feature and the critical shared modules and assembles the graph.

## Why this matters

The plugin-injected baseline keeps modules uniform and small, the directory a
module lives in tells you its layer, and the acyclic graph means a change in a leaf
lib can't secretly reach back into a feature. New code almost never needs a new
convention plugin — pick the right directory and the right existing plugin, and the
boundaries enforce themselves.
201 changes: 201 additions & 0 deletions docs/architecture/02-state-and-dependency-injection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# 02 — State & dependency injection

Two patterns dominate how Flipcash wires dependencies and manages UI state:
**Hilt** provides singletons and ViewModels at the object-graph level, and a
**CompositionLocal** layer re-exposes the long-lived controllers to the Compose
tree so screens read them as ambient values instead of injecting each one. State
itself flows through a small MVI base class, `BaseViewModel<State, Event>`.

```mermaid
graph TD
Hilt["Hilt SingletonComponent<br/>controllers, services, channels"]
Activity["MainActivity (@AndroidEntryPoint)<br/>@Inject lateinit var ..."]
CL["CompositionLocalProvider<br/>LocalRouter, LocalExchange, LocalSessionController, ..."]
Screen["Composable screen<br/>LocalRouter.current"]
VM["hiltViewModel<XxxViewModel>()"]
State["stateFlow / eventFlow"]

Hilt --> Activity --> CL --> Screen
Screen --> VM --> State --> Screen
```

## Hilt setup

The application class
[`FlipcashApp`](../../apps/flipcash/app/src/main/kotlin/com/flipcash/app/FlipcashApp.kt)
is annotated `@HiltAndroidApp`. Bindings are organized into `@Module` objects, each
`@InstallIn(SingletonComponent::class)`, living next to the thing they provide
(e.g. `services/flipcash/.../inject/FlipcashModule.kt`,
`apps/flipcash/shared/router/.../inject/RouterModule.kt`,
`apps/flipcash/shared/session/.../inject/`). Most app-level dependencies are
`@Singleton`; ViewModels use `@HiltViewModel` (activity-retained scope). Custom
qualifiers disambiguate same-typed bindings — for example
`@FlipcashManagedChannel` vs `@FlipcashManagedStreamingChannel` for the two gRPC
channels (see [04 — Networking](04-networking.md)).

## The CompositionLocal injection pattern

Rather than `@Inject`-ing a dozen controllers into every screen,
[`MainActivity`](../../apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt)
injects them once and republishes them through a `CompositionLocalProvider`:

```kotlin
@AndroidEntryPoint
class MainActivity : FragmentActivity() {
@Inject lateinit var router: Router
@Inject lateinit var userManager: UserManager
@Inject lateinit var sessionController: SessionController
@Inject lateinit var exchange: Exchange
// ... ~20 injected controllers/services

override fun onCreate(savedInstanceState: Bundle?) {
// ...
setContent {
CompositionLocalProvider(
LocalRouter provides router,
LocalUserManager provides userManager,
LocalSessionController provides sessionController,
LocalExchange provides exchange,
// ... LocalShareController, LocalFeatureFlags, LocalToastController, etc.
) {
ProvidePermissionChecker(permissionChecker) {
Rinku { App(tipsEngine = tipsEngine) }
}
}
}
}
}
```

Each `Local*` is a `staticCompositionLocalOf` declared next to the type it carries,
so the dependency and its ambient handle live together. For example
[`Router.kt`](../../apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt):

```kotlin
val LocalRouter = staticCompositionLocalOf<Router?> { null }
```

Representative composition locals and where they're defined:

| Local | Type | Defined in |
|-------|------|-----------|
| `LocalRouter` | `Router` | `shared/router/.../Router.kt` |
| `LocalSessionController` | `SessionController` | `shared/session/.../SessionController.kt` |
| `LocalUserManager` | `UserManager` | `apps/flipcash/core/.../Locals.kt` |
| `LocalExchange` | `Exchange` | `services/opencode-compose/.../LocalExchange` |
| `LocalFeatureFlags` | `FeatureFlagController` | `shared/featureflags/.../FeatureFlagController.kt` |
| `LocalToastController` | `ToastController` | `apps/flipcash/core/.../toast/ToastController.kt` |
| `LocalNetworkObserver` | `NetworkConnectivityListener` | `libs/network/connectivity/...` |

A screen then reads `val session = LocalSessionController.current` instead of
taking it as a constructor parameter. Hilt still owns the lifecycle and singleton
identity — CompositionLocal is purely the delivery mechanism into Compose.

> **When to use which.** Inject **per-screen** dependencies through the ViewModel
> constructor (`@HiltViewModel`). Reach for a **`Local*`** only for the long-lived,
> app-wide controllers that `MainActivity` already provides.

## Roles: coordinators, controllers, managers, services

The injected dependencies above carry recurring suffixes — `Coordinator`,
`Controller`, `Manager`, `Service` — and they are **not interchangeable**. Each
names a specific role:

| Role | Responsibility | Lives in | Exposes |
|------|----------------|----------|---------|
| **Coordinator** | The **single source of truth for a domain** (contacts, tokens, chat, settings, activity feed). Wraps one or more stateless Controllers and adds **caching (memory + Room), persistence, and sync/consistency**. **Session- and lifecycle-aware.** | `apps/flipcash/shared/*` | `StateFlow` domain state; usually `: SessionListener, DefaultLifecycleObserver` |
| **Controller** | A domain/feature API. Service-layer controllers are often **stateless network gateways** (no caching/state); app-layer controllers expose light UI-facing state/actions. | `services/*/controllers/*`, `apps/flipcash/shared/*` | `suspend` actions + `Result<T>`, or light `StateFlow` |
| **Manager** | Owns a **state machine or resource lifecycle** (auth flow, credential storage), coordinating side effects across coordinators/controllers. | `services/*`, `apps/flipcash/shared/*` | `StateFlow` of a lifecycle/auth state (e.g. `UserManager` → `AuthState`) |
| **Service** | The **internal gRPC/REST adapter** — translates network responses to domain models. Never exposed to the UI. | `services/*/internal/network/services/*` | `Result<T>` of domain models |

### Coordinator vs Controller, by example

The cleanest illustration is tokens. `TokenController`
(`services/opencode/.../controllers/TokenController.kt`) describes itself in its
own KDoc as a **stateless network gateway**:

> *"This controller provides direct access to token-related network APIs without
> any caching, persistence, or state management… usable both within the Flipcash
> app (wrapped by `TokenCoordinator`) and as part of a standalone public SDK. All
> state management (caching, persistence, lifecycle, balance tracking) is the
> responsibility of the consumer."*

That consumer is the **Coordinator**. `TokenCoordinator`
(`apps/flipcash/shared/tokens/.../TokenCoordinator.kt`) wraps the controller,
implements `SessionListener, DefaultLifecycleObserver`, holds `StateFlow` state, and
serves reads from a **Memory → Room → network** cache — rehydrating on login and
reacting to foreground/background. `ContactCoordinator`
(`apps/flipcash/shared/contacts/.../ContactCoordinator.kt`) follows the same shape,
syncing device contacts ↔ persistence ↔ server. So: **a Controller is the stateless
domain API; a Coordinator is the stateful, session-aware owner of that domain's
cached state.** When in doubt, [09 — Separation of concerns](09-separation-of-concerns.md)
has a "where does this code go?" table.

## State: `BaseViewModel<State, Event>`

The MVI base class lives at
[`BaseViewModel.kt`](../../ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt):

```kotlin
abstract class BaseViewModel<ViewState : Any, Event : Any>(
initialState: ViewState,
private val updateStateForEvent: (Event) -> (ViewState.() -> ViewState),
private val defaultDispatcher: CoroutineContext = Dispatchers.Default,
) : ViewModel() {

private val _eventFlow = MutableSharedFlow<Event>()
val eventFlow: SharedFlow<Event> = _eventFlow.asSharedFlow()

private val _stateFlow = MutableStateFlow(initialState)
val stateFlow: StateFlow<ViewState> = _stateFlow.asStateFlow()

fun dispatchEvent(event: Event) {
setState(updateStateForEvent(event)) // synchronous reducer
viewModelScope.launch(defaultDispatcher) {
_eventFlow.emit(event) // async side-effect channel
}
}
}
```

The contract for every concrete ViewModel:

- **State** — an immutable `data class` describing the screen.
- **Event** — a `sealed interface` of user actions and system signals.
- **Reducer** — `updateStateForEvent`, a pure `(Event) -> (State.() -> State)`
function (conventionally a `companion object val`) that maps each event to a state
transform. It runs synchronously inside `dispatchEvent`.
- **Side effects** — collected off `eventFlow` in the `init` block, using Flow
operators (`filterIsInstance<…>()`, `onEach`, `flatMapLatest`, `combine`,
`launchIn(viewModelScope)`) to react to events and external `StateFlow`s.

A representative implementation is
[`CashScreenViewModel`](../../apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt),
which combines token, balance, and exchange-rate flows into its `State` and emits a
`PresentBill` event that the screen consumes to show a cash bill.

`BaseViewModel.kt` also ships `LoadingSuccessState` (`loading` / `success` / `error`
with an `Idle` default) for the common async-status pattern.

## Consuming state in Compose

```kotlin
@Composable
fun CashScreen(/* args */) {
val session = LocalSessionController.current!! // ambient controller
val viewModel = hiltViewModel<CashScreenViewModel>() // Hilt-built VM
val state by viewModel.stateFlow.collectAsStateWithLifecycle()

LaunchedEffect(viewModel) {
viewModel.eventFlow
.filterIsInstance<CashScreenViewModel.Event.PresentBill>()
.onEach { session.showBill(it.bill) }
.launchIn(this)
}
// render from `state`, send Event via viewModel.dispatchEvent(...)
}
```

`stateFlow` is collected lifecycle-aware for rendering; `eventFlow` is collected in
a `LaunchedEffect` for one-shot side effects (navigation, showing a bill, toasts).
This keeps rendering a pure function of `state` while side effects stay explicit.
Loading
Loading