diff --git a/CLAUDE.md b/CLAUDE.md index 2c35bc121..35f2bb97b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 @@ -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`) - **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 diff --git a/docs/architecture/01-modules-and-boundaries.md b/docs/architecture/01-modules-and-boundaries.md new file mode 100644 index 000000000..2f15dc47e --- /dev/null +++ b/docs/architecture/01-modules-and-boundaries.md @@ -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::public` — pure API: interfaces and data classes, **no Hilt**. +- `:libs::impl` — the implementation, `api(public)`, Hilt bindings. +- `:libs::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. diff --git a/docs/architecture/02-state-and-dependency-injection.md b/docs/architecture/02-state-and-dependency-injection.md new file mode 100644 index 000000000..252895309 --- /dev/null +++ b/docs/architecture/02-state-and-dependency-injection.md @@ -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`. + +```mermaid +graph TD + Hilt["Hilt SingletonComponent
controllers, services, channels"] + Activity["MainActivity (@AndroidEntryPoint)
@Inject lateinit var ..."] + CL["CompositionLocalProvider
LocalRouter, LocalExchange, LocalSessionController, ..."] + Screen["Composable screen
LocalRouter.current"] + VM["hiltViewModel()"] + 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 { 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`, 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` 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` + +The MVI base class lives at +[`BaseViewModel.kt`](../../ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt): + +```kotlin +abstract class BaseViewModel( + initialState: ViewState, + private val updateStateForEvent: (Event) -> (ViewState.() -> ViewState), + private val defaultDispatcher: CoroutineContext = Dispatchers.Default, +) : ViewModel() { + + private val _eventFlow = MutableSharedFlow() + val eventFlow: SharedFlow = _eventFlow.asSharedFlow() + + private val _stateFlow = MutableStateFlow(initialState) + val stateFlow: StateFlow = _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() // Hilt-built VM + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel) { + viewModel.eventFlow + .filterIsInstance() + .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. diff --git a/docs/architecture/03-navigation.md b/docs/architecture/03-navigation.md new file mode 100644 index 000000000..38f5f69fc --- /dev/null +++ b/docs/architecture/03-navigation.md @@ -0,0 +1,153 @@ +# 03 — Navigation + +> **Note on stacks:** Flipcash navigates with **Jetpack Navigation 3** +> (`androidx.navigation3`) wrapped by a custom `CodeNavigator`. There is **no +> Voyager** dependency in the project. + +Navigation has three moving parts: a **typed route graph** (`AppRoute`), a +**navigator** that drives a Navigation3 back stack (`CodeNavigator` + +`AppNavHost`), and a **`Router`** that turns external deeplinks into navigation +actions, gated by auth state. + +```mermaid +graph TD + DL["DeepLink (URL / QR)"] + Router["Router.dispatch()"] + Auth{"AuthState.Ready?"} + Action["DeeplinkAction (Navigate / Login / OpenCashLink / None)"] + Nav["CodeNavigator (push / pop / replaceAll / hide)"] + BackStack["NavBackStack"] + Entry["appEntryProvider: AppRoute -> Composable"] + Scene["SceneStrategy (SinglePane / ModalBottomSheet)"] + Screen["Feature screen"] + + DL --> Router --> Auth + Auth -->|yes| Action + Auth -->|no| Action + Action --> Nav --> BackStack --> Entry --> Scene --> Screen +``` + +## The route graph: `AppRoute` + +Routes are a serializable, parcelable sealed hierarchy in +[`AppRoute.kt`](../../apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt). +Every route is a Navigation3 `NavKey`: + +```kotlin +@Serializable +@Parcelize +sealed interface AppRoute : NavKey, Parcelable { + + @Serializable @Parcelize + data object Loading : AppRoute + + @Serializable @Parcelize + data class OnboardingFlow( + val phase: Phase = Phase.Account, + val seed: String? = null, + val resumeAt: ResumePoint = ResumePoint.Login, + // ... + ) : AppRoute, FlowRoute { + override val initialStack: List get() = /* steps for this phase */ + } + + sealed interface Token : AppRoute { /* Info, Swap, ... */ } + sealed interface Transfers : AppRoute { /* Deposit, Withdrawal */ } + sealed interface Messaging : AppRoute { /* Chat */ } + // Onboarding, Main, Sheets, Menu, ... +} +``` + +Two marker interfaces from `com.getcode.navigation.flow` model multi-screen flows: + +- **`FlowRoute`** — a route that expands into an `initialStack` of inner steps + (e.g. `OnboardingFlow` → `OnboardingStep.Start`, `…AccessKey`, `…Purchase`). +- **`FlowRouteWithResult`** — a flow that returns a typed result to its caller + (e.g. `Token.Swap` → `SwapResult`, `Transfers.Deposit` → `DepositResult`). + +Because routes are `@Serializable` + `@Parcelize`, the back stack survives process +death and deeplinks can be expressed as route lists. + +## The navigator and host + +The root composable +[`App.kt`](../../apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt) +creates the back stack and navigator and hosts the graph: + +```kotlin +val backStack = remember { NavBackStack(AppRoute.Loading) } +val codeNavigator = rememberCodeNavigator(backStack = backStack, /* ... */) + +CompositionLocalProvider(LocalCodeNavigator provides codeNavigator) { + AppNavHost( + navigator = codeNavigator, + sceneStrategies = listOf( + ModalBottomSheetSceneStrategy(/* ... */), + SinglePaneSceneStrategy(), + ), + entryProvider = appEntryProvider(/* ... */), + ) +} +``` + +- **`CodeNavigator`** (`com.getcode.navigation.core`, in `:ui:navigation`) is the + public navigation API. Screens obtain it via `LocalCodeNavigator.current` and call + `push(route)`, `push(routes)`, `pop()`, `replaceAll(routes)`, and `hide()` (to + dismiss a bottom sheet). +- **Scene strategies** decide presentation: `SinglePaneSceneStrategy` for + full-screen content, `ModalBottomSheetSceneStrategy` for sheets. +- **`appEntryProvider`** maps each `AppRoute` to its composable. + +## Registering screens + +Each feature exports its screen composable; the app wires routes to screens in one +place, +[`AppScreenContent.kt`](../../apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt), +via an `entryProvider { … }` builder: + +```kotlin +fun appEntryProvider(/* ... */): (NavKey) -> NavEntry = entryProvider { + annotatedEntry { MainRoot(/* ... */) } + annotatedEntry { key -> OnboardingFlowScreen(route = key, /* ... */) } + annotatedEntry { key -> CashScreen(key.mint, key.fromTokenInfo) } + annotatedEntry { key -> TokenInfoScreen(mint = key.mint) } + annotatedEntry { key -> SwapFlowScreen(route = key, /* ... */) } + annotatedEntry { key -> WithdrawalFlowScreen(route = key, /* ... */) } + annotatedEntry { key -> ChatFlowScreen(route = key, /* ... */) } + // ... +} +``` + +Features stay self-contained (own screens, ViewModels, Hilt modules); all routes +converge here. Adding a screen means: define the `AppRoute`, export the composable +from the feature, and register one `annotatedEntry`. + +## Deeplinks: the `Router` + +The [`Router`](../../apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/Router.kt) +interface has two jobs: + +```kotlin +interface Router { + /** Parse + classify + resolve routes in one call. Called once per deeplink. */ + fun dispatch(deepLink: DeepLink): DeeplinkAction + /** Classify a URL (for QR scanning) without resolving routes. */ + fun classify(deepLink: DeepLink): DeeplinkType? +} +``` + +`AppRouter` (the implementation, in `router/internal/`) classifies the URL into a +`DeeplinkType` (`Login`, `CashLink`, `TokenInfo`, `Chat`, …) and then **gates on +auth**: unauthenticated users are redirected into onboarding; authenticated users +get the real `DeeplinkAction` (`Navigate(routes)`, `Login(entropy)`, +`OpenCashLink(entropy)`, or `None`). Deeplink intake itself is handled by **Rinku** +(`dev.theolm.rinku`), wired in `MainActivity`; `App.kt` observes the incoming link +and feeds it to `router.dispatch(...)`, then pushes the resulting routes onto the +navigator. + +## Why this matters + +Typed `NavKey` routes give compile-time-checked navigation and free state +restoration; the single `appEntryProvider` keeps feature modules decoupled from one +another; and routing all external links through `Router` means **auth gating lives +in exactly one place** instead of being re-checked on every screen. diff --git a/docs/architecture/04-networking.md b/docs/architecture/04-networking.md new file mode 100644 index 000000000..514b5b00c --- /dev/null +++ b/docs/architecture/04-networking.md @@ -0,0 +1,137 @@ +# 04 — Networking + +Flipcash talks to two **gRPC** backends — the Flipcash service (accounts, +profiles, chat, activity) and the Open Code Protocol / OCP service (transactions, +intents, exchange rates) — plus a little **REST** for the Coinbase on-ramp. The +gRPC code is organized as a strict four-layer stack so that protobuf details never +leak up into features. + +```mermaid +graph TD + Feature["Feature / shared coordinator"] + Controller["Controller — public, stateful API"] + Repository["Repository — orchestration, caching"] + Service["Service — Result, error mapping"] + Api["Api — gRPC stub, request build, validation, signing"] + Channel["ManagedChannel (unary / streaming)"] + Backend["gRPC backend"] + + Feature --> Controller --> Repository --> Service --> Api --> Channel --> Backend +``` + +## The four layers + +For both `:services:flipcash` and `:services:opencode`: + +| Layer | Location | Responsibility | +|-------|----------|----------------| +| **Api** | `…/internal/network/api/` | Holds the generated gRPC stub, builds requests, validates them (`protovalidate`), signs/authenticates, returns raw protobuf. E.g. `AccountApi`, `TransactionApi`. | +| **Service** | `…/internal/network/services/` | Wraps an Api, converts responses to `Result`, maps RPC/status errors to typed domain errors (e.g. `RegisterError.InvalidSignature`). E.g. `AccountService`. | +| **Repository** | `…/internal/repositories/` (or `…/repositories/`) | Coordinates one or more services, applies caching, exposes suspend functions / flows. E.g. `InternalAccountRepository`, `TransactionRepository`. | +| **Controller** | `…/controllers/` | The public, often stateful API features consume. Holds `StateFlow`s, pulls key material from `UserManager`. E.g. `AccountController`, `TransactionController`. | + +The `*-compose` modules (`:services:flipcash-compose`, `:services:opencode-compose`) +add Compose-facing bindings — for example `LocalExchange`, the composition local +that surfaces exchange rates to the UI. + +## Managed channels + +Each service module provides **two** gRPC channels via Hilt, distinguished by +qualifier — see +[`FlipcashModule.kt`](../../services/flipcash/src/main/kotlin/com/flipcash/services/inject/FlipcashModule.kt): + +```kotlin +@Provides @FlipcashManagedChannel +fun provideManagedChannel(/* context, config */): ManagedChannel = + AndroidChannelBuilder + .usingBuilder(OkHttpChannelBuilder.forAddress(config.baseUrl, config.port)) + .keepAliveWithoutCalls(false) + .intercept(LoggingClientInterceptor()) + .build() + +@Provides @FlipcashManagedStreamingChannel +fun provideManagedStreamingChannel(/* ... */): ManagedChannel = + AndroidChannelBuilder + .usingBuilder(OkHttpChannelBuilder.forAddress(config.baseUrl, config.port)) + .keepAliveTime(config.keepAlive.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .keepAliveTimeout(config.keepAliveTimeout.inWholeMilliseconds, TimeUnit.MILLISECONDS) + .keepAliveWithoutCalls(true) // held open for bidirectional streams + .intercept(LoggingClientInterceptor()) + .build() +``` + +- The **unary** channel is for request/response RPCs; the **streaming** channel + keeps a keep-alive open for long-lived bidirectional streams. +- `@OpenCodeManagedChannel` / `@OpenCodeManagedStreamingChannel` mirror this in the + OCP module against its own base URL. +- `LoggingClientInterceptor` traces every call (see + [08 — Cross-cutting concerns](08-cross-cutting-concerns.md)). + +## Signing & authentication + +Requests are signed with the owner's **Ed25519** key. Builder extensions in +`services/flipcash/.../internal/network/extensions/` add the signature/auth fields +before send: + +```kotlin +// SignMessage.kt — sign the serialized message +fun GeneratedMessageLite.Builder.sign(owner: Ed25519.KeyPair): Common.Signature { + val bytes = buildPartial().toByteArray() + return Ed25519.sign(bytes, owner).asSignature() +} + +// AuthenticateMessage.kt — attach pubkey + signature as Common.Auth +fun GeneratedMessageLite.Builder.authenticate(owner: Ed25519.KeyPair): Common.Auth { /* ... */ } +``` + +Companion `LocalToProtobuf` / `ProtobufToLocal` extensions translate between domain +models (keys, token amounts, timestamps) and their protobuf representations at the +Api boundary. + +## Streaming: `SubmitIntent` + +Payments use a **bidirectional stream**. `TransactionApi.submitIntent` takes a +`Flow` and returns a `Flow`; the +client/server handshake is: + +1. Client sends `SubmitActions` (the intent + actions). +2. Server validates and returns `ServerParameters`. +3. Client builds the Solana transactions locally and signs them. +4. Client sends `SubmitSignatures`. +5. Server verifies and returns `Success`. + +The full handshake and the intent types are covered in +[06 — Payments & operations](06-payments-and-operations.md). + +## REST, JWT & the Coinbase on-ramp + +The Coinbase on-ramp uses Retrofit. `CoinbaseApi` +(`libs/network/coinbase/onramp/...`) declares `@POST`/`@GET` endpoints that take an +`@Header("Authorization")` JWT and dynamic `@Url`s. JWTs are minted per endpoint by +`JwtProvider` (`libs/network/jwt/...`): + +```kotlin +interface JwtProvider { + suspend fun provideJwtForEndpoint(apiKey: String, endpoint: JwtSecuredEndpoint): Result +} +``` + +Tokens are short-lived and passed per request. + +## Exchange rates & connectivity + +- **Exchange** (`libs/network/exchange/.../Exchange.kt`, implemented by + `OpenCodeExchange`) exposes current and observable rates as Flows + (`observeLocalRate()`, `observeRates()`, `fetchRatesIfNeeded()`) and is surfaced + to Compose as `LocalExchange`. All transfers verify a cryptographically-signed + exchange rate. +- **Connectivity** (`libs/network/connectivity/...`, `Api24NetworkObserver`) + watches `ConnectivityManager` via a `callbackFlow`, `debounce(2000)`s WiFi↔mobile + transitions, and `shareIn`s a single connectivity flow. It's published app-wide as + `LocalNetworkObserver`. + +## Why this matters + +The four-layer split means a proto or transport change is absorbed at the Api/ +Service boundary and never ripples into a feature. Features depend only on +**controllers**, which speak in domain types and `Result`, not in gRPC stubs. diff --git a/docs/architecture/05-persistence.md b/docs/architecture/05-persistence.md new file mode 100644 index 000000000..3c39cc9ba --- /dev/null +++ b/docs/architecture/05-persistence.md @@ -0,0 +1,107 @@ +# 05 — Persistence + +Local state lives in a **Room** database, with **DataStore** for lightweight +key/value preferences and a **Paging 3 `RemoteMediator`** layer to back paged lists +(activity feed, chat) from the network. The persistence layer is split into three +shared sub-modules so storage details don't leak into features. + +> **Note on encryption:** the Flipcash Room database is **not** wrapped with +> SQLCipher (there is no `SupportFactory` / `net.zetetic` in +> `apps/flipcash/shared/persistence`). Instead, **each account gets its own +> database file**, named from the account entropy. + +```mermaid +graph TD + Feature["Feature / shared coordinator"] + Provider[":shared:persistence:provider — access facade"] + Sources[":shared:persistence:sources — RemoteMediators, paged sources"] + Db[":shared:persistence:db — FlipcashDatabase, DAOs, entities"] + Room["Room (SQLite)"] + DataStore["DataStore (preferences)"] + Backend["gRPC backend"] + + Feature --> Provider --> Sources --> Db --> Room + Provider --> DataStore + Sources --> Backend +``` + +## Sub-modules + +| Module | Role | +|--------|------| +| `:apps:flipcash:shared:persistence:db` | The Room `FlipcashDatabase`, entities, DAOs, type converters, migrations. | +| `:apps:flipcash:shared:persistence:sources` | Paging `RemoteMediator`s and data sources that bridge network ↔ database. | +| `:apps:flipcash:shared:persistence:provider` | The injected facade features use to reach DAOs/DataStore without depending on Room directly. | + +## The database + +[`FlipcashDatabase`](../../apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt) +is a standard Room `@Database` (currently **version 20**) with a long chain of +`@AutoMigration`s and `@TypeConverters` for token and chat payloads: + +```kotlin +@Database( + entities = [ + MessageEntity::class, TokenEntity::class, SocialLinkEntity::class, + TokenValuationEntity::class, CurrencyCreatorDraftEntity::class, + ContactSyncStateEntity::class, ContactMappingEntity::class, + ChatMetadataEntity::class, ChatMessageEntity::class, ChatMemberEntity::class, + ], + autoMigrations = [ /* 1->2 ... 19->20 */ ], + version = 20, +) +@TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) +abstract class FlipcashDatabase : RoomDatabase() { + abstract fun messageDao(): MessageDao + abstract fun tokenDao(): TokenDao + abstract fun contactDao(): ContactDao + abstract fun chatMetadataDao(): ChatMetadataDao + abstract fun chatMessageDao(): ChatMessageDao + abstract fun chatMemberDao(): ChatMemberDao + abstract fun currencyCreatorDraftDao(): CurrencyCreatorDraftDao + // ... +} +``` + +### Per-user database naming + +The database is opened with a name derived from the signed-in account's entropy, so +each user gets an isolated file and switching accounts can't cross-contaminate +cached state: + +```kotlin +fun init(context: Context, entropyB64: String) { + val dbUniqueName = Base58.encode(entropyB64.toByteArray().subByteArray(0, 6)) + dbName = "$dbNamePrefix-$dbUniqueName.db" // e.g. fcash_database-.db + instance = Room.databaseBuilder(context, FlipcashDatabase::class.java, dbName) + .fallbackToDestructiveMigration() + .build() +} +``` + +Room schema JSON is checked in under +`apps/flipcash/shared/persistence/db/schemas/` to keep migrations honest. + +## DAOs & paged sources + +DAOs (`…/persistence/dao/`) expose suspend functions and `Flow`s for reads; +mutations are suspend. Paged lists are populated by `RemoteMediator`s in the +`sources` module — e.g. a feed mediator and a chat-message mediator — which fetch +pages from the backend, write them through the DAO, and let Room/Paging serve the UI +from the local copy. This gives offline reads and a single source of truth. + +## DataStore + +Preferences and small caches use Jetpack **DataStore** rather than the database. +A representative example is +[`OpenGraphCacheProvider`](../../libs/opengraph/src/main/kotlin/com/getcode/libs/opengraph/OpenGraphCacheProvider.kt), +which caches link-preview metadata via `PreferenceDataStoreFactory.create(...)` with +a corruption handler and JSON-serialized values. User flags and similar small state +follow the same pattern. + +## Why this matters + +Routing all storage access through the `provider` facade keeps Room out of feature +modules; per-user database files make account isolation structural rather than +something each query has to remember; and the `RemoteMediator` layer keeps the +network-to-cache sync in one place so screens just observe the database. diff --git a/docs/architecture/06-payments-and-operations.md b/docs/architecture/06-payments-and-operations.md new file mode 100644 index 000000000..7e66b1514 --- /dev/null +++ b/docs/architecture/06-payments-and-operations.md @@ -0,0 +1,212 @@ +# 06 — Payments & operations + +Flipcash is **self-custodial**: the device holds the keys, derives Solana accounts, +and signs every transaction locally. This document covers the currency model, key +management, the account model, the auth-state machine, and how a payment is actually +submitted. + +```mermaid +graph TD + Mnemonic["MnemonicPhrase (BIP39 entropy)"] + Derived["DerivedKey (Ed25519.KeyPair per path)"] + Cluster["AccountCluster (authority + timelock + deposits)"] + User["UserManager (AuthState, current account)"] + TxController["TransactionController / TransactionOperations"] + Stream["SubmitIntent bidirectional stream"] + Backend["OCP backend + Solana"] + + Mnemonic --> Derived --> Cluster --> User + User --> TxController --> Stream --> Backend +``` + +## Currency model: USDF & launchpad currencies + +Flipcash has **two kinds of currency**, and the relationship between them is the +heart of the product: + +- **USDF** is the **base / reserve currency** — a USD-pegged stablecoin. In code it + is the *"core mint"*: `Mint.usdf` + ([`libs/encryption/keys/.../Mint.kt`](../../libs/encryption/keys/src/main/kotlin/com/getcode/solana/keys/Mint.kt)), + modeled as a `MintMetadata` (alias `Token`) with **`launchpadMetadata = null`**. +- **Launchpad currencies** are the **user-facing, tradable tokens** — the unit people + actually create, buy, sell, and share. Each is a custom on-chain currency **backed + by USDF reserves**. + +> **Mental model:** launchpad currencies are to USDF what memecoins are to USDC on +> Solana. USDF is the reserve everything is priced in and backed by; launchpad +> currencies are what circulates socially. + +A launchpad currency is a `MintMetadata` whose `launchpadMetadata` is **non-null** +([`services/opencode/.../model/financial/MintMetadata.kt`](../../services/opencode/src/main/kotlin/com/getcode/opencode/model/financial/MintMetadata.kt)). +`LaunchpadMetadata` describes the backing and pricing: + +| Field | Meaning | +|-------|---------| +| `liquidityPool` | The on-chain bonding-curve pool. | +| `mintVault` | Where the launchpad token itself is locked against the pool. | +| `coreMintVault` | **Where USDF is locked against the pool — the on-chain backing/reserves.** | +| `currentCirculatingSupplyQuarks` | Circulating supply; drives price via the curve. | +| `price`, `marketCap` | Both denominated in **USDF** (`Fiat`). | +| `sellFeeBps` | Sell fee in basis points (currently 1%). | + +```mermaid +graph LR + User["User USDF balance
(spendable reserve)"] + Pool["Liquidity pool (bonding curve)"] + Core["coreMintVault
(USDF backing)"] + MintV["mintVault
(launchpad token)"] + Token["Launchpad currency
price = f(supply) in USDF"] + + User -->|buy: USDF in| Pool + Pool -->|tokens out| Token + Token -->|sell: tokens in, −1% fee| Pool + Pool -->|USDF out| User + Pool --- Core + Pool --- MintV +``` + +**Price discovery is deterministic and on-chain.** `price = f(currentSupply)` is +computed by a discrete bonding curve +([`libs/currency-math/.../curves/DiscreteBondingCurve.kt`](../../libs/currency-math/src/main/kotlin/com/flipcash/libs/currency/math/internal/curves/DiscreteBondingCurve.kt)); +supply updates stream in via `LaunchpadReserveStateSnapshot` and `TokenCoordinator`, +which recomputes balances and appreciation as the price moves. (This is separate +from the **fiat** display rates in [04 — Networking](04-networking.md), which convert +USDF to the user's local currency for display.) + +**Buying and selling always goes through USDF.** `SwapPurpose` +([`apps/flipcash/core/.../tokens/TokenSwapPurpose.kt`](../../apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/tokens/TokenSwapPurpose.kt)) +is either `Buy(mint, fundingSource)` or `Sell(mint)`, and the swap's counter-currency +is `Mint.usdf`: a buy spends USDF to mint tokens (adding USDF to the pool's +`coreMintVault`), a sell burns tokens for USDF (returning USDF, minus the 1% fee). +Creating a currency (`CurrencyCreatorViewModel`) seeds an **initial USDF buy** +(default ~$5, from a user flag) and hands the creator a cash bill of the new token. + +**Sending/sharing carries any token.** A cash bill (`Bill.Cash`) holds whichever +`Token` is selected — a launchpad currency *or* USDF. Launchpad currencies render as +a custom bill (`renderAsBill = token.address != Mint.usdf`); USDF renders as plain +cash. + +> **Two senses of "reserves" — don't conflate them.** (1) The on-chain USDF locked in +> a launchpad token's `coreMintVault` is the token's **backing**. (2) +> `ReservesBalanceProvider.observeReservesBalance()` → +> `balanceForToken(Mint.usdf)` is the **user's own USDF balance**, i.e. the spendable +> reserve they buy launchpad currencies with. Both are USDF, but one is pool-side +> backing and the other is the user's wallet balance. + +## Keys & cryptography + +The crypto primitives live under `libs/encryption/*`: + +| Module | Provides | +|--------|----------| +| `libs/encryption/ed25519` | `Ed25519` — native (JNI) key generation, signing, verification; `Ed25519.KeyPair` (Parcelable). | +| `libs/encryption/mnemonic` | `MnemonicPhrase` (BIP39, 12/24 words), `DerivedKey`, `DerivePath` (Solana derivation paths). | +| `libs/encryption/keys` | Solana primitives: `PublicKey`, `Mint`, account-address derivation helpers. | +| `libs/encryption/base58`, `sha256/512`, `hmac`, `utils` | Encoding and hashing helpers. | + +A user's identity starts from entropy: + +```kotlin +val phrase = MnemonicPhrase.generate() // or fromEntropyB64(...) +val keyPair = phrase.getSolanaKeyPair(DerivePath.primary) // Ed25519.KeyPair +``` + +`MnemonicPhrase` caches seed derivation and exposes the entropy in Base58/Base64 — +the same Base58 entropy prefix that names the per-user database (see +[05 — Persistence](05-persistence.md)). + +## The account model: `AccountCluster` + +[`AccountCluster`](../../services/opencode/src/main/kotlin/com/getcode/opencode/model/accounts/AccountCluster.kt) +bundles the derived accounts that make up a wallet: + +```kotlin +class AccountCluster( + val authority: DerivedKey, // master key controlling the account + val timelock: TimelockDerivedAccounts, // custody / vault accounts +) { + val rendezvous: Ed25519.KeyPair get() = authority.keyPair // signing key for requests + val vaultPublicKey: PublicKey get() = timelock.vault.publicKey + val usdfDepositAddress: PublicKey get() = depositAddressFor(Token.usdf) + + fun depositAddressFor(token: Token): PublicKey { /* derive VM + deposit account */ } +} +``` + +- **authority** — the master keypair that controls the account. +- **timelock** — vault accounts in the timelock virtual machine that hold custody. +- **rendezvous** — the key used to sign requests. +- **deposit addresses** — derived per token, used for on-ramp funding. + +## Auth state: `UserManager` + +[`UserManager`](../../services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt) +owns the mnemonic lifecycle, derives the `AccountCluster`, and exposes the current +account and a state machine as `StateFlow`s: + +```kotlin +sealed interface AuthState { + data object Unknown : AuthState + data class Onboarding(val resumePoint: ResumePoint = ResumePoint.PostAccessKey) : AuthState + data object Authenticating : AuthState, LoggedIn + data object Ready : AuthState, LoggedIn // can call authenticated APIs + data object LoggedOut : AuthState + + val canAccessAuthenticatedApis: Boolean get() = this is Ready +} +``` + +`AuthState` is what the [`Router`](03-navigation.md) gates deeplinks on, and what the +`SessionController` (`:shared:session`) uses to route between onboarding, login, and +the main app. + +## Submitting a payment + +Payments are expressed as **intents** and submitted over the OCP `SubmitIntent` +bidirectional stream. `TransactionController` (in `:services:opencode`, implementing +`TransactionOperations`) is the public entry point; it also tracks send limits: + +```kotlin +@Singleton +class TransactionController @Inject constructor( + private val repository: TransactionRepository, + private val swapRepository: SwapRepository, + private val accountController: AccountController, +) : TransactionOperations { + private val _limits = MutableStateFlow(Limits.Empty) + override val limits: StateFlow = _limits.asStateFlow() + + override suspend fun updateLimits(owner: AccountCluster, force: Boolean) { /* ... */ } +} +``` + +Intent kinds include `IntentTransfer`, `IntentRemoteSend` / `IntentRemoteReceive` +(cash links), `IntentWithdraw`, `IntentStatefulSwap`, and `IntentDistribution`. The +client builds and signs the Solana transactions locally during the handshake +described in [04 — Networking](04-networking.md): send actions → receive server +parameters → sign → send signatures → success. + +## Funding: `PurchaseMethodController` + +[`PurchaseMethodController`](../../apps/flipcash/shared/payments/src/main/kotlin/com/flipcash/app/payments/PurchaseMethodController.kt) +coordinates how a user adds funds and exposes the selection as state: + +```kotlin +interface PurchaseMethodController { + val state: StateFlow + val selections: Flow + fun present(metadata: PurchaseMethodMetadata = PurchaseMethodMetadata()) + suspend fun presentDepositOptions(popToRoot: Boolean = false): AppRoute? +} +``` + +Methods include in-app purchase, the **Coinbase on-ramp** (REST + JWT, see +[04](04-networking.md)), and manual deposit to the cluster's deposit address. + +## Why this matters + +Keys never leave the device; everything from the database name to the request +signature is derived from one mnemonic. Modeling money movement as signed +**intents** over a streamed handshake — rather than fire-and-forget RPCs — is what +lets the backend verify exchange rates and limits while the client retains custody +of signing. diff --git a/docs/architecture/07-design-system.md b/docs/architecture/07-design-system.md new file mode 100644 index 000000000..6d5153dc6 --- /dev/null +++ b/docs/architecture/07-design-system.md @@ -0,0 +1,67 @@ +# 07 — Design system + +The `ui/*` modules are the shared visual layer: theme tokens, reusable Compose +components, string/drawable resources, the camera scanner, and biometric prompts. +They depend only on `libs/*` and each other — **never on app/feature modules** — so +they can be reused everywhere without creating cycles (see +[01 — Modules & boundaries](01-modules-and-boundaries.md)). + +```mermaid +graph TD + Feature["Feature screen (Compose)"] + Components[":ui:components — buttons, bars, pills, sheets"] + Core[":ui:core — primitives, modifiers, RestrictionType"] + Theme[":ui:theme — colors, type, MODE_NIGHT_YES"] + Resources[":ui:resources — strings, drawables"] + Scanner[":ui:scanner — camera / Kik code capture"] + Biometrics[":ui:biometrics — biometric prompt state"] + Libs["libs/* (currency, models, datetime, ...)"] + + Feature --> Components --> Core --> Theme + Feature --> Resources + Feature --> Scanner + Feature --> Biometrics + Components --> Libs +``` + +## The modules + +| Module | Role | +|--------|------| +| `:ui:theme` | Color, typography, shapes, spacing tokens. The app is **dark-mode only** (`MODE_NIGHT_YES`), so the theme targets a single palette. | +| `:ui:components` | Reusable composables — app bars, buttons, pills, bottom sheets, list rows. Depends on `libs/currency`, `libs/models`, `libs/network/exchange`, and `ui/theme`. | +| `:ui:core` | Lower-level Compose primitives, modifiers, and shared types (e.g. `RestrictionType`). Re-exported by components via `api(...)`. | +| `:ui:resources` | Strings and drawables, behind `ResourceHelper` so non-Compose code can resolve them too. | +| `:ui:navigation` | The navigation runtime used by the app: `CodeNavigator`, `BaseViewModel`, flow-route types. (See [02](02-state-and-dependency-injection.md) and [03](03-navigation.md).) | +| `:ui:scanner` | Camera preview and Kik-code capture surface used by the scanner feature. | +| `:ui:biometrics` | `rememberBiometricsState` and `LocalBiometricsState` for gating sensitive screens. | +| `:ui:emojis` | Emoji rendering/lookup helpers. | +| `:ui:testing` | Compose test utilities and `LocalUiTesting` for UI-test affordances. | + +## How features consume the UI layer + +Feature modules don't declare the UI layer by hand — the +`flipcash.android.feature` convention plugin injects `:ui:core`, `:ui:components`, +`:ui:navigation`, `:ui:resources`, and `:ui:theme` automatically (see +[01](01-modules-and-boundaries.md)). So a screen can use the shared components and +theme tokens immediately, and only adds an explicit dependency for the less common +pieces (`:ui:scanner`, `:ui:biometrics`, `:ui:emojis`). + +## Compose conventions + +- **State in, events out.** Components are stateless where possible: they take state + plus callback lambdas and let the caller's `BaseViewModel` own the state (see + [02](02-state-and-dependency-injection.md)). +- **Theme tokens, not literals.** Colors, type, and spacing come from `:ui:theme` + rather than hard-coded values, so the single dark palette stays consistent. +- **Resources via `ResourceHelper`.** Strings/drawables resolve through `:ui:resources` + / `LocalResources` so logic modules (which have no Compose) can format text too. +- **Ambient controllers via `Local*`.** Cross-cutting UI controllers (toasts, + scrim, biometrics, navigator) are read from composition locals rather than passed + down by hand. + +## Why this matters + +Keeping the design system in `ui/*` with a no-app-dependencies rule means visual +consistency is shared, not copy-pasted, and the convention-plugin baseline means +every feature starts from the same component and theme set with zero boilerplate. diff --git a/docs/architecture/08-cross-cutting-concerns.md b/docs/architecture/08-cross-cutting-concerns.md new file mode 100644 index 000000000..802a61be3 --- /dev/null +++ b/docs/architecture/08-cross-cutting-concerns.md @@ -0,0 +1,108 @@ +# 08 — Cross-cutting concerns + +Concerns that touch every layer: logging/tracing, error reporting, analytics, +biometrics, and the async model. These are centralized so features get them +"for free" without each one reinventing instrumentation. + +> **Note on async:** the app is effectively **pure Kotlin Coroutines + Flow**. +> There is no meaningful RxJava usage in app source. + +```mermaid +graph TD + Code["Any module"] + Trace["trace(tag, message, type) -> TraceManager"] + Plugins["TraceLogPlugin chain (PII masking, RPC body filter)"] + File["FileTree (rotated log file)"] + Sinks["BreadcrumbSink(s)"] + Bugsnag["Bugsnag (error reporting)"] + Mixpanel["Mixpanel (analytics)"] + + Code --> Trace --> Plugins --> File + Trace --> Sinks --> Bugsnag + Code --> Mixpanel +``` + +## Logging & tracing + +`libs/logging` centralizes tracing behind `TraceManager` and a single `trace(...)` +call: + +```kotlin +trace(tag = "Transactions", message = "updating limits", type = TraceType.Process) +trace(tag = "gRPC", message = "opencode => READY", type = TraceType.StateChange) +``` + +- **`TraceType`** routes/labels each line: `Silent`, `Error`, `Log`, `Navigation`, + `Process`, `Network`, `StateChange`, `User`. +- **`TraceLogPlugin`** transforms or drops lines before they're written — used for + **PII masking** and **RPC body filtering** so secrets never hit the log file. +- **`FileTree`** writes a time-rotated local log (surfaced in the device-logs + feature for support). +- **`BreadcrumbSink`** forwards structured breadcrumbs to external services so a + crash report carries recent context. +- `LoggingClientInterceptor` (used by both gRPC channels, see + [04](04-networking.md)) feeds network traces through this pipeline. + +## Error reporting + +`ErrorReporter` (`libs/logging`) is the abstraction over **Bugsnag**: + +```kotlin +interface ErrorReporter { + fun report(error: Throwable, cause: Throwable, isNotifiable: Boolean) +} +``` + +Errors implementing `NotifiableError` are flagged `isNotifiable` and routed for +elevated alerting; reports are enriched with the current `userId` (from +`TraceManager`) and recent breadcrumbs. + +## Analytics + +`AnalyticsService` (`libs/analytics`) defines the event surface; the Flipcash +implementation (`apps/flipcash/shared/analytics`) wraps the **Mixpanel** SDK +(`MixpanelAnalyticsDelegate`): + +```kotlin +interface AnalyticsService { + fun onAppStart() + fun action(action: AppAction, source: AppActionSource? = null) + // ... +} +``` + +It's exposed to Compose as `LocalAnalytics` and used by `UserManager` to track +auth-state transitions and user properties. + +## Biometrics + +`ui/biometrics` provides `rememberBiometricsState(...)` and `LocalBiometricsState` +for gating sensitive screens. It checks device support via `BiometricManager`, +prompts when required, is **lifecycle-aware** (clears the pass when the app +backgrounds), and applies a short cooldown so it doesn't re-prompt on every resume. + +## 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()`. + +## 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/`. + +## Why this matters + +One trace pipeline (with PII masking applied centrally), one error-reporting +abstraction, one analytics surface, and an injected dispatcher provider mean +instrumentation is consistent and safe by default — a feature opts in by calling +`trace(...)` or reading a `Local*`, not by wiring up its own logging. diff --git a/docs/architecture/09-separation-of-concerns.md b/docs/architecture/09-separation-of-concerns.md new file mode 100644 index 000000000..1dcfe89d4 --- /dev/null +++ b/docs/architecture/09-separation-of-concerns.md @@ -0,0 +1,82 @@ +# 09 — Separation of concerns + +This document ties the others together. Flipcash's architecture is, at its core, +**one rule applied consistently**: dependencies point downward through clear +layers, and each layer has a single job. The previous documents describe the +pieces; this one states the principles. + +```mermaid +graph TD + UI["UI — Compose screens + components (state in, events out)"] + VM["Presentation — BaseViewModel (MVI reducer + effects)"] + Domain["Domain — controllers, coordinators, models"] + Data["Data — repositories, services, persistence"] + Transport["Transport — gRPC / REST / Solana"] + + UI --> VM --> Domain --> Data --> Transport +``` + +## The principles + +### 1. Dependencies are acyclic and point downward +`ui/*` and `libs/*` never depend on app code; `services/*` never depend on features; +features depend on shared modules, not the other way around. The module's directory +and its convention plugin enforce this — see +[01 — Modules & boundaries](01-modules-and-boundaries.md). + +### 2. UI is a function of state +Screens render from an immutable `State` and emit `Event`s; they hold no business +logic. The `BaseViewModel` reducer is the only place state changes, +and side effects are explicit on `eventFlow`. See +[02 — State & dependency injection](02-state-and-dependency-injection.md). + +### 3. MVI for screens, coordinators/controllers for shared logic +Per-screen concerns live in a feature's ViewModel. Logic shared across features +lives in a `shared/*` module, exposed as a `StateFlow`-bearing interface and +delivered to Compose as a `Local*`. Use a **Coordinator** when that shared logic +**owns a domain's cached, synced state** (it wraps a stateless controller and is +session/lifecycle-aware); use a **Controller** for a stateless domain API or light +UI-facing state. The full vocabulary — Coordinator vs Controller vs Manager vs +Service — is defined in +[02 — Roles](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services). +Features never reach into each other's internals; they go through shared modules. + +### 4. Transport details stop at the data layer +The gRPC stack's four layers (Api → Service → Repository → Controller) mean protobuf +types, channels, and signing never appear in a feature. Features consume +**controllers** that speak domain types and `Result`. See +[04 — Networking](04-networking.md). + +### 5. One source of truth for cached state +The database is the source of truth for lists; `RemoteMediator`s sync the network +into it and the UI observes the database. Per-user database files make account +isolation structural. See [05 — Persistence](05-persistence.md). + +### 6. Cross-cutting concerns are centralized +Logging, error reporting, analytics, and biometrics are single shared +abstractions, opted into via `trace(...)` or a `Local*`, never reimplemented per +feature. See [08 — Cross-cutting concerns](08-cross-cutting-concerns.md). + +### 7. Generated and signed artifacts are not hand-edited +`definitions/*:models` is generated from `.proto`; don't edit it — regenerate. +Signing and key derivation live in `libs/encryption/*` and `services/*`, not in +feature code. See [06 — Payments & operations](06-payments-and-operations.md). + +## Where does this code go? + +| If you're adding… | It belongs in… | +|-------------------|----------------| +| A new screen | a `:apps:flipcash:features:*` module (screen + ViewModel + Hilt module) | +| Cached, synced state for a domain (contacts, tokens, chat) | a `:apps:flipcash:shared:*` **Coordinator** ([roles](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services)) | +| Stateless shared logic two+ features call | a `:apps:flipcash:shared:*` **Controller** | +| A new backend call | the appropriate `:services:*` layer (Api → Service → Repository → Controller) | +| A reusable component or token | `:ui:components` / `:ui:theme` | +| A domain-agnostic utility | a `:libs:*` module | +| A new persisted entity | `:apps:flipcash:shared:persistence:db` (+ migration) | + +## Why this matters + +Consistency is the feature. Because every module follows the same layering, a new +engineer can predict where code lives, a change in a leaf library can't secretly +reach a screen, and each concern — rendering, state, domain logic, data, transport — +can be reasoned about and tested in isolation. diff --git a/docs/architecture/10-build-and-run.md b/docs/architecture/10-build-and-run.md new file mode 100644 index 000000000..fd603bd09 --- /dev/null +++ b/docs/architecture/10-build-and-run.md @@ -0,0 +1,101 @@ +# 10 — Build & run + +How to get a debug build of Flipcash compiling and running locally, and what the +build actually needs from you. This is the day-one setup doc. + +## Prerequisites + +| Requirement | Notes | +|-------------|-------| +| **JDK 21** | Amazon Corretto 21 is what CI uses. The convention plugins pin the Java/Kotlin toolchain to 21 ([01](01-modules-and-boundaries.md)). | +| **Android SDK** | `compileSdk 36`, `minSdk 29`, `targetSdk` per the version catalog. | +| **`google-services.json`** | Required at `apps/flipcash/app/src/google-services.json` (Firebase). The build fails without it. | +| **`local.properties`** | API keys (below) plus the standard `sdk.dir`. | + +## API keys in `local.properties` + +Keys are read by `tryReadProperty(...)` +([`buildSrc/.../LocalPropertyFetcher.kt`](../../buildSrc/src/main/java/LocalPropertyFetcher.kt)), +which resolves **`local.properties` → environment variable → empty string**. A +missing key therefore **does not fail the build** — it compiles with an empty value +and the dependent feature simply won't work. The keys that are actually consumed: + +| Key | Read in | Powers | +|-----|---------|--------| +| `MIXPANEL_API_KEY` | `apps/flipcash/app/build.gradle.kts` → `BuildConfig`, used in `app/.../inject/ApiModule.kt` | Analytics ([08](08-cross-cutting-concerns.md)) | +| `BUGSNAG_API_KEY` | `apps/flipcash/app/build.gradle.kts` → manifest placeholder | Crash/error reporting ([08](08-cross-cutting-concerns.md)) | +| `COINBASE_ONRAMP_API_KEY` | `apps/flipcash/shared/onramp/coinbase/build.gradle.kts` → `BuildConfig` | Coinbase on-ramp ([04](04-networking.md)) | +| `GOOGLE_CLOUD_PROJECT_NUMBER` | `services/{flipcash,opencode}{,-compose}/build.gradle.kts` → `BuildConfig` | gRPC / backend integration | + +A minimal `local.properties`: + +```properties +sdk.dir=/path/to/Android/sdk +MIXPANEL_API_KEY=... +BUGSNAG_API_KEY=... +COINBASE_ONRAMP_API_KEY=... +GOOGLE_CLOUD_PROJECT_NUMBER=... +``` + +> There is **no** `FINGERPRINT_API_KEY`. (`Build.FINGERPRINT` appears in +> `GooglePayReadiness` for emulator detection — unrelated to any key.) In CI these +> values come from secrets injected into `local.properties` by `.github/workflows/ci.yml`. + +## Common Gradle commands + +```bash +# Debug APK +./gradlew :apps:flipcash:app:assembleDebug + +# Release bundle (AAB) +./gradlew :apps:flipcash:app:bundleRelease + +# All unit tests +./gradlew test + +# Unit tests for one module (fast inner loop) +./gradlew :apps:flipcash:features:cash:test +./gradlew :apps:flipcash:shared:router:test + +# Instrumented tests (needs a device/emulator) +./gradlew connectedAndroidTest + +# What CI runs (unit tests via Fastlane) +bundle exec fastlane android flipcash_tests +``` + +See [12 — Testing](12-testing.md) for the testing approach. + +## Build variants & namespaces + +| | Application ID | +|---|----------------| +| Release | `com.flipcash.app.android` | +| Debug | `com.flipcash.app.android.dev` (suffix lets debug + release coexist on one device) | + +The app forces **dark mode** (`MODE_NIGHT_YES`, see [07](07-design-system.md)). +`versionCode` comes from `Packaging.Flipcash.versionCode` or `gitVersionCode()`. + +## Module structure at a glance + +The project is ~132 modules; the directory a module lives in defines its layer and +the convention plugin it applies. If you're about to add code, read +[01 — Modules & boundaries](01-modules-and-boundaries.md) and +[11 — Adding a feature](11-adding-a-feature.md) first. + +## CI + +[`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) runs on PRs: it sets up +JDK 21 + Ruby, decodes `google-services.json`, writes the API-key secrets into +`local.properties`, and runs `bundle exec fastlane android flipcash_tests` +(Fastfile lane `flipcash_tests`). Other workflows handle version bumps, dev prep, +and the upload pipeline. + +## Troubleshooting + +- **`google-services.json` missing** → place it at `apps/flipcash/app/src/`. +- **A feature behaves as if unconfigured** (no analytics, on-ramp fails) → the + corresponding key is absent from `local.properties` (the build won't warn loudly, + because the fallback is an empty string). +- **Wrong JDK** → ensure `JAVA_HOME` points at a 21 JDK; the toolchain is pinned but + Gradle itself must launch on a compatible JVM. diff --git a/docs/architecture/11-adding-a-feature.md b/docs/architecture/11-adding-a-feature.md new file mode 100644 index 000000000..af3dd6284 --- /dev/null +++ b/docs/architecture/11-adding-a-feature.md @@ -0,0 +1,165 @@ +# 11 — Adding a feature + +A practical, end-to-end walkthrough for the most common task: adding a new screen +as a feature module. It ties together the module system ([01](01-modules-and-boundaries.md)), +state & DI ([02](02-state-and-dependency-injection.md)), and navigation +([03](03-navigation.md)). Follow the steps in order; each links to the reference +doc for the "why." + +```mermaid +graph TD + A["1. Scaffold module
apps/flipcash/features/"] + B["2. Register in settings.gradle.kts"] + C["3. Build ViewModel
BaseViewModel"] + D["4. Build the Composable screen"] + E["5. Add an AppRoute"] + F["6. Register in appEntryProvider"] + G["7. (optional) expose a Local* / shared dep"] + A --> B --> C --> D --> E --> F --> G +``` + +## 1. Scaffold the module + +Create `apps/flipcash/features//` with a `build.gradle.kts` applying the +feature convention plugin: + +```kotlin +plugins { + alias(libs.plugins.flipcash.android.feature) +} + +dependencies { + // Only the EXTRA deps you need — Hilt, Compose, the ui/* layer, + // :libs:logging, and :apps:flipcash:core are injected by the plugin. + implementation(project(":apps:flipcash:shared:session")) + // implementation(project(":apps:flipcash:shared:tokens")) // etc. +} +``` + +The `flipcash.android.feature` plugin already provides Hilt, Compose, KSP, +Parcelize, `:ui:core/components/navigation/resources/theme`, `:libs:logging`, and +`:apps:flipcash:core` — don't redeclare them ([01](01-modules-and-boundaries.md)). +Use the package convention `com.flipcash.app.` (see `CLAUDE.md` namespaces). + +> There is a **`module-scaffolder`** agent that generates this skeleton +> (build file, package structure, entry-point files, `settings.gradle.kts` +> inclusion). Prefer it for the boilerplate, then follow the steps below to wire +> behavior. + +## 2. Register the module + +Add the module path to [`settings.gradle.kts`](../../settings.gradle.kts) so Gradle +includes it, then add a dependency on it from `:apps:flipcash:app`'s +`build.gradle.kts` (the app is the universal collector — [01](01-modules-and-boundaries.md)). + +## 3. Build the ViewModel + +Extend `BaseViewModel` +([`ui/navigation/.../BaseViewModel.kt`](../../ui/navigation/src/main/kotlin/com/getcode/view/BaseViewModel.kt)), +following the MVI contract from [02](02-state-and-dependency-injection.md): + +```kotlin +@HiltViewModel +internal class WidgetViewModel @Inject constructor( + private val tokenCoordinator: TokenCoordinator, // inject domain coordinators/controllers + dispatchers: DispatcherProvider, // inject dispatchers (testable) +) : BaseViewModel( + initialState = State(), + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, +) { + internal data class State(val loading: Boolean = false /* ... */) + + sealed interface Event { + data object OnAppear : Event + data class OnLoaded(val value: String) : Event + } + + init { + // side effects: collect off eventFlow / external StateFlows + eventFlow.filterIsInstance() + .onEach { /* ... */ } + .launchIn(viewModelScope) + } + + internal companion object { + val updateStateForEvent: (Event) -> (State.() -> State) = { event -> + when (event) { + is Event.OnLoaded -> { state -> state.copy(loading = false) } + else -> { state -> state } + } + } + } +} +``` + +- Inject **Coordinators** for cached domain state, **Controllers** for stateless + domain APIs (see [Roles](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services)). +- Inject `DispatcherProvider` rather than touching `Dispatchers.*` — this keeps the + ViewModel testable ([12](12-testing.md)). +- If you need a new Hilt binding, add a `@Module @InstallIn(SingletonComponent::class)` + in the module's `inject/` package. + +## 4. Build the screen + +```kotlin +@Composable +fun WidgetScreen(/* route args */) { + val navigator = LocalCodeNavigator.current + val session = LocalSessionController.current!! // ambient controllers via Local* + val viewModel = hiltViewModel() + val state by viewModel.stateFlow.collectAsStateWithLifecycle() + + LaunchedEffect(viewModel) { viewModel.dispatchEvent(WidgetViewModel.Event.OnAppear) } + + // render from `state`; emit via viewModel.dispatchEvent(...) +} +``` + +Read long-lived controllers from `Local*`, build per-screen state from the +ViewModel, and use components/theme from the `ui/*` layer ([07](07-design-system.md)). + +## 5. Add a route + +Add a case to the `AppRoute` sealed hierarchy in +[`apps/flipcash/core/.../AppRoute.kt`](../../apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt). +For a single screen, a `@Serializable @Parcelize data class`/`data object` is +enough. For a multi-step flow, implement `FlowRoute` (with an `initialStack`) or +`FlowRouteWithResult` if it returns a value ([03](03-navigation.md)): + +```kotlin +@Serializable +data class Widget(val id: String) : AppRoute +``` + +## 6. Register route → screen + +Add one `annotatedEntry` in +[`AppScreenContent.kt`](../../apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt): + +```kotlin +annotatedEntry { key -> WidgetScreen(id = key.id) } +``` + +Navigate to it from elsewhere with `navigator.push(AppRoute.Widget(id))`. + +## 7. (Optional) expose a shared dependency + +If the feature owns state or a controller that **other** features need, don't let +them depend on your feature module — put the shared piece in a +`:apps:flipcash:shared:*` module (a **Coordinator** if it owns cached/synced domain +state, a **Controller** otherwise — [Roles](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services)), +provide it via Hilt, and if it's app-wide, surface it to Compose by adding a +`Local*` and providing it in `MainActivity`'s `CompositionLocalProvider` +([02](02-state-and-dependency-injection.md)). + +## Checklist + +- [ ] Module under `apps/flipcash/features/` with the feature plugin +- [ ] Listed in `settings.gradle.kts` and depended on by `:apps:flipcash:app` +- [ ] `BaseViewModel` with an `Event` sealed interface + reducer +- [ ] Screen reads `Local*` controllers and `collectAsStateWithLifecycle()` +- [ ] `AppRoute` case added +- [ ] `annotatedEntry` registered in `appEntryProvider` +- [ ] Cross-feature logic lives in a `shared/*` module, not in the feature +- [ ] Tests for the ViewModel / any new coordinator ([12](12-testing.md)) diff --git a/docs/architecture/12-testing.md b/docs/architecture/12-testing.md new file mode 100644 index 000000000..a2b6b9019 --- /dev/null +++ b/docs/architecture/12-testing.md @@ -0,0 +1,130 @@ +# 12 — Testing + +How testing is set up across the project and what to test where. The project favors +**fast JVM unit tests** (Robolectric where Android APIs are needed) over instrumented +tests — there are ~155 unit tests and only a handful of instrumented ones. For the +Compose-UI-specific patterns, see the companion +[Compose UI Testing Guide](../compose-ui-testing.md). + +```mermaid +graph TD + Unit["Unit tests (src/test, JVM + Robolectric)
ViewModels, coordinators, mappers, DAOs"] + Fakes["Fakes of injected interfaces
(controllers, coordinators, providers)"] + Utils[":libs:test-utils
TestDispatchers, MainCoroutineRule"] + Turbine["Turbine — Flow/StateFlow assertions"] + Compose["Compose UI tests
(see compose-ui-testing.md)"] + + Unit --> Fakes + Unit --> Utils + Unit --> Turbine + Compose --> Fakes +``` + +## What goes where + +| Test what | How | Lives in | +|-----------|-----|----------| +| **ViewModel** logic (reducer + event side effects) | Plain JUnit; inject fakes + `TestDispatchers`; assert on `stateFlow` with Turbine | the feature module's `src/test` | +| **Coordinator** sync/cache logic | Fake the wrapped Controller + persistence; drive session/lifecycle callbacks | the shared module's `src/test` | +| **DAO / Room** queries & migrations | Robolectric + in-memory Room, `runTest` | `shared/persistence/*/src/test` | +| **Mappers / pure logic** (currency, crypto, encoding) | Plain JUnit, no Android | the relevant `:libs:*` module | +| **Compose UI** | Compose test rule + fakes | see [Compose UI Testing Guide](../compose-ui-testing.md) | + +## What the convention plugin gives every module + +`flipcash.android.library` (and therefore every module — +[01](01-modules-and-boundaries.md)) configures unit tests automatically +([`AndroidLibraryConventionPlugin.kt`](../../build-logic/convention/src/main/kotlin/AndroidLibraryConventionPlugin.kt)): + +- `testOptions { unitTests.isReturnDefaultValues = true; unitTests.isIncludeAndroidResources = true }` +- `kotlin-test-junit` on the test classpath +- A `robolectric.properties` pinning the Robolectric SDK to **36** +- `:libs:test-utils` available to tests (except for itself) + +So a module can write Robolectric-backed unit tests with zero extra setup. + +## Shared test utilities (`:libs:test-utils`) + +Two helpers make coroutine code deterministic: + +**`TestDispatchers`** — a `DispatcherProvider` whose `IO`/`Main`/`Default` all map to +a single `StandardTestDispatcher`, so injected dispatchers are controllable in tests: + +```kotlin +class TestDispatchers(scheduler: TestCoroutineScheduler) : DispatcherProvider { + val dispatcher = StandardTestDispatcher(scheduler) + override val IO = dispatcher + override val Main = dispatcher + override val Default = dispatcher +} +``` + +**`MainCoroutineRule`** — a JUnit `TestWatcher` that `Dispatchers.setMain(...)`/ +`resetMain()` around each test so `viewModelScope` uses the test dispatcher. + +Because production code injects `DispatcherProvider` rather than referencing +`Dispatchers.*` ([08](08-cross-cutting-concerns.md)), passing `TestDispatchers` +makes a ViewModel or coordinator fully deterministic. + +## The interface + fake pattern + +The codebase is built on interfaces (controllers, coordinators, providers exposed +behind interfaces and delivered via Hilt / `Local*`). Tests substitute **hand-written +fakes** for those interfaces rather than using a mocking framework. This is the +dominant pattern — the [Compose UI Testing Guide](../compose-ui-testing.md) +documents it in detail and it applies equally to ViewModel/coordinator unit tests. + +## Asserting on flows + +State is exposed as `StateFlow`/`Flow`, so tests assert on emissions with +**Turbine** (`app.cash.turbine.test`) inside `runTest`: + +```kotlin +@Test +fun `loads on appear`() = runTest { + val vm = WidgetViewModel(fakeCoordinator, TestDispatchers(testScheduler)) + vm.stateFlow.test { + assertEquals(false, awaitItem().loading) // initial + vm.dispatchEvent(Widget.Event.OnAppear) + // advance/await subsequent states... + } +} +``` + +## Example: a Room DAO test + +DAO tests run on Robolectric against an in-memory database and use `runTest` +(e.g. `ContactDaoTest`, +`apps/flipcash/shared/persistence/db/src/test/.../dao/ContactDaoTest.kt`): + +```kotlin +@RunWith(RobolectricTestRunner::class) +class ContactDaoTest { + @Before fun setup() { /* Room.inMemoryDatabaseBuilder(...) */ } + + @Test fun `upsertSyncState and getSyncState roundtrip`() = runTest { /* ... */ } +} +``` + +## Running tests + +```bash +./gradlew test # all modules +./gradlew :apps:flipcash:features:cash:test # one module (fast loop) +./gradlew :apps:flipcash:shared:router:test +bundle exec fastlane android flipcash_tests # what CI runs +``` + +See [10 — Build & run](10-build-and-run.md) for the wider command set. + +## Guidance + +- **Inject, don't reach.** A ViewModel/coordinator that takes its dependencies + (incl. `DispatcherProvider`) as constructor params is trivially testable; one that + reaches for singletons or `Dispatchers.*` is not. +- **Test the reducer and the effects.** Assert state transitions for events, and + assert the side effects emitted on `eventFlow`. +- **Prefer unit + Robolectric over instrumented.** Reserve `connectedAndroidTest` + for things that genuinely need a device. +- **Fakes over mocks.** Write a small fake implementing the interface; it's clearer + and survives refactors better than mock setups. diff --git a/docs/architecture/13-protobuf-and-codegen.md b/docs/architecture/13-protobuf-and-codegen.md new file mode 100644 index 000000000..31bbc2493 --- /dev/null +++ b/docs/architecture/13-protobuf-and-codegen.md @@ -0,0 +1,92 @@ +# 13 — Protobuf & code generation + +The backend contract is **Protocol Buffers**. Generated gRPC/proto code is the +foundation the whole service layer sits on ([04 — Networking](04-networking.md)), +so this doc explains where it comes from, how it's generated, and how to update it +safely. + +```mermaid +graph TD + Upstream["Upstream proto repos (Flipcash, OCP)"] + Protos[":definitions:*:protos — .proto sources (java-library)"] + Models[":definitions:*:models — generated Java/Kotlin + grpc stubs"] + Wrap[":services:* — hand-written Api/Service/Repository/Controller"] + Feat["features / shared"] + + Upstream -->|/fetch-protos| Protos --> Models --> Wrap --> Feat +``` + +## Layout + +| Module | Plugin | Contents | +|--------|--------|----------| +| `:definitions:flipcash:protos`, `:definitions:opencode:protos` | `java-library` | The raw `.proto` source files under `src/main/proto/`. The `java-library` plugin packages them so the `models` module can compile them. | +| `:definitions:flipcash:models`, `:definitions:opencode:models` | `flipcash.android.library` + `protobuf` + `protobuf.validate` | The **generated** Java/Kotlin message classes and gRPC stubs. | + +The `models` build consumes the matching `protos` module via the `protobuf(...)` +configuration and runs `protoc`: + +```kotlin +// definitions//models/build.gradle.kts +plugins { + alias(libs.plugins.flipcash.android.library) + alias(libs.plugins.protobuf) + alias(libs.plugins.protobuf.validate) +} +dependencies { + protobuf(project(":definitions::protos")) // source of .proto files + implementation(libs.grpc.protobuf.lite) + implementation(libs.protobuf.kotlin.lite) +} +protobuf { + protoc { artifact = "com.google.protobuf:protoc:$protobufVersion$archSuffix" } + plugins { /* protoc-gen-grpc-java */ } +} +``` + +Generated output uses the **lite** runtime (`protobuf-kotlin-lite`, +`grpc-protobuf-lite`) suited to Android, and `protobuf.validate` wires up +`protovalidate` so requests can be validated at the Api boundary +(`...orThrow()` — see [04](04-networking.md)). + +## The golden rule + +> **Never hand-edit anything under `:definitions:*:models`.** It is generated from +> the `.proto` sources and will be overwritten. To change a model, change the +> `.proto` (upstream) and regenerate. + +The hand-written code lives one layer up, in `:services:*` — the +Api/Service/Repository/Controller wrappers and the `LocalToProtobuf` / +`ProtobufToLocal` extensions that translate between protobuf and domain types. + +## Updating protos + +Use the **`/fetch-protos`** skill rather than copying files by hand. It fetches the +latest `.proto`s from the upstream repos, verifies they compile, summarizes the API +changes, and scaffolds missing service-layer stubs: + +``` +/fetch-protos # both targets at HEAD +/fetch-protos flipcash # flipcash only +/fetch-protos opencode # opencode at a specific commit +``` + +After fetching, run the **`proto-change-tracer`** agent to trace the impact through +`generated models → Api → Service → Repository → Controller → features` and get the +list of files that need updating. + +## Typical workflow + +1. `/fetch-protos [commit]` — pull and regenerate. +2. Build `:definitions::models` to confirm codegen succeeds. +3. Run `proto-change-tracer` to find affected wrappers. +4. Update the hand-written `:services:*` layer (new RPCs → new Api/Service methods; + changed messages → mapper updates). +5. Add/adjust tests ([12 — Testing](12-testing.md)) and build. + +## Why this matters + +Keeping generated code isolated in `:definitions:*:models` and all +human-maintained code in `:services:*` means a proto bump is a mechanical +regenerate-plus-rewire, and the boundary ([01](01-modules-and-boundaries.md)) keeps +protobuf types from leaking past the service layer. diff --git a/docs/architecture/14-error-handling.md b/docs/architecture/14-error-handling.md new file mode 100644 index 000000000..45724458c --- /dev/null +++ b/docs/architecture/14-error-handling.md @@ -0,0 +1,121 @@ +# 14 — Error handling + +How failures are represented and surfaced across the app. The conventions are: +suspend functions return **`Result`** with **typed domain errors**, errors that +represent genuine bugs are tagged **`NotifiableError`** so they reach Bugsnag/Slack, +and transient network calls are wrapped in **`retryable`**. + +```mermaid +graph TD + Api["Api — raw gRPC call"] + Service["Service — foldWithSuppression"] + Result["Result with typed *Error"] + Caller["Controller / Coordinator / ViewModel"] + UI["UI — user-facing message"] + Reporter["ErrorReporter -> Bugsnag (if NotifiableError)"] + + Api --> Service --> Result --> Caller + Caller --> UI + Caller --> Reporter +``` + +## Typed errors over raw exceptions + +Backend calls map RPC status to a **sealed error hierarchy** per operation, rather +than throwing generic exceptions. The base type is `CodeServerError` +([`libs/logging/.../CodeServerError.kt`](../../libs/logging/src/main/kotlin/com/getcode/utils/CodeServerError.kt)); +each RPC defines its own sealed subclass with one case per failure mode. Example +from [`services/flipcash/.../models/Errors.kt`](../../services/flipcash/src/main/kotlin/com/flipcash/services/models/Errors.kt): + +```kotlin +sealed class RegisterError(message: String?, cause: Throwable? = null) : CodeServerError(message, cause) { + class InvalidSignature : RegisterError("Invalid signature"), NotifiableError + class Denied : RegisterError("Denied") + class Unrecognized : RegisterError("Unrecognized"), NotifiableError + data class Other(override val cause: Throwable? = null) : + RegisterError(message = cause?.message, cause = cause), NotifiableError +} +``` + +OCP defines the same shape for its operations (e.g. `SubmitIntentError`, +`CodeAccountCheckError` in +`services/opencode/.../model/core/errors/Errors.kt`). The recurring cases are an +expected/benign set (`Denied`, `InvalidTimestamp`, …) plus a catch-all +`Other(cause)`. + +## `Result` at the service boundary + +The **Service** layer converts a raw Api response into a `Result`, mapping the +status code to the right typed error via `foldWithSuppression` +(`services/*/internal/network/extensions/`): + +```kotlin +suspend fun register(owner: KeyPair): Result { + return api.register(owner).foldWithSuppression( + onResult = { response -> + when (response.result) { + OK -> Result.success(response.userId.toDomain()) + INVALID_SIGNATURE -> Result.failure(RegisterError.InvalidSignature()) + DENIED -> Result.failure(RegisterError.Denied()) + else -> Result.failure(RegisterError.Other()) + } + }, + onError = { cause -> Result.failure(cause.toValidationOrElse { RegisterError.Other(cause = it) }) }, + ) +} +``` + +Callers (controllers, coordinators, ViewModels) then use `onSuccess` / `onFailure` +or `fold` — they never see gRPC types ([04](04-networking.md)). + +## Notifiable vs expected errors + +`NotifiableError` +([`libs/logging/.../NotifiableError.kt`](../../libs/logging/src/main/kotlin/com/getcode/utils/NotifiableError.kt)) +is a marker that distinguishes **bugs** from **expected outcomes**: + +```kotlin +/** + * Marker interface for errors representing unexpected failures (not user-caused). + * Errors implementing this are tagged in Bugsnag with metadata that triggers Slack notifications. + */ +interface NotifiableError : ConditionallyNotifiable { + override val isNotifiable: Boolean get() = true +} +``` + +- **Tag it `NotifiableError`** when the case "should never happen" (an invalid + signature, an unrecognized server response) — these flow through `ErrorReporter` + to Bugsnag and alert the team ([08 — Cross-cutting concerns](08-cross-cutting-concerns.md)). +- **Don't** tag expected, user-driven outcomes (a `Denied`, a validation failure) — + surfacing them as noise defeats the purpose. + +## Retrying transient failures + +For flaky network calls, wrap the call in `retryable` / `retryableOrThrow` +([`libs/network/connectivity/public/.../Retry.kt`](../../libs/network/connectivity/public/src/main/kotlin/com/getcode/utils/network/Retry.kt)) +rather than hand-rolling a loop: + +```kotlin +suspend inline fun retryable( + maxRetries: Int = 3, + delayDuration: Duration = 2.seconds, + backoffFactor: Double = 1.0, // 1.0 = fixed delay; >1 = exponential + retryIf: (Throwable) -> Boolean = { true }, + // onRetry / onError default to tracing +): T? +``` + +`retryable` returns `null` when retries are exhausted; `retryableOrThrow` rethrows +the last exception. Use `retryIf` to retry only on transient conditions. Retries are +traced automatically ([08](08-cross-cutting-concerns.md)). + +## Guidance + +- **Return `Result` with a typed error**, not a thrown exception, from anything + that can fail at the service boundary. +- **One sealed error hierarchy per operation**, with an `Other(cause)` catch-all. +- **Mark genuine-bug cases `NotifiableError`**; leave expected outcomes unmarked. +- **Wrap transient calls in `retryable`** with a sensible `retryIf`. +- **Map to user-facing copy at the UI layer** — the ViewModel decides what (if + anything) the user sees for each error case. diff --git a/docs/architecture/15-ci-and-release.md b/docs/architecture/15-ci-and-release.md new file mode 100644 index 000000000..788ce53ef --- /dev/null +++ b/docs/architecture/15-ci-and-release.md @@ -0,0 +1,72 @@ +# 15 — CI & release + +How the project builds, tests, versions, and ships. Day-to-day you mostly care +about the **CI** check on PRs; the rest is reference for the release pipeline and +the helper skills. + +```mermaid +graph TD + PR["Pull request"] --> CI["ci.yml — JDK 21 + Ruby, secrets -> local.properties"] + CI --> Tests["fastlane flipcash_tests (unit tests)"] + Prep["prep-dev.yml (monthly cron)"] --> Bump["bump-patch.yml (manual)"] + Bump --> Deploy["build-fcash2-upload-android.yml -> Play Store"] + Build["fastlane build_flipcash"] --> Deploy +``` + +## CI (every PR) + +[`.github/workflows/ci.yml`](../../.github/workflows/ci.yml) runs on `pull_request`: + +1. Checks out and sets up **JDK 21 (Corretto)** and Ruby. +2. Restores the Gradle build cache. +3. Decodes `google-services.json` and writes the API-key secrets into + `local.properties` (`BUGSNAG_API_KEY`, `GOOGLE_CLOUD_PROJECT_NUMBER`, + `MIXPANEL_API_KEY`, `COINBASE_ONRAMP_API_KEY` — see [10 — Build & run](10-build-and-run.md)). +4. Runs `bundle exec fastlane android flipcash_tests`. + +So CI == the `flipcash_tests` Fastlane lane == unit tests. Keep PRs green by running +`./gradlew test` locally first ([12 — Testing](12-testing.md)). + +## Fastlane lanes + +[`fastlane/Fastfile`](../../fastlane/Fastfile): + +| Lane | Purpose | +|------|---------| +| `flipcash_tests` | Generates the emoji list, then runs `flipcashTestDebug` (the CI lane). | +| `build_flipcash` | Builds the release bundle (`apps:flipcash:app:bundle`) and uploads the Bugsnag/ProGuard (R8) mapping. | +| `upload_flipcash` | Uploads the build to Google Play. | +| `deploy_flipcash` | Build + deploy to Play in one step. | +| `download_from_playstore_flipcash` | Pulls store metadata. | + +## Release workflows + +| Workflow | Trigger | What it does | +|----------|---------|--------------| +| [`prep-dev.yml`](../../.github/workflows/prep-dev.yml) | Cron, 1st of each month (+ manual) | Increments Flipcash versioning for the new month. | +| [`bump-patch.yml`](../../.github/workflows/bump-patch.yml) | Manual (`workflow_dispatch`, choose a track) | Updates the release manifest and bumps the patch version. | +| [`build-fcash2-upload-android.yml`](../../.github/workflows/build-fcash2-upload-android.yml) | Manual | Builds and deploys Flipcash to the Play Store. | +| [`labeler.yml`](../../.github/workflows/labeler.yml) | PR opened/synchronized | Applies area labels to PRs. | + +`versionCode` comes from `Packaging.Flipcash.versionCode` or `gitVersionCode()` +([10](10-build-and-run.md)). + +## Helper skills + +Three skills support the release/debug loop (invoke with `/`): + +| Skill | Use | +|-------|-----| +| `/build-lookup ` | Find the git commit and GitHub Actions run for a given Flipcash `versionCode`. | +| `/r8-mapping ` | Download the R8/ProGuard mapping for a release build to deobfuscate a stack trace. | +| `/release-notes ` | Generate polished GitHub release notes from git refs. | + +For where these fit among the other automation, see +[16 — Agents & skills](16-agents-and-skills.md). + +## Why this matters + +CI is intentionally narrow (unit tests via one Fastlane lane), so a fast local +`./gradlew test` is a faithful preview. Versioning is automated (monthly prep + +patch bumps) and releases go through Fastlane, so manual version edits are rarely +needed — reach for the skills above when investigating a shipped build. diff --git a/docs/architecture/16-agents-and-skills.md b/docs/architecture/16-agents-and-skills.md new file mode 100644 index 000000000..2394a4a37 --- /dev/null +++ b/docs/architecture/16-agents-and-skills.md @@ -0,0 +1,71 @@ +# 16 — Agents & skills + +This repo ships **Claude Code** automation tuned to its architecture: subagents +(under `.claude/agents/`) for multi-step investigations, and slash-command skills +(under `.claude/skills/`) for repeatable workflows. This page maps a task to the +right tool so you (or an agent) don't reinvent work the repo already automates. + +## Task → tool + +| When you want to… | Use | Type | +|-------------------|-----|------| +| Triage a Bugsnag issue / crash with evidence and a fix direction | `/triage` | skill | +| Investigate a crash/stack trace and trace it to a root cause | `bug-triage` | agent | +| Create a new feature / shared / lib module skeleton | `module-scaffolder` | agent | +| Add a screen end-to-end (uses the scaffolder) | follow [11 — Adding a feature](11-adding-a-feature.md) | guide | +| Fetch latest protobufs, verify, summarize, scaffold stubs | `/fetch-protos` | skill | +| Trace the impact of a proto change through the codebase | `proto-change-tracer` | agent | +| Assess the blast radius of a dependency bump | `dependency-impact` | agent | +| Review a Dependabot PR for breaking changes | `/dep-review` | skill | +| Review a PR (or local changes) for quality & arch consistency | `pr-reviewer` | agent | +| Find untested code and scaffold tests | `test-gap-finder` | agent | +| Map a `versionCode` to its commit / CI run | `/build-lookup` | skill | +| Deobfuscate a release stack trace | `/r8-mapping` | skill | +| Generate GitHub release notes | `/release-notes` | skill | + +## Agents (`.claude/agents/`) + +Agents are launched via the `Agent` tool for open-ended, multi-step work: + +- **bug-triage** — traces a Bugsnag link / stack trace / error through the codebase + to a root cause and suggested fix. +- **module-scaffolder** — generates a full module skeleton: `build.gradle.kts`, + package structure, entry points, navigation registration, `settings.gradle.kts` + inclusion. +- **proto-change-tracer** — after `/fetch-protos`, traces + `generated models → Api → Service → Repository → Controller → features` + ([13](13-protobuf-and-codegen.md)). +- **dependency-impact** — for a dependency bump, finds dependent modules, breaking + API changes, and targeted tests to run. +- **pr-reviewer** — reviews a PR or local diff against the project's patterns + (CompositionLocal injection, MVI/MVVM, convention plugins, proto boundaries). +- **test-gap-finder** — finds coverage gaps and scaffolds tests in the project's + style ([12](12-testing.md)). + +## Skills (`.claude/skills/`) + +Skills are slash commands for repeatable workflows: + +- **/triage** — triage a Bugsnag production issue (top open or a specific + URL/ID) end-to-end. +- **/fetch-protos** `[flipcash|opencode] [commit]` — pull + regenerate protos + ([13](13-protobuf-and-codegen.md)). +- **/dep-review** `` — review a Dependabot PR for breaking changes and required + code updates. +- **/build-lookup** `` — git commit + Actions run for a build + ([15](15-ci-and-release.md)). +- **/r8-mapping** `` — download the R8 mapping to deobfuscate a trace. +- **/release-notes** ` ` — generate polished release notes. + +## Project context for agents + +The repo root **`CLAUDE.md`** is the canonical orientation file (build commands, +module layout, key patterns, namespaces, git conventions) and points here. New +agents should read it first, then this `docs/architecture/` suite for depth. + +## Why this matters + +The architecture has sharp conventions (layered modules, proto boundaries, +coordinator/controller roles, MVI), and these agents/skills already encode them. +Reaching for the right one keeps work consistent with the codebase instead of +re-deriving the patterns each time. diff --git a/docs/architecture/README.md b/docs/architecture/README.md new file mode 100644 index 000000000..e068f0544 --- /dev/null +++ b/docs/architecture/README.md @@ -0,0 +1,95 @@ +# Flipcash Android — Architecture + +A guide to how the Android app is structured. Each document covers one +architectural concern; start here, then jump to the topic you need. + +> **These docs describe structure and intent, not every line of code.** If the +> documentation and the code disagree, **the code is authoritative** — fix the +> docs. When you change a subsystem, update its document in the same commit. + +## What Flipcash is + +Flipcash is a **self-custodial mobile wallet** for instant, global, private +payments. Its base reserve currency is **USDF** (a USD stablecoin); on top of it, +users create, buy, sell, and share **launchpad currencies** — custom on-chain tokens +**backed by USDF reserves** (think memecoins backed by USDC on Solana). Launchpad +currencies are the unit that circulates socially; USDF is what they're priced in and +redeemable for. The signature interaction is a **device-to-device cash bill** (which +can carry a launchpad currency or USDF): one phone renders a cash bill displaying an +animated circular **Kik Code** on screen, another phone scans the code, and a +peer-to-peer handshake settles the payment. The app also supports contact/username sends, on-ramp funding (Coinbase, +in-app purchase), buying/selling launchpad currencies against USDF, and on-chain +withdrawals. + +Under the hood it talks to **two gRPC backends** — the Flipcash service +(accounts, profiles, chat, activity) and the Open Code Protocol / OCP service +(transactions, intents, exchange rates) — and signs operations on **Solana** with +**Ed25519** keys derived from a BIP39 mnemonic. State is cached locally in **Room**, +the UI is **Jetpack Compose** (dark-mode only), and dependencies are wired with +**Hilt** + **CompositionLocal**. + +## The layer cake + +```mermaid +graph TD + App["apps/flipcash/app — entry point, navigation host, DI wiring"] + Features["apps/flipcash/features/* — 26 self-contained screens + ViewModels"] + Shared["apps/flipcash/shared/* — 34 coordinators / controllers / services"] + Core["apps/flipcash/core — app-wide routes, locals, infra"] + Services["services/* — gRPC wrappers (API → Service → Repository → Controller)"] + Defs["definitions/* — protobuf sources + generated models"] + UI["ui/* — Compose components, theme, navigation, scanner"] + Libs["libs/* — crypto, network, logging, currency (leaf utilities)"] + Vendor["vendor/* — Kik scanner, OpenCV, TipKit"] + + App --> Features --> Shared --> Services --> Defs --> Libs + App --> Core + Features --> Core + Features --> UI --> Libs + Shared --> Core + Services --> Libs + Libs --> Vendor +``` + +*Arrows point to dependencies; the graph is acyclic. `ui/*` and `libs/*` never +depend on app modules.* See [01 — Modules & boundaries](01-modules-and-boundaries.md). + +## Documentation index + +| # | Topic | Scope | +|---|-------|-------| +| 01 | [Modules & boundaries](01-modules-and-boundaries.md) | ~132 modules, convention plugins, dependency graph, `public`/`impl`/`bindings` pattern, enforced boundaries | +| 02 | [State & dependency injection](02-state-and-dependency-injection.md) | Hilt setup, the CompositionLocal injection pattern, `BaseViewModel` MVI | +| 03 | [Navigation](03-navigation.md) | Navigation3, the `AppRoute` sealed graph, `CodeNavigator`, `Router` deeplink dispatch with auth gating | +| 04 | [Networking](04-networking.md) | gRPC layering, managed channels, Ed25519 signing, `SubmitIntent` streaming, REST/JWT/Coinbase, exchange rates, connectivity | +| 05 | [Persistence](05-persistence.md) | Room, per-user database naming, DAOs, Paging `RemoteMediator`s, DataStore | +| 06 | [Payments & operations](06-payments-and-operations.md) | Key management, `AccountCluster`, `AuthState`, intent submission handshake, purchase methods | +| 07 | [Design system](07-design-system.md) | The `ui/*` layer, theme, components, scanner, biometrics, Compose conventions | +| 08 | [Cross-cutting concerns](08-cross-cutting-concerns.md) | Logging/tracing, error reporting, analytics, biometrics, coroutines, build config | +| 09 | [Separation of concerns](09-separation-of-concerns.md) | Layering principles, MVI/MVVM split, where logic lives, the acyclic discipline | +| — | [Feature catalog](features/README.md) | Representative features by category, with screens, ViewModels, and patterns | + +### Guides (how to work in the codebase) + +| # | Topic | Scope | +|---|-------|-------| +| 10 | [Build & run](10-build-and-run.md) | Prerequisites, the real `local.properties` keys, Gradle commands, variants, CI | +| 11 | [Adding a feature](11-adding-a-feature.md) | End-to-end: scaffold module → ViewModel → screen → route → register → share | +| 12 | [Testing](12-testing.md) | What to test where, `:libs:test-utils`, Robolectric, fakes, Turbine (+ Compose UI guide) | +| 13 | [Protobuf & codegen](13-protobuf-and-codegen.md) | proto sources → generated models → services; updating protos with `/fetch-protos` | +| 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 | +| — | [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. +- **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. +- **New to Claude Code automation here** → 16 (agents & skills) → CLAUDE.md. + +Unfamiliar with a term? The [Glossary](glossary.md) defines the domain and +architecture vocabulary (USDF, intent, timelock, coordinator, …). diff --git a/docs/architecture/features/README.md b/docs/architecture/features/README.md new file mode 100644 index 000000000..488423cbf --- /dev/null +++ b/docs/architecture/features/README.md @@ -0,0 +1,70 @@ +# Feature catalog + +The app has **26 feature modules** under +[`apps/flipcash/features/`](../../../apps/flipcash/features/). Each is +self-contained — it owns its screens, ViewModels, and Hilt wiring, inherits the UI +layer and `:apps:flipcash:core` from the `flipcash.android.feature` convention +plugin ([01](../01-modules-and-boundaries.md)), and is reachable through a route in +`AppRoute` registered in `appEntryProvider` ([03](../03-navigation.md)). + +This catalog highlights **representative features per category**, not every module. +Paths are relative to each module's `src/main/kotlin/`. + +## Patterns + +Two recurring shapes show up in the **Pattern** column: + +- **VM-driven** — a `BaseViewModel` ([02](../02-state-and-dependency-injection.md)) + coordinates multi-step state and async work; the screen renders `state` and + dispatches `Event`s. +- **Flow** — a `FlowRoute` / `FlowRouteWithResult` whose screen hosts an inner + back stack of steps and (often) returns a typed result to its caller. + +## A. Onboarding & auth + +| Feature | Purpose | Screen / ViewModel | Pattern & integration | +|---------|---------|--------------------|-----------------------| +| **login** | Account creation, seed login, access-key, and permission steps | `login/OnboardingFlowScreen.kt` · `…/internal/SeedInputViewModel`, `LoginAccessKeyViewModel` | **Flow** (`AppRoute.OnboardingFlow`); drives `UserManager`/`AuthState`, hands off to **purchase** | +| **purchase** | First-purchase / account funding during onboarding | `purchase/PurchaseAccountScreen.kt` · `…/internal/PurchaseAccountViewModel` | **VM-driven**; uses `PurchaseMethodController` ([06](../06-payments-and-operations.md)) | +| **backupkey** | Show / confirm the recovery access key | `backupkey/BackupKeyScreen.kt` · `…/internal/BackupKeyScreenViewModel` | **VM-driven**; reads mnemonic from `UserManager` | + +## B. Money movement + +| Feature | Purpose | Screen / ViewModel | Pattern & integration | +|---------|---------|--------------------|-----------------------| +| **cash** | Amount entry for a device-to-device cash bill (sends the selected token — a launchpad currency or USDF) | `cash/CashScreen.kt` · `…/internal/CashScreenViewModel` | **VM-driven**; combines token + balance + rate flows, emits `PresentBill`, calls `SessionController.showBill` | +| **scanner** | Camera capture of a peer's Kik Code (e.g. on a cash bill) | `scanner/ScannerScreen.kt` | Screen-local; uses `:ui:scanner`, routes scans through `Router.classify` | +| **direct-send** | Send to a contact / phone number | `directsend/SendFlowScreen.kt` · `…/internal/SendFlowViewModel` | **Flow**; contact list + phone gate steps | +| **withdrawal** | Withdraw funds on-chain | `withdrawal/WithdrawalFlowScreen.kt` · `withdrawal/WithdrawalViewModel` | **Flow** (`AppRoute.Transfers.Withdrawal`); entry → destination → confirmation | +| **deposit** | Add funds via on-ramp / deposit address | `deposit/DepositFlowScreen.kt` · `…/internal/DepositViewModel` | **Flow** (`FlowRouteWithResult`); USDC deposit info, Coinbase on-ramp | +| **transactions** | Activity / transaction history | `transactions/TransactionHistoryScreen.kt` · `…/internal/TransactionHistoryViewModel` | **VM-driven**; Paging-backed list from persistence ([05](../05-persistence.md)) | + +## C. Currencies & tokens + +| Feature | Purpose | Screen / ViewModel | Pattern & integration | +|---------|---------|--------------------|-----------------------| +| **balance** | Wallet balance across tokens | `balance/BalanceScreen.kt` · `…/internal/BalanceViewModel` | **VM-driven**; observes token balances + exchange rates | +| **tokens** | Token info (price/market cap in USDF), selection, and buy/sell swaps against USDF | `tokens/TokenInfoScreen.kt`, `tokens/SwapFlowScreen.kt`, `tokens/TokenSelectScreen.kt` | **Flow** for swap (`FlowRouteWithResult`, `SwapPurpose.Buy`/`Sell`); screen-local for info/selection | +| **currency-creator** | Create a launchpad currency (USDF-backed), seeded by an initial USDF buy | `currencycreator/CurrencyCreatorFlowScreen.kt` · `…/internal/CurrencyCreatorViewModel` | **Flow**; name → icon → info → processing steps | + +## D. Profile, settings & misc + +| Feature | Purpose | Screen / ViewModel | Pattern & integration | +|---------|---------|--------------------|-----------------------| +| **myaccount** | Account screen + user profile | `myaccount/MyAccountScreen.kt`, `myaccount/UserProfileScreen.kt` · `…/internal/MyAccountScreenViewModel` | **VM-driven**; reactive feature-flag / staff binding, event-driven nav | +| **messenger** | In-app chat & chat-amount entry | `messenger/ChatFlowScreen.kt` · `…/internal/ChatViewModel` | **Flow** (`AppRoute.Messaging.Chat`); Paging-backed messages | +| **appsettings** | App preferences | `apps/flipcash/features/appsettings/…` | **VM-driven**; backed by `AppSettingsCoordinator` (`LocalAppSettings`) | +| **invite / shareapp** | Invite & share flows | `apps/flipcash/features/invite`, `…/shareapp` | Screen-local; use `InviteController` / `ShareSheetController` locals | +| **device-logs / lab / advanced** | Debug & internal tooling | `apps/flipcash/features/device-logs`, `…/lab`, `…/advanced` | Internal; surface trace logs and feature-flag overrides | + +## Full module list + +The 26 feature directories: `advanced`, `appsettings`, `appupdates`, `backupkey`, +`balance`, `bill-customization`, `cash`, `contact-verification`, `currency-creator`, +`deposit`, `device-logs`, `direct-send`, `discovery`, `invite`, `lab`, `login`, +`menu`, `messenger`, `myaccount`, `purchase`, `scanner`, `shareapp`, `tokens`, +`transactions`, `userflags`, `withdrawal`. + +> Keep this catalog representative, not exhaustive — when you add a feature, add a +> row to the relevant category if it introduces a new user-facing capability or a +> notable pattern, and update the full list above. diff --git a/docs/architecture/glossary.md b/docs/architecture/glossary.md new file mode 100644 index 000000000..8187e13fa --- /dev/null +++ b/docs/architecture/glossary.md @@ -0,0 +1,67 @@ +# Glossary + +Domain and architecture terms used throughout this codebase and these docs. +Definitions are kept short; the **See** column points to the doc (or code) with the +full story. + +## Product & money + +| Term | Meaning | See | +|------|---------|-----| +| **USDF** | The USD-pegged **base / reserve** stablecoin (the "core mint"). Everything is priced in and backed by USDF; it is itself sendable. In code: a `Token` with `launchpadMetadata = null`. | [06](06-payments-and-operations.md) | +| **Launchpad token / currency** | The user-facing, tradable currency people **create, buy, sell, and share** — a custom on-chain token **backed by USDF reserves** and priced by an on-chain bonding curve. Like a memecoin backed by USDC. | [06](06-payments-and-operations.md) | +| **Core mint** | The codebase's name for USDF — the reserve currency that launchpad currencies are denominated in and backed by. | [06](06-payments-and-operations.md) | +| **Bonding curve** | The on-chain formula that sets a launchpad currency's price from its circulating supply (`price = f(supply)`, in USDF). | [06](06-payments-and-operations.md) | +| **Liquidity pool / `coreMintVault`** | A launchpad currency's bonding-curve pool; its `coreMintVault` holds the **USDF reserves** backing the token (vs `mintVault`, which holds the token). | [06](06-payments-and-operations.md) | +| **Reserves** | Two senses: (1) on-chain USDF backing a token in its `coreMintVault`; (2) the **user's own USDF balance** (`observeReservesBalance()`) spent to buy launchpad currencies. | [06](06-payments-and-operations.md) | +| **Cash bill** | A digital representation of "cash" — a note/denomination, like a US dollar — used for device-to-device transfers. Modeled as `Bill.Cash`; carries a token (a launchpad currency or USDF) and **displays a Kik Code** for the recipient to scan. One of the surfaces a Kik Code rides on. | [06](06-payments-and-operations.md), [features](features/README.md) | +| **Kik Code** | The scannable, animated **circular code** itself — the device-to-device transfer transport. It is **surface-agnostic**: today it's displayed on cash bills, soon on digital gold bars, and could later back other flows such as merchant payments. Captured via the camera scanner (`:ui:scanner`). | [07](07-design-system.md) | +| **On-ramp** | Funding the wallet with fiat (Coinbase) or in-app purchase. | [04](04-networking.md), [06](06-payments-and-operations.md) | +| **Swap** | Buying or selling a launchpad currency **against USDF** (`SwapPurpose.Buy`/`Sell`); sells charge a ~1% fee. | [03](03-navigation.md), [06](06-payments-and-operations.md) | +| **Withdrawal** | Moving funds on-chain to an external Solana address. | [features](features/README.md) | + +## Identity, keys & accounts + +| Term | Meaning | See | +|------|---------|-----| +| **Self-custodial** | Keys live on the device; the user — not a server — controls funds. | [06](06-payments-and-operations.md) | +| **Mnemonic** | The BIP39 word phrase from which all of a user's keys are derived. | [06](06-payments-and-operations.md) | +| **Entropy** | The raw seed bytes behind the mnemonic; its Base58 prefix also names the user's local database. | [05](05-persistence.md), [06](06-payments-and-operations.md) | +| **Access key** | The user-facing recovery credential (backed by the mnemonic) that the user saves during onboarding. | [features](features/README.md) | +| **Ed25519** | The signature scheme used to sign requests and transactions. | [04](04-networking.md), [06](06-payments-and-operations.md) | +| **AccountCluster** | The bundle of derived accounts that makes up a wallet: authority + timelock + per-token deposit addresses. | [06](06-payments-and-operations.md) | +| **Authority** | The master keypair that controls an account. | [06](06-payments-and-operations.md) | +| **Timelock** | The custody mechanism — vault accounts in a timelock virtual machine that hold funds. | [06](06-payments-and-operations.md) | +| **Vault** | A timelock account that actually holds a token balance. | [06](06-payments-and-operations.md) | +| **Rendezvous** | The keypair used to sign requests during the payment handshake (the cluster's authority key). | [06](06-payments-and-operations.md) | +| **Deposit address** | A per-token address derived from the cluster, used for on-ramp funding. | [06](06-payments-and-operations.md) | +| **AuthState** | The auth-state machine (`Unknown → Onboarding → Authenticating → Ready → LoggedOut`) owned by `UserManager`. | [06](06-payments-and-operations.md) | + +## Backend & transactions + +| Term | Meaning | See | +|------|---------|-----| +| **OCP / Open Code Protocol** | The gRPC backend for transactions, intents, swaps, and exchange rates (module `:services:opencode`). | [04](04-networking.md) | +| **Flipcash service** | The gRPC backend for accounts, profiles, chat, and activity (module `:services:flipcash`). | [04](04-networking.md) | +| **Intent** | A signed unit of money movement (transfer, remote send/receive, withdraw, swap, distribution) submitted over the `SubmitIntent` bidirectional stream. | [04](04-networking.md), [06](06-payments-and-operations.md) | +| **Mint** | A Solana token mint address (`PublicKey` subtype); identifies a token such as USDF (`Mint.usdf`) or a launchpad currency. | [06](06-payments-and-operations.md) | +| **MintMetadata / Token** | The model for any currency (`MintMetadata`, aliased `Token`). A non-null `launchpadMetadata` makes it a launchpad currency; `null` means it's USDF (the core mint). | [06](06-payments-and-operations.md) | +| **Protobuf / proto** | The Protocol Buffers contract; generated code lives in `:definitions:*:models` and is never hand-edited. | [13](13-protobuf-and-codegen.md) | +| **NotifiableError** | Marker for errors that represent bugs (not user-caused) and should alert via Bugsnag/Slack. | [14](14-error-handling.md) | + +## Architecture roles + +| Term | Meaning | See | +|------|---------|-----| +| **Coordinator** | App-layer, session/lifecycle-aware owner of a domain's cached, synced state; wraps stateless controllers. | [02](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services) | +| **Controller** | A domain/feature API — often a stateless network gateway, or light UI-facing state. | [02](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services) | +| **Manager** | Owner of a state machine / resource lifecycle (e.g. `UserManager`, `AuthManager`). | [02](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services) | +| **Service** | Internal gRPC/REST adapter returning `Result`; never exposed to the UI. | [02](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services), [04](04-networking.md) | +| **CompositionLocal / `Local*`** | The ambient handle (e.g. `LocalRouter`) through which Compose reads app-wide controllers. | [02](02-state-and-dependency-injection.md) | +| **AppRoute** | A typed, serializable navigation destination (a Navigation3 `NavKey`). | [03](03-navigation.md) | +| **CodeNavigator** | The custom navigator wrapping the Navigation3 back stack. | [03](03-navigation.md) | +| **Convention plugin** | A `build-logic` Gradle plugin (`flipcash.android.*`) that standardizes a module's setup and dependencies. | [01](01-modules-and-boundaries.md) | + +> Solana/Kin lineage: the codebase descends from the Code/Kin wallet; some packages +> use the legacy `com.getcode` namespace. Current accounts and tokens are +> Solana-based.