mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-14 14:19:57 +02:00
Compare commits
69 Commits
master
...
feat/devic
| Author | SHA1 | Date | |
|---|---|---|---|
| bece2af179 | |||
| beab4100ed | |||
| ac77950ac3 | |||
| 6ab658fea2 | |||
| 2ac9166b34 | |||
| 4f09e474c5 | |||
| 2c941dc623 | |||
| 5b57917f52 | |||
| 5ffd86afbc | |||
| 67a557af7c | |||
| 32d69b9771 | |||
| 644c9bfda3 | |||
| dac8232324 | |||
| ebdff3df4b | |||
| b9884b566a | |||
| 37cc238888 | |||
| eabc2754a7 | |||
| edf7426672 | |||
| d91820b867 | |||
| 3b7fd3d468 | |||
| 5c66c2ca65 | |||
| a2385912c2 | |||
| 0a6ecb5c3b | |||
| 9c7697c90b | |||
| 039d3f942b | |||
| 1e601e118a | |||
| e79aba7bcf | |||
| f603f5fb8d | |||
| 0c317efe59 | |||
| 8e8cab9b24 | |||
| 89c2206f0f | |||
| a01d8328c9 | |||
| 8798edf0f2 | |||
| 4a83b18d42 | |||
| e2cfad7fb3 | |||
| 22c842acb1 | |||
| 11fae16391 | |||
| f4cec1415c | |||
| 193fe7c757 | |||
| 04a31e8a24 | |||
| e740942f63 | |||
| eefedd0cb0 | |||
| 429987785e | |||
| 931b297321 | |||
| 1cd09a2ec3 | |||
| b128457137 | |||
| 564b86c28c | |||
| 0ec0daef2a | |||
| c852a68f28 | |||
| 225ff9ad22 | |||
| 677fc3c6d5 | |||
| 3441280599 | |||
| d8587851a3 | |||
| cba51ad504 | |||
| 0f70663263 | |||
| 94a8ba2103 | |||
| 268fbf161d | |||
| dc23e87c56 | |||
| 1369df4578 | |||
| 256ff89423 | |||
| 3c89c34c6e | |||
| 178def0629 | |||
| 5f5f7e0515 | |||
| e041fbeda2 | |||
| 7aa99304b2 | |||
| 9fbcf653e8 | |||
| 4cfee786fc | |||
| 0388422318 | |||
| 7afc7d0892 |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(swift test *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
---
|
||||
description: Token-optimized writing standards for skill reference files. Apply when creating or editing reference .md files in the references/ directory.
|
||||
globs: references/*.md
|
||||
alwaysApply: false
|
||||
---
|
||||
|
||||
# Reference File Writing Standards
|
||||
|
||||
Every reference file must be token-efficient. Agents already know standard APIs — provide patterns, pitfalls, and project-specific rules, not tutorials.
|
||||
|
||||
## File Structure
|
||||
|
||||
1. **Line 1: `# Title`** — concise topic name
|
||||
2. **Line 2-3: One-liner scope** — what this file covers and when to use it, not a marketing paragraph
|
||||
3. **Cross-links** — point to related canonical files (e.g., "For shared architecture concepts, see [architecture.md](architecture.md)")
|
||||
4. **`References:` block** (optional) — external URLs for provenance; keep, they're cheap
|
||||
5. **No Table of Contents** — agents navigate by headings, not TOC lists
|
||||
|
||||
## Writing Rules
|
||||
|
||||
| Rule | Do | Don't |
|
||||
|---|---|---|
|
||||
| Tables over prose | `\| Issue \| Fix \|` table | Multi-paragraph explanations |
|
||||
| Rules over explanations | State the rule directly | Explain "why" unless non-obvious |
|
||||
| Examples over descriptions | One BAD/GOOD code pair | Three paragraphs describing the concept |
|
||||
| No tutorial content | Show the pattern/pitfall | Explain what `StateFlow` or `PagingSource` is |
|
||||
| One-liner intros | "SQLite persistence via Room (KMP-ready since 2.7.0)" | "Room is a powerful persistence library that provides an abstraction layer over SQLite..." |
|
||||
| Trim filler words | Direct statements | "It is important to note that...", "In order to...", "You should consider..." |
|
||||
|
||||
## DRY Cross-Referencing
|
||||
|
||||
Each concept has ONE canonical home. Other files link to it instead of duplicating.
|
||||
|
||||
| Concept | Canonical home | Other files do |
|
||||
|---|---|---|
|
||||
| MVI ViewModel collection pattern | `architecture.md` § Reactive Data Collection | 5-8 line domain stub + cross-link |
|
||||
| State modeling (forms, calculators) | `architecture.md` § State Modeling | Cross-link |
|
||||
| Effect delivery (Channel vs SharedFlow) | `architecture.md` § Effect Delivery | Cross-link |
|
||||
| Generic test setup (runTest, Turbine) | `testing.md` | Domain-specific test factory + cross-link |
|
||||
| Koin module patterns | `koin.md` | 1-2 line binding example + cross-link |
|
||||
| Hilt module patterns | `hilt.md` | 1-2 line binding example + cross-link |
|
||||
| Nav 3 + DI wiring | `navigation-3-di.md` | Condensed example + cross-link |
|
||||
|
||||
## Section Templates
|
||||
|
||||
### MVI Integration (in data-layer files)
|
||||
|
||||
Keep to ~5-8 lines: state the domain-specific mapping rule + cross-link.
|
||||
|
||||
```markdown
|
||||
## MVI Integration
|
||||
|
||||
Map entities to domain models at the repository boundary. Never pass raw [DataType] to the UI.
|
||||
|
||||
For the ViewModel collection pattern, see [architecture.md](architecture.md) — Reactive Data Collection.
|
||||
```
|
||||
|
||||
### DI Integration (in data-layer files)
|
||||
|
||||
1-2 line binding examples + link.
|
||||
|
||||
```markdown
|
||||
## DI Integration
|
||||
|
||||
Always provide [Type] as a **singleton**.
|
||||
|
||||
\`\`\`kotlin
|
||||
// Koin: single<Type> { createType(get()) }
|
||||
// Hilt: @Provides @Singleton fun provideType(...): Type = ...
|
||||
\`\`\`
|
||||
|
||||
For full module patterns, see [koin.md](koin.md) or [hilt.md](hilt.md).
|
||||
```
|
||||
|
||||
### Anti-Patterns (table format)
|
||||
|
||||
```markdown
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it is harmful | Better replacement |
|
||||
|---|---|---|
|
||||
| [pattern] | [consequence] | [fix] |
|
||||
```
|
||||
|
||||
### Performance / Critical Rules (table format)
|
||||
|
||||
```markdown
|
||||
## Critical Rules
|
||||
|
||||
| Rule | Why |
|
||||
|---|---|
|
||||
| [rule] | [brief rationale] |
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
- Use BAD/GOOD pairs — show the mistake and the fix side by side
|
||||
- No redundant comments like `// Import the module` — only explain non-obvious intent
|
||||
- Keep examples minimal: show the pattern, not a full app
|
||||
- Use `<latest>` for dependency versions with a comment: `// search: "library latest version"`
|
||||
|
||||
## Token Budget
|
||||
|
||||
- Target: **under 3,500 tokens** per reference file (hard max: 4,000)
|
||||
- Estimate: characters / 4
|
||||
- If a file grows past 3,500 tokens, split into base + advanced (e.g., `animations.md` + `animations-advanced.md`)
|
||||
|
||||
## Checklist for New Reference Files
|
||||
|
||||
- [ ] One-liner scope, no marketing intro
|
||||
- [ ] No Table of Contents
|
||||
- [ ] Tables instead of multi-paragraph prose
|
||||
- [ ] BAD/GOOD code examples for key pitfalls
|
||||
- [ ] Anti-patterns table at the end
|
||||
- [ ] Cross-links to canonical homes (no duplicated patterns)
|
||||
- [ ] MVI/DI/Testing sections use stubs + cross-links
|
||||
- [ ] Under 3,500 tokens
|
||||
- [ ] Linked from `SKILL.md` (both trigger list and reference catalog)
|
||||
@@ -1,204 +1,182 @@
|
||||
---
|
||||
name: compose-skill
|
||||
license: MIT
|
||||
description: >
|
||||
Jetpack Compose and Compose Multiplatform (KMP/CMP) architecture skill.
|
||||
Only use when the user explicitly mentions "compose-skill", "@compose-skill",
|
||||
or "use compose skill" in their message. Do NOT auto-activate based on
|
||||
keyword matching — this skill should only be triggered by direct user request.
|
||||
description: VniDrop-specific Compose Multiplatform UI, Kotlin presentation architecture, and rendered-app visual QA. Use when designing, implementing, refactoring, or reviewing code under shared/ for Android, Windows, or Linux: screens, ViewModels, routes, navigation, adaptive layouts, platform adapters, native icons, resources, accessibility, UI tests, simulator inspection, screenshots, and visual refinement.
|
||||
---
|
||||
|
||||
# Jetpack Compose & Compose Multiplatform
|
||||
# VniDrop KMP UI
|
||||
|
||||
This skill covers the full Compose app development lifecycle — from architecture and state management through UI, networking, persistence, performance, accessibility, cross-platform sharing, build configuration, and distribution. Jetpack Compose and Compose Multiplatform share the same core APIs and mental model. **Not all Jetpack libraries work in `commonMain`** — many remain Android-only. A subset of AndroidX libraries now publish multiplatform artifacts (e.g., `lifecycle-viewmodel`, `lifecycle-runtime-compose`, `datastore-preferences`), but availability and API surface vary by version. **Before adding any Jetpack/AndroidX dependency to `commonMain`, verify the artifact is published for all required targets by checking Maven Central or the library's official documentation.** CMP uses `expect/actual` or interfaces for platform-specific code. MVI (Model-View-Intent) is the recommended architecture, but the skill adapts to existing project conventions.
|
||||
Build VniDrop's Android and desktop UI without weakening its domain model, platform identity, or Rust streaming invariants.
|
||||
|
||||
## Existing Project Policy
|
||||
## Start here
|
||||
|
||||
**Do not force migration.** If a project already follows MVI with its own conventions (different base class, different naming, different file layout), respect that. Adapt to the project's existing patterns. The architecture pattern — unidirectional data flow with Event, State, and Effect — is what matters, not a specific base class or framework. Only suggest structural changes when the user asks for them or when the existing code has clear architectural violations (business logic in composables, scattered state mutations, etc.).
|
||||
1. Read the root `AGENTS.md` and `shared/AGENTS.md` completely.
|
||||
2. Read `CONTEXT.md` and only the ADRs relevant to the feature.
|
||||
3. Inspect the nearby feature, its tests, and its Android/JVM adapters before designing.
|
||||
4. Identify the module, interface, seam, and adapters. Prefer a deep module: small interface, substantial hidden behavior, one test surface.
|
||||
5. Model state and platform behavior before drawing pixels.
|
||||
6. Implement the smallest complete product flow; add regressions at the lowest useful layer.
|
||||
7. Run `make test-shared`, then launch and inspect the affected app using the visual QA gate below.
|
||||
8. Refine the rendered result until every affected presentation passes the maturity and native-platform review.
|
||||
9. Run `make check-shared` for a production UI handoff.
|
||||
|
||||
## Workflow
|
||||
## Scope and ownership
|
||||
|
||||
When helping with Jetpack Compose or Compose Multiplatform code, follow this process:
|
||||
- `shared/commonMain` owns shared domain-facing presentation state, feature behavior, semantic UI structure, and reusable visual primitives.
|
||||
- `androidMain` owns Android pickers, SAF, MediaStore, system surfaces, and Android-native presentation where needed.
|
||||
- `jvmMain` owns Windows/Linux filesystem, desktop integration, and platform-native presentation where needed.
|
||||
- Apple uses the native SwiftUI app under `apple/`. Do not move Apple presentation into KMP.
|
||||
- Rust owns transfer payload streaming, authorization, durable lifecycle, and transfer persistence. Kotlin must not become the payload path.
|
||||
|
||||
1. **Read the existing code first for context** — check conventions, base classes, and layout. For small UI or logic asks, restrict your reading to the immediately relevant files to save time. Do not map out the entire project architecture unless a structural refactor is requested.
|
||||
2. **Identify the concern** — is this architecture, state modeling, performance, navigation, DI, animation, cross-platform, or testing?
|
||||
3. **Apply the core rules below** — the decision heuristics and defaults in this file cover most cases.
|
||||
4. **Consult the right reference** — load the relevant file from `references/` only when deeper guidance is needed. Use the [Quick Routing](#quick-routing) in the Detailed References section to pick the right file.
|
||||
5. **Verify dependencies before recommending** — before adding or upgrading any dependency, verify coordinates, target support, and API shape via a documentation MCP tool or official docs (see [Dependency Verification Rule](#dependency-verification-rule)).
|
||||
6. **Flag anti-patterns contextually** — if the user's code violates best practices, call it out for production code. For quick prototypes or minor UI tweaks, prioritize answering their specific question over lecturing them on strict rules.
|
||||
7. **Write the minimal correct solution** — do not over-engineer. Prefer feature-specific code over generic frameworks.
|
||||
## Architecture
|
||||
|
||||
## Dependency Verification Rule
|
||||
Use VniDrop's MVVM-style modules:
|
||||
|
||||
**Before recommending any new dependency or version upgrade, verify:**
|
||||
- Immutable `*State` exposed through `StateFlow`.
|
||||
- Named ViewModel methods for user actions. Do not introduce a generic `onEvent` hierarchy.
|
||||
- Route: obtain/collect state, invoke platform adapters, collect effects, and perform navigation.
|
||||
- Screen: render state and emit explicit callbacks.
|
||||
- Leaf composables: accept narrow state and callbacks; retain only visual-local state such as focus, scroll, or animation.
|
||||
- `AppGraph` wires dependencies. Do not introduce Hilt, Koin, or a second graph.
|
||||
|
||||
1. **Coordinates** — Confirm the exact Maven coordinates (`group:artifact:version`) exist and are current.
|
||||
2. **Target support** — Confirm the artifact supports the project's targets (Android, iOS, Desktop, `commonMain`). Do not assume a Jetpack library works in `commonMain` unless verified.
|
||||
3. **API shape** — Confirm the API you plan to use actually exists in that version. Function signatures, parameter names, and return types change between major versions.
|
||||
Design for depth and locality:
|
||||
|
||||
**How to verify:**
|
||||
- **Documentation MCP tool** (preferred) — If a documentation MCP server is available (e.g., Context7), verify exact tool names and schemas first, then use it to fetch current official documentation for the library.
|
||||
- **Official docs** — Search the library's official documentation or release notes.
|
||||
- **Maven Central / Google Maven** — Check artifact availability and supported platforms.
|
||||
- Put behavior behind a small interface used by callers and tests.
|
||||
- Keep internal seams private. Do not add an interface until behavior genuinely varies.
|
||||
- An Android/JVM/test adapter set is a real seam; a single implementation is not.
|
||||
- Do not hide callback explosion in an `Actions` data class. That changes syntax, not depth.
|
||||
- Do not add use-case classes or pass-through repositories around `CoreGateway`.
|
||||
- Preserve `Invitation transfer`, `Targeted transfer`, `Transfer draft`, `Saved device`, and `Device relationship` as distinct terms from `CONTEXT.md`.
|
||||
|
||||
**If verification is not possible** (no documentation tool, no network access, docs unavailable), **provide the standard or latest known dependency snippet anyway.** Add a brief comment (e.g., `// Verify latest version`) so the user isn't blocked.
|
||||
For structural work, read [references/architecture.md](references/architecture.md). Open no other reference in the same turn unless the task changes materially.
|
||||
|
||||
## Fetching Up-to-Date Documentation
|
||||
## Platform-native experience
|
||||
|
||||
When adding a new dependency, upgrading major versions, or verifying latest API patterns, use a **documentation MCP tool** (e.g., Context7) if available. Before invoking, verify the tool's exact name and parameter schema — tool names vary across environments.
|
||||
Every supported platform should feel native. Sharing implementation is a means, not the goal.
|
||||
|
||||
1. **Resolve library ID** — if the tool requires a resolution step, call it first.
|
||||
2. **Query docs** — call with the resolved ID and a specific question.
|
||||
- Prefer shared behavior and state, but allow repeated Android, Windows, and Linux presentation implementations when native interaction, layout, menus, dialogs, shortcuts, density, or system integration differ.
|
||||
- Do not force the lowest-common-denominator UI merely to maximize `commonMain` code.
|
||||
- Keep duplicated platform presentation thin and semantic; do not duplicate domain rules or transfer state machines.
|
||||
- Use `expect`/`actual`, platform source sets, or injected adapters only at genuine seams.
|
||||
- Android should follow Material interaction and navigation conventions.
|
||||
- Windows should use Fluent iconography and desktop interaction conventions.
|
||||
- Linux/Desktop should use the existing Lucide family and desktop conventions.
|
||||
|
||||
**Alternative**: Users can add `use context7` (or equivalent) to their prompt. Bundled references remain the primary source for architectural patterns and MVI guidance; use documentation tools for API-specific and version-specific queries.
|
||||
### Native icons
|
||||
|
||||
## Core Architecture: MVI or MVVM
|
||||
- Use semantic `AppIcon` values rendered through `PlatformIcon`.
|
||||
- Android resolves Material icons, Windows resolves Fluent icons, and Linux/Desktop resolves Lucide icons.
|
||||
- When adding an icon, provide the appropriate resource for every supported family. Do not reuse one platform's asset everywhere because it is convenient.
|
||||
- Prefer the native system icon or platform icon family when a platform exposes a stronger convention. A platform-specific implementation is acceptable.
|
||||
- Give actionable icons a localized content description; decorative icons use `null`.
|
||||
- Do not inline arbitrary Material icons or hard-code drawable selection in feature composables.
|
||||
|
||||
Both MVI and MVVM use **unidirectional data flow**: UI renders state → user acts → ViewModel updates state → UI re-renders. The difference is how UI actions reach the ViewModel.
|
||||
For platform-specific UI decisions, read [references/platform-native-ui.md](references/platform-native-ui.md). Open no other reference in the same turn unless the task changes materially.
|
||||
|
||||
- **MVI**: `sealed interface Event` + single `onEvent()` entry point
|
||||
- **MVVM**: Named public functions (`onTitleChanged()`, `save()`)
|
||||
## VniDrop transfer invariants
|
||||
|
||||
Both patterns use:
|
||||
- **State** — immutable data class that fully describes the screen, owned via `StateFlow`
|
||||
- **Effect** — one-shot commands (navigate, snackbar, share) delivered via `Channel`
|
||||
- Invitation transfer and Targeted transfer may share a Transfer draft implementation, but never erase their distinct destination, authorization, lifecycle, or result types.
|
||||
- Public Targeted operations remain transfer-ID-only. Never expose authorization material to Kotlin.
|
||||
- A saved-device display name resolves as local label, then authenticated remote display name, then a localized unnamed fallback. Endpoint ID is secondary diagnostic identity.
|
||||
- Android folder sharing expands a SAF tree into file descriptors plus safe relative names. Never pass an Android directory FD to Rust.
|
||||
- Desktop may pass filesystem directories marked as directories for Rust traversal.
|
||||
- Platform source adapters keep descriptors and leases alive for the complete core call and close them exactly once.
|
||||
- App-owned picker copies are released on replacement, removal, explicit dismissal, or successful creation. Never delete original user sources.
|
||||
- Picker cancellation and creation failure preserve the current valid Transfer draft.
|
||||
|
||||
**Default recommendation:** Preserve the project's existing pattern when it is coherent. For new projects, choose based on team preference and screen complexity. See [Architecture & State Management](references/architecture.md) for the decision guide, then [mvi.md](references/mvi.md) or [mvvm.md](references/mvvm.md) for implementation details.
|
||||
### Transfer draft architecture
|
||||
|
||||
### UI Rendering Boundary
|
||||
Use one deep, session-scoped composition module for Invitation and Targeted creation:
|
||||
|
||||
These boundaries apply to both MVI and MVVM:
|
||||
- Concrete MVVM module with `TransferDraftState`, named methods, and semantic outputs.
|
||||
- Domain-specific `openInvitation` and `openTargeted`; Targeted receiver is locked for the session.
|
||||
- Routes invoke file/folder picker adapters and navigate from semantic creation results.
|
||||
- The module owns selection, opaque source IDs, automatic-name provenance, validation, retry, single-flight submission, destination revalidation, and temporary-copy lifecycle.
|
||||
- One private platform source-adapter seam serves Android, JVM, and tests.
|
||||
- Multiple files or one folder; do not add mixed file-plus-folder drafts without an explicit product decision.
|
||||
- Targeted mode omits Invitation sender-name and access-policy controls.
|
||||
- Operational failure preserves the draft; successful creation emits the correct domain identity for the host to open.
|
||||
|
||||
- **Route** composable: obtains ViewModel, collects state via `collectAsStateWithLifecycle()`, collects effects via `CollectEffect` (see [compose-essentials.md](references/compose-essentials.md)), binds navigation/snackbar/platform APIs
|
||||
- **Screen** composable: stateless renderer — receives state and callbacks (MVI: `onEvent`, MVVM: individual callbacks), renders the screen, adapts callbacks for leaf composables
|
||||
- **Leaf** composables: render sub-state, emit specific callbacks, keep only tiny visual-local state (focus, scroll, animation)
|
||||
## UI system
|
||||
|
||||
## Decision Heuristics
|
||||
- Use `LocalVniDropColors` and `VniDropThemeTokens`; do not hard-code product colors.
|
||||
- Use existing `WindowClass`, `LocalUiPlatform`, `contentWindowClassFor`, and shell/navigation helpers.
|
||||
- Prefer semantic feature modules over generic visual abstractions.
|
||||
- Keep stable keys for device and transfer lists.
|
||||
- Preserve minimum touch targets, keyboard access, focus order, readable contrast, and meaningful semantics.
|
||||
- Treat phone, tablet/rail, Windows desktop, and Linux desktop as deliberate presentations—not scaled copies.
|
||||
|
||||
- Composable functions render state and emit events, never decide business rules
|
||||
- If a value can be derived from state, do not store it redundantly unless async/persistence/performance justifies it
|
||||
- Event handling in the ViewModel owns state transitions; composables do not mutate state
|
||||
- UI-local state is acceptable only for ephemeral visual concerns: focus, scroll, animation progress, expansion toggles
|
||||
- Do not push animation-only flags into global screen state unless business logic depends on them
|
||||
- Pass the narrowest possible state to leaf composables
|
||||
- MVI: implement `onEvent()` as the single entry point; MVVM: implement named functions for user actions
|
||||
- Do not introduce a use case for every repository call
|
||||
- Cross-platform sharing prioritizes business logic and presentation state before platform behavior
|
||||
- Least recomposition is achieved by state shape and read boundaries first, Compose APIs second
|
||||
- When a project has an existing MVI base class or pattern, use it — don't introduce a competing abstraction
|
||||
### Visual maturity
|
||||
|
||||
## State Modeling
|
||||
Build quiet, intentional product interfaces. Establish hierarchy with typography, alignment, spacing, and native controls before adding containers or decoration.
|
||||
|
||||
For calculator/form screens, split state into four buckets:
|
||||
- Give each screen one clear primary task and scanning order.
|
||||
- Use title-only headers for familiar, populated screens. Put explanatory copy in genuine empty/onboarding states or beside the specific control that needs clarification.
|
||||
- Use cards only when a real object or boundary needs containment. Prefer native lists, grouped rows, dividers, and whitespace for ordinary collections.
|
||||
- Use count badges only when the count changes a decision. Use icon tiles only when the icon is meaningful content or a native convention.
|
||||
- Keep accent color scarce. Let status, selection, or the primary action earn it.
|
||||
- Keep utility screens concise. Explanatory copy must resolve a real ambiguity; headings and helper panels are not filler.
|
||||
- Preserve platform density: touch-friendly Material surfaces on Android and restrained, information-dense desktop layouts on Windows/Linux.
|
||||
- Compare the result with the app's strongest nearby screen and the affected platform's native conventions. A prototype is input, not a visual specification to copy literally.
|
||||
- Treat repeated rounded cards, pills, icon-in-a-square decoration, equal-weight sections, oversized headings, and generic dashboard layouts as signals to simplify.
|
||||
|
||||
1. **Editable input** — raw text and choice values as the user edits them
|
||||
2. **Derived display/business** — parsed, validated, calculated values
|
||||
3. **Persisted domain snapshot** — saved entity for dirty tracking or reset
|
||||
4. **Transient UI-only** — purely visual, not business-significant
|
||||
## Rendered-app visual QA
|
||||
|
||||
| Concern | Where | Example |
|
||||
|---|---|---|
|
||||
| Raw field text | `state` fields | `"12"`, `"12."`, `""` |
|
||||
| Parsed/derived | `state` computed props or fields | `val hasRequiredFields: Boolean` |
|
||||
| Validation | `state.validationErrors` or similar | `mapOf("name" to "Required")` |
|
||||
| Loading/refresh | `state` flags | `isSaving = true` |
|
||||
| One-off UI commands | `Effect` via Channel | snackbar, navigate, share |
|
||||
| Scroll/focus/animation | local Compose state | `LazyListState`, focus requester |
|
||||
A visible UI change is incomplete until the actual app has been launched and inspected. Unit tests, Compose tests, previews, and successful compilation do not replace this gate.
|
||||
|
||||
## Recommended Defaults
|
||||
1. Build and launch the real affected host from the repository's current Make/Gradle tasks.
|
||||
2. Navigate to the changed screen through the product UI. Exercise the changed interaction rather than stopping at app launch.
|
||||
3. Inspect realistic content, including long names, empty/content states, busy or pending actions, and destructive confirmations when affected.
|
||||
4. Capture a screenshot of every affected presentation and inspect hierarchy, density, alignment, clipping, contrast, native iconography, focus/touch targets, and awkward unused space.
|
||||
5. Fix visible defects and repeat the same route. Complete the gate only after the new screenshot is materially acceptable.
|
||||
|
||||
Apply these unless the project already follows a different coherent pattern.
|
||||
Choose hosts by changed source set:
|
||||
|
||||
| Concern | Default |
|
||||
|---|---|
|
||||
| ViewModel | One ViewModel per screen (`commonMain` for CMP, feature package for Android-only). MVI: `onEvent(Event)` entry point; MVVM: named functions |
|
||||
| State source of truth | `StateFlow<FeatureState>` owned by the ViewModel |
|
||||
| Event handling | MVI: `onEvent(event)` with `when` expression; MVVM: named functions. Both map user actions to state updates, effect emissions, and async launches |
|
||||
| Side effects | `Effect` sent via `Channel<Effect>(Channel.BUFFERED)` for UI-consumed one-shots (navigate, snackbar). Async work (network, persistence) launched in `viewModelScope` |
|
||||
| Async loading | Keep previous content, flip loading flag, cancel outdated jobs, update state on completion |
|
||||
| Dumb UI contract | Render props, emit explicit callbacks, keep only ephemeral visual state local |
|
||||
| Resource access | Semantic keys/enums in state; resolve strings/icons close to UI. CMP uses `Res.string` / `Res.drawable` (not Android `R`). See [Resources](references/resources.md) |
|
||||
| Platform separation | CMP: share in `commonMain`, `expect/actual` (verify Kotlin 1.9 vs 2.0+ via `build.gradle.kts` or ask user) or interfaces, Koin DI by default. Android-only: standard package, Hilt or Koin DI |
|
||||
| Navigation | ViewModel emits semantic navigation effect; route/navigation layer executes it |
|
||||
| Persistence (settings) | DataStore Preferences in `commonMain` for key-value settings; Typed DataStore (JSON) for structured settings objects; Room for relational/queried data. See [DataStore](references/datastore.md) |
|
||||
| Testing | ViewModel event→state→effect tests via Turbine in `commonTest`; validators/calculators tested as pure functions; platform bindings tested per target |
|
||||
- `commonMain` visual changes: inspect an Android phone emulator and a desktop window when the UI has a desktop/adaptive branch.
|
||||
- `androidMain`: inspect an Android emulator at the affected form factor.
|
||||
- `jvmMain`: inspect the affected desktop presentation; verify Windows/Linux-specific conventions where those hosts are available.
|
||||
- Logic-only ViewModel/model changes with no rendered difference may omit screenshots, but still require behavior tests.
|
||||
|
||||
## Do / Don't Quick Reference
|
||||
Use available simulator or computer-control tools to operate the app and view the rendered result. Prefer screenshots from the running app over isolated previews. If an affected platform cannot be launched, report that exact validation gap and do not claim the UI is visually complete.
|
||||
|
||||
### Do
|
||||
Apple UI lives under `apple/` and requires a native SwiftUI workflow with iOS/macOS simulator inspection. This Compose skill does not validate Apple presentation.
|
||||
|
||||
- Model raw editable text separately from parsed values
|
||||
- Keep state immutable and equality-friendly
|
||||
- Reuse unchanged nested objects when possible
|
||||
- Emit semantic effects instead of making platform calls from event handling
|
||||
- Preserve old content during refresh
|
||||
- Map domain data to UI state close to the presentation boundary
|
||||
- Use feature-specific ViewModel names
|
||||
- Key list items by stable domain ID
|
||||
- Import all types and functions at the top of the file; use `import ... as ...` aliases to resolve name clashes
|
||||
- Guard no-op state emissions (don't update state if nothing changed)
|
||||
- Respect the project's existing MVI conventions
|
||||
## Strings and resources
|
||||
|
||||
### Don't
|
||||
- `localization/strings.json` is the only source of truth for product strings.
|
||||
- Run the localization generator after editing it.
|
||||
- Never hand-edit generated Compose XML, Apple catalogs, or accessors.
|
||||
- Use `Res.string.*` in `commonMain`; never Android `R` there.
|
||||
- Do not synthesize English product copy in ViewModels, including automatic transfer names.
|
||||
- Resolve semantic strings near presentation or inject a small formatter when behavior requires localized text.
|
||||
|
||||
- Parse numbers in composable bodies
|
||||
- Run network requests from composables
|
||||
- Store `MutableState`, controllers, lambdas, or platform objects in screen state
|
||||
- Encode snackbar/navigation as "consume once" booleans in state — use effects
|
||||
- Keep every minor visual toggle in the ViewModel state
|
||||
- Pass entire state to every child composable
|
||||
- Wrap every repository call in a use case class
|
||||
- Wipe the screen with a full-screen spinner during refresh
|
||||
- Force-migrate a working codebase to a different architecture or base class
|
||||
- Use fully qualified package paths inline (e.g., `com.example.pkg.SomeClass.method()`) — always import at file top
|
||||
## Dependencies
|
||||
|
||||
## Detailed References
|
||||
- Prefer existing dependencies and platform facilities.
|
||||
- Before adding AndroidX/Jetpack to `commonMain`, verify coordinates, exact API shape, and every required KMP target using official documentation or artifact metadata.
|
||||
- If verification is unavailable, stop and report the uncertainty. Do not add an unverified production dependency with a “check later” comment.
|
||||
- Do not add navigation, DI, persistence, networking, or image-loading frameworks unless the requested feature proves the need.
|
||||
|
||||
**Do not load reference files for basic Compose usage.** If you already know how to build the required UI or logic, write the code immediately. **Load exactly one reference file only when the task involves advanced concepts** (e.g., Paging 3, Nav 3 setup). Pick the right file below — do not load files speculatively.
|
||||
## Testing
|
||||
|
||||
### Quick Routing
|
||||
- `commonTest`: state machines and feature behavior through the module interface; use focused fakes.
|
||||
- `jvmTest`: Compose interaction, semantics, keyboard behavior, and adaptive phone/desktop presentation.
|
||||
- Platform tests: Android SAF/MediaStore and desktop filesystem/native integration.
|
||||
- Test complete states where relevant: empty, loading, content, busy, error, confirmation, interruption, and terminal outcomes.
|
||||
- Test both Invitation and Targeted modes through the shared Transfer draft interface.
|
||||
- Assert native icon-family selection and accessibility semantics when adding platform actions.
|
||||
- Prefer deterministic gates and virtual time; avoid fixed sleeps.
|
||||
- Delete obsolete shallow tests after equivalent interface-level coverage exists.
|
||||
- Record which real hosts and screen states were visually inspected in the handoff.
|
||||
|
||||
- **Recomposition too frequent, stability, or Compose Compiler Metrics** → [performance.md](references/performance.md)
|
||||
- **Channel vs SharedFlow, Flow operators, structured concurrency, or exception handling** → [coroutines-flow.md](references/coroutines-flow.md)
|
||||
- **Backpressure, callbackFlow, Mutex/Semaphore, or Turbine testing** → [coroutines-flow-advanced.md](references/coroutines-flow-advanced.md)
|
||||
- **Nav 3 routes, tabs, scenes, deep links, or back stack patterns** → [navigation-3.md](references/navigation-3.md)
|
||||
- **Nav 2 NavHost, tabs, deep links, nested graphs, or animations** → [navigation-2.md](references/navigation-2.md)
|
||||
- **Wiring Hilt or Koin with navigation** → [navigation-3-di.md](references/navigation-3-di.md) or [navigation-2-di.md](references/navigation-2-di.md) based on version
|
||||
- **Migrating from Nav 2 to Nav 3** → [navigation-migration.md](references/navigation-migration.md)
|
||||
- **Paging 3 setup, PagingSource, filters, LoadState, or transformations** → [paging.md](references/paging.md)
|
||||
- **Offline-first paging with Room and RemoteMediator** → [paging-offline.md](references/paging-offline.md)
|
||||
- **Paging MVI integration, paging tests, or paging anti-patterns** → [paging-mvi-testing.md](references/paging-mvi-testing.md)
|
||||
- **Ktor client setup, plugins, DTOs, API service, or repository pattern** → [networking-ktor.md](references/networking-ktor.md)
|
||||
- **Auth (bearer), WebSockets, or SSE** → [networking-ktor-auth.md](references/networking-ktor-auth.md)
|
||||
- **Network layer architecture, plugin composition, or error handling strategy** → [networking-ktor-architecture.md](references/networking-ktor-architecture.md)
|
||||
- **Choosing Hilt vs Koin** → [dependency-injection.md](references/dependency-injection.md) first, then the chosen framework's file
|
||||
- **Accessibility audit, semantics, touch targets, or WCAG contrast** → [accessibility.md](references/accessibility.md)
|
||||
- **Animation API selection (animate*AsState, Animatable, transitions, AnimatedVisibility)** → [animations.md](references/animations.md)
|
||||
- **Shared element transitions, gesture-driven animations, Canvas, or graphicsLayer** → [animations-advanced.md](references/animations-advanced.md)
|
||||
- **Code review or anti-pattern detection** → [anti-patterns.md](references/anti-patterns.md) first, then domain-specific files as needed
|
||||
- **Exposing Kotlin to Swift, SKIE, or Flow→AsyncSequence** → [ios-swift-interop.md](references/ios-swift-interop.md)
|
||||
- **ViewModel pipeline, state modeling, domain layer, or inter-feature communication** → [architecture.md](references/architecture.md)
|
||||
- **MVI pipeline, Event/State/Effect, onEvent pattern, or effect delivery** → [mvi.md](references/mvi.md)
|
||||
- **MVVM pipeline, ViewModel named functions, or direct-callback UI wiring** → [mvvm.md](references/mvvm.md)
|
||||
- **File organization, naming conventions, or disciplined screen architecture** → [clean-code.md](references/clean-code.md)
|
||||
- **Three phases, state primitives, side effects, or modifiers** → [compose-essentials.md](references/compose-essentials.md)
|
||||
- **M3 theme, dynamic color, M3 components, or adaptive layouts** → [material-design.md](references/material-design.md)
|
||||
- **AsyncImage, image cache, SVG, or Coil 3** → [image-loading.md](references/image-loading.md)
|
||||
- **LazyColumn, LazyRow, keys, grids, pager, or scroll state** → [lists-grids.md](references/lists-grids.md)
|
||||
- **Nav 2 vs Nav 3 decision or MVI navigation rules** → [navigation.md](references/navigation.md)
|
||||
- **Loading states, skeleton/shimmer, or inline validation UX** → [ui-ux.md](references/ui-ux.md)
|
||||
- **Turbine, ViewModel tests, Macrobenchmark, or lean test matrix** → [testing.md](references/testing.md)
|
||||
- **DataStore Preferences, Typed DataStore, or KMP DataStore** → [datastore.md](references/datastore.md)
|
||||
- **Room entities, DAOs, migrations, relationships, or Room MVI integration** → [room-database.md](references/room-database.md)
|
||||
- **Ktor `@Resource` routes or type-safe API definitions** → [networking-ktor.md](references/networking-ktor.md) § Type-Safe Resources
|
||||
- **MockEngine, network testing, or Koin/Hilt network DI** → [networking-ktor-testing.md](references/networking-ktor-testing.md)
|
||||
- **Koin CMP setup, Nav 3 Koin integration, or scoped modules** → [koin.md](references/koin.md)
|
||||
- **Hilt Android setup, @HiltViewModel, scopes, or Hilt testing** → [hilt.md](references/hilt.md)
|
||||
- **commonMain sharing, expect/actual, or platform bridges** → [cross-platform.md](references/cross-platform.md)
|
||||
- **CMP Res class, qualifiers, localization, or Android resource interop** → [resources.md](references/resources.md)
|
||||
- **AGP 9+, version catalog, convention plugins, or composite builds** → [gradle-build.md](references/gradle-build.md)
|
||||
- **GitHub Actions CI/CD, desktop packaging, signing, or notarization** → [ci-cd-distribution.md](references/ci-cd-distribution.md)
|
||||
## Anti-patterns
|
||||
|
||||
## Validation
|
||||
|
||||
Run `./scripts/validate.sh` to scan the skill package against the [agentskills.io spec](https://agentskills.io/specification). It checks token budgets, broken links, file structure, and content quality. Fix any errors before committing.
|
||||
- Business rules, core calls, ticket parsing, or filesystem work in composables.
|
||||
- A global mutable draft shared by Send and Saved devices.
|
||||
- Payload bytes streamed through Kotlin.
|
||||
- Android directory FDs.
|
||||
- Raw endpoint IDs as primary saved-device names.
|
||||
- Generic `onEvent`, forced MVI, Hilt/Koin migrations, or use-case-per-method architecture.
|
||||
- One universal icon set or one platform's interaction model imposed on every platform.
|
||||
- Duplicated domain behavior justified as “native UI.” Only presentation duplication is acceptable.
|
||||
- Hand-edited generated localization outputs.
|
||||
- New generic wrappers whose deletion merely moves calls to the caller.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
interface:
|
||||
display_name: "compose-skill"
|
||||
short_description: "AI agent skill for Jetpack Compose and Compose Multiplatform — architecture, state, navigation, DI, performance, cross-platform, and code review"
|
||||
default_prompt: "Use $compose-skill to build, refactor, or review Compose/CMP features, adapting to the project's architecture (MVI recommended for new work), and verifying dependencies before recommending them."
|
||||
display_name: "VniDrop KMP UI"
|
||||
short_description: "Build and visually verify native VniDrop UI"
|
||||
default_prompt: "Use $compose-skill to design, implement, launch, and visually verify a native-feeling VniDrop KMP UI feature."
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
# Accessibility
|
||||
|
||||
## Content Descriptions
|
||||
|
||||
Every `Image` and `Icon` composable must have an explicit `contentDescription`:
|
||||
|
||||
- **Decorative** (no information conveyed): `contentDescription = null`
|
||||
- **Meaningful** (conveys information): localized string via `stringResource()`
|
||||
|
||||
```kotlin
|
||||
// Decorative — purely visual, screen reader skips it
|
||||
Icon(Icons.Default.Star, contentDescription = null)
|
||||
|
||||
// Meaningful — screen reader announces it
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.profile_avatar),
|
||||
contentDescription = stringResource(Res.string.user_avatar_description)
|
||||
)
|
||||
```
|
||||
|
||||
Flag any `Image` with a non-obvious resource name and `contentDescription = null` that lacks a comment explaining why it is decorative.
|
||||
|
||||
## Semantics API
|
||||
|
||||
Use `Modifier.semantics { }` to add or override accessibility information.
|
||||
|
||||
| Property | Purpose | Example values |
|
||||
|---|---|---|
|
||||
| `contentDescription` | Override screen reader announcement | `"Profile picture of $name"` |
|
||||
| `role` | Declare interactive role | `Role.Button`, `Role.Image`, `Role.Switch`, `Role.Tab`, `Role.RadioButton`, `Role.Checkbox` |
|
||||
| `stateDescription` | Describe current state | `"Expanded"`, `"Selected"`, `"3 of 5"` |
|
||||
| `heading` | Mark as section heading | `heading()` |
|
||||
|
||||
```kotlin
|
||||
Box(
|
||||
modifier = Modifier.semantics {
|
||||
contentDescription = "Profile picture of ${user.name}"
|
||||
role = Role.Image
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Prefer built-in Material components (`Button`, `Switch`, `Checkbox`) over manual `role` assignment — they include correct semantics automatically.
|
||||
|
||||
## Grouping and Overriding Semantics
|
||||
|
||||
### mergeDescendants
|
||||
|
||||
Groups a composable's children into a single screen reader announcement. Use when children together form one logical unit.
|
||||
|
||||
```kotlin
|
||||
// GOOD — screen reader announces "4.5 stars, 128 reviews" as one item
|
||||
Row(modifier = Modifier.semantics(mergeDescendants = true) { }) {
|
||||
Icon(Icons.Default.Star, contentDescription = null)
|
||||
Text("4.5 stars")
|
||||
Text("(128 reviews)")
|
||||
}
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// BAD — screen reader stops on each child separately, fragmenting the meaning
|
||||
Row {
|
||||
Icon(Icons.Default.Star, contentDescription = "Star icon")
|
||||
Text("4.5 stars")
|
||||
Text("(128 reviews)")
|
||||
}
|
||||
```
|
||||
|
||||
### clearAndSetSemantics
|
||||
|
||||
Replaces all auto-generated and child semantics with a single custom description. Use when the auto-generated text is verbose or misleading.
|
||||
|
||||
```kotlin
|
||||
Row(modifier = Modifier.clearAndSetSemantics {
|
||||
contentDescription = "Rating: 4.5 stars from 128 reviews"
|
||||
}) {
|
||||
StarRating(4.5f)
|
||||
Text("(128 reviews)")
|
||||
}
|
||||
```
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| Group children into one announcement, keep their text | `semantics(mergeDescendants = true)` |
|
||||
| Replace all child semantics with a custom string | `clearAndSetSemantics { }` |
|
||||
|
||||
## Touch Targets
|
||||
|
||||
Minimum interactive size: **48 x 48 dp**.
|
||||
|
||||
- Use `Modifier.minimumInteractiveComponentSize()` on custom interactive elements to enforce this automatically.
|
||||
- Material components (`Button`, `IconButton`, `Switch`, etc.) handle this internally — do not add redundant padding.
|
||||
|
||||
```kotlin
|
||||
// Custom clickable element — enforce minimum touch target
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.minimumInteractiveComponentSize()
|
||||
.clickable { onAction() }
|
||||
) {
|
||||
Icon(Icons.Default.Add, contentDescription = "Add item")
|
||||
}
|
||||
```
|
||||
|
||||
## Color and Contrast
|
||||
|
||||
WCAG AA minimum contrast ratios:
|
||||
|
||||
| Text type | Minimum ratio |
|
||||
|---|---|
|
||||
| Normal text (<18sp) | 4.5 : 1 |
|
||||
| Large text (18sp+ or 14sp bold+) | 3 : 1 |
|
||||
|
||||
Never use color as the **only** way to convey information. Always pair with an icon, text label, or pattern.
|
||||
|
||||
```kotlin
|
||||
// BAD — only color differentiates status
|
||||
Box(modifier = Modifier.background(if (isOnline) Color.Green else Color.Red))
|
||||
|
||||
// GOOD — icon + text + color
|
||||
Row {
|
||||
Icon(
|
||||
imageVector = if (isOnline) Icons.Default.CheckCircle else Icons.Default.Cancel,
|
||||
contentDescription = null,
|
||||
)
|
||||
Text(if (isOnline) "Online" else "Offline")
|
||||
}
|
||||
```
|
||||
|
||||
Use theme tokens (`MaterialTheme.colorScheme`) rather than hardcoded colors — theme tokens are designed to meet contrast requirements across light/dark modes.
|
||||
|
||||
## Custom Interactive Elements
|
||||
|
||||
When using `Modifier.clickable` on a non-Button composable, add semantic role and click label:
|
||||
|
||||
```kotlin
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.clickable(onClickLabel = "Open book details") { onBookClick(book.id) }
|
||||
.semantics { role = Role.Button }
|
||||
) {
|
||||
Text(book.title)
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `Button` / `IconButton` / `TextButton` over custom clickable elements when possible — they include correct semantics, touch targets, and visual feedback out of the box.
|
||||
|
||||
## Custom Accessibility Actions
|
||||
|
||||
For composables with multiple actions (e.g., a list item with favorite, share, delete), expose named accessibility actions so screen reader users can discover and invoke them without navigating to individual buttons:
|
||||
|
||||
```kotlin
|
||||
Modifier.semantics {
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction("Add to favorites") { onFavorite(); true },
|
||||
CustomAccessibilityAction("Share") { onShare(); true },
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The lambda returns `true` if the action was handled successfully.
|
||||
|
||||
## MVI Integration
|
||||
|
||||
Accessibility does not change the MVI architecture. Key placement rules:
|
||||
|
||||
| Concern | Where | Why |
|
||||
|---|---|---|
|
||||
| Semantic descriptions (`contentDescription`, `stateDescription`) | Screen / Leaf composables | These are UI-layer concerns — resolve from state close to rendering |
|
||||
| Semantic keys/enums for dynamic descriptions | `State` data class | e.g., `statusLabel: StringKey` — the UI resolves to a localized string |
|
||||
| `Modifier.semantics` | Composable `modifier` chains | Applied in the UI layer, never in ViewModel |
|
||||
| Accessibility-triggered actions (e.g., custom action callbacks) | `onEvent` callbacks → ViewModel | Same as any user interaction — goes through the event pipeline |
|
||||
|
||||
Keep accessibility descriptions in the **UI layer**, not in state. State holds semantic keys (enums, string resource keys); the Screen/Leaf composable resolves them to localized strings via `stringResource()`.
|
||||
|
||||
## Do / Don't
|
||||
|
||||
### Do
|
||||
|
||||
- Provide `contentDescription` for every meaningful `Image` and `Icon`
|
||||
- Use `mergeDescendants` for logically grouped content
|
||||
- Use `clearAndSetSemantics` when auto-generated text is misleading
|
||||
- Enforce 48dp minimum touch targets on custom interactive elements
|
||||
- Pair color with icons/text for status indicators
|
||||
- Use `MaterialTheme.colorScheme` tokens for contrast-safe colors
|
||||
- Test with a screen reader on each target platform
|
||||
|
||||
### Don't
|
||||
|
||||
- Leave `contentDescription = null` on meaningful images without a comment
|
||||
- Apply `role` manually when a Material component already provides it
|
||||
- Add extra padding on Material components that already meet touch target requirements
|
||||
- Rely on color alone to communicate state changes
|
||||
- Put localized accessibility strings in ViewModel state — use semantic keys and resolve in UI
|
||||
- Hardcode accessibility text — use `stringResource()` for localization
|
||||
@@ -1,238 +0,0 @@
|
||||
# Animations — Advanced Patterns
|
||||
|
||||
Shared element transitions, gesture-driven animations, Canvas drawing, and graphicsLayer optimization. For core animation APIs (animate*AsState, Animatable, updateTransition, AnimatedVisibility, AnimatedContent, AnimationSpec) and the animation API decision table, see [animations.md](animations.md).
|
||||
|
||||
## Shared Element Transitions
|
||||
|
||||
Seamless transitions between composables that share visual content (e.g., list item -> detail screen). Available in both Jetpack Compose and Compose Multiplatform (since CMP 1.7+).
|
||||
|
||||
### Core setup
|
||||
|
||||
```kotlin
|
||||
SharedTransitionLayout {
|
||||
AnimatedContent(showDetails, label = "shared") { targetState ->
|
||||
if (!targetState) {
|
||||
ListItem(
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this@AnimatedContent,
|
||||
)
|
||||
} else {
|
||||
DetailScreen(
|
||||
sharedTransitionScope = this@SharedTransitionLayout,
|
||||
animatedVisibilityScope = this@AnimatedContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### sharedElement vs sharedBounds
|
||||
|
||||
| | `sharedElement` | `sharedBounds` |
|
||||
|---|---|---|
|
||||
| Content | Same content in both states | Visually different content |
|
||||
| Rendering | Only target content rendered during transition | Both entering and exiting content visible |
|
||||
| Use for | Hero transitions (same image/icon) | Container transforms (card -> full screen) |
|
||||
| Text | Avoid (use `sharedBounds`) | Preferred (handles font changes) |
|
||||
|
||||
### Modifier usage
|
||||
|
||||
```kotlin
|
||||
Image(
|
||||
modifier = Modifier.sharedElement(
|
||||
rememberSharedContentState(key = "image-$id"),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.sharedBounds(
|
||||
rememberSharedContentState(key = "bounds-$id"),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
enter = fadeIn(), exit = fadeOut(),
|
||||
resizeMode = SharedTransitionScope.ResizeMode.ScaleToBounds(),
|
||||
)
|
||||
)
|
||||
```
|
||||
|
||||
### Unique keys
|
||||
|
||||
```kotlin
|
||||
data class SharedElementKey(val id: Long, val origin: String, val type: SharedElementType)
|
||||
enum class SharedElementType { Bounds, Image, Title, Background }
|
||||
```
|
||||
|
||||
### Customize transitions
|
||||
|
||||
```kotlin
|
||||
Modifier.sharedElement(
|
||||
state = rememberSharedContentState(key = "image"),
|
||||
animatedVisibilityScope = scope,
|
||||
boundsTransform = BoundsTransform { initial, target ->
|
||||
keyframes {
|
||||
durationMillis = 300
|
||||
initial at 0 using ArcMode.ArcBelow using FastOutSlowInEasing
|
||||
target at 300
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
### resizeMode
|
||||
|
||||
- `ScaleToBounds()` — scales child layout graphically. Recommended for `Text`.
|
||||
- `RemeasureToBounds` — re-measures child each frame. Recommended for different aspect ratios.
|
||||
|
||||
### With Navigation
|
||||
|
||||
Wrap `NavHost` in `SharedTransitionLayout`. Pass both scopes to screens:
|
||||
|
||||
```kotlin
|
||||
SharedTransitionLayout {
|
||||
NavHost(navController, startDestination = "list") {
|
||||
composable("list") {
|
||||
ListScreen(this@SharedTransitionLayout, this@composable)
|
||||
}
|
||||
composable("detail/{id}") {
|
||||
DetailScreen(this@SharedTransitionLayout, this@composable)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Async images (Coil)
|
||||
|
||||
For full Coil 3 guidance (API choice, caching strategy, SVG, and CMP resource loading), see [Image Loading](image-loading.md).
|
||||
|
||||
```kotlin
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalPlatformContext.current)
|
||||
.data(url)
|
||||
.placeholderMemoryCacheKey("image-$id")
|
||||
.memoryCacheKey("image-$id")
|
||||
.build(),
|
||||
modifier = Modifier.sharedElement(
|
||||
rememberSharedContentState(key = "image-$id"),
|
||||
animatedVisibilityScope = scope,
|
||||
),
|
||||
)
|
||||
```
|
||||
|
||||
### Overlays and clipping
|
||||
|
||||
- `renderInSharedTransitionScopeOverlay()` — keep elements (bottom bar, FAB) on top during transition
|
||||
- `clipInOverlayDuringTransition` — clip shared element to parent bounds
|
||||
- `skipToLookaheadSize()` — prevent text reflow during size transitions
|
||||
|
||||
### Modifier order
|
||||
|
||||
Size modifiers AFTER `sharedElement()`. Inconsistent modifier order between matched elements causes visual jumps.
|
||||
|
||||
## Gesture-Driven Animations
|
||||
|
||||
### Tap to animate
|
||||
|
||||
```kotlin
|
||||
val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().pointerInput(Unit) {
|
||||
coroutineScope {
|
||||
while (true) {
|
||||
awaitPointerEventScope {
|
||||
val position = awaitFirstDown().position
|
||||
launch { offset.animateTo(position) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}) {
|
||||
Circle(modifier = Modifier.offset { offset.value.toIntOffset() })
|
||||
}
|
||||
```
|
||||
|
||||
Interruption: tapping during animation cancels current and starts new, maintaining velocity.
|
||||
|
||||
### Swipe to dismiss
|
||||
|
||||
```kotlin
|
||||
fun Modifier.swipeToDismiss(onDismissed: () -> Unit) = composed {
|
||||
val offsetX = remember { Animatable(0f) }
|
||||
pointerInput(Unit) {
|
||||
val decay = splineBasedDecay<Float>(this)
|
||||
coroutineScope {
|
||||
while (true) {
|
||||
val velocityTracker = VelocityTracker()
|
||||
offsetX.stop()
|
||||
awaitPointerEventScope {
|
||||
val pointerId = awaitFirstDown().id
|
||||
horizontalDrag(pointerId) { change ->
|
||||
launch { offsetX.snapTo(offsetX.value + change.positionChange().x) }
|
||||
velocityTracker.addPosition(change.uptimeMillis, change.position)
|
||||
}
|
||||
}
|
||||
val velocity = velocityTracker.calculateVelocity().x
|
||||
val targetOffsetX = decay.calculateTargetValue(offsetX.value, velocity)
|
||||
offsetX.updateBounds(-size.width.toFloat(), size.width.toFloat())
|
||||
launch {
|
||||
if (targetOffsetX.absoluteValue <= size.width) {
|
||||
offsetX.animateTo(0f, initialVelocity = velocity)
|
||||
} else {
|
||||
offsetX.animateDecay(velocity, decay)
|
||||
onDismissed()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.offset { IntOffset(offsetX.value.roundToInt(), 0) }
|
||||
}
|
||||
```
|
||||
|
||||
Key patterns: `snapTo` during drag (sync with finger), `animateDecay` for fling, `animateTo(0f)` for snap-back, `VelocityTracker` for fling velocity.
|
||||
|
||||
## Canvas and Custom Drawing
|
||||
|
||||
### Canvas composable
|
||||
|
||||
```kotlin
|
||||
Canvas(modifier = Modifier.fillMaxSize()) {
|
||||
drawCircle(color = Color.Blue, radius = 100f, center = center)
|
||||
drawRect(color = Color.Red, topLeft = Offset(50f, 50f), size = Size(200f, 200f))
|
||||
drawLine(Color.Green, start = Offset.Zero, end = Offset(size.width, size.height), strokeWidth = 4f)
|
||||
}
|
||||
```
|
||||
|
||||
### Drawing modifiers
|
||||
|
||||
`Modifier.drawBehind { }` draws behind child content; `Modifier.drawWithContent { drawContent(); … }` draws over or around it.
|
||||
|
||||
### Animate canvas content
|
||||
|
||||
```kotlin
|
||||
val progress by animateFloatAsState(if (active) 1f else 0f, label = "progress")
|
||||
Canvas(Modifier.size(200.dp)) {
|
||||
drawArc(Color.Blue, startAngle = -90f, sweepAngle = 360f * progress, useCenter = false, style = Stroke(8.dp.toPx()))
|
||||
}
|
||||
```
|
||||
|
||||
Canvas draws in the Drawing phase — no recomposition needed for visual updates.
|
||||
|
||||
## graphicsLayer for Efficient Animation
|
||||
|
||||
`graphicsLayer` transforms at the Drawing phase level, avoiding recomposition entirely:
|
||||
|
||||
```kotlin
|
||||
Box(modifier = Modifier.graphicsLayer {
|
||||
scaleX = animatedScale.value
|
||||
rotationZ = animatedRotation.value
|
||||
alpha = animatedAlpha.value
|
||||
translationX = animatedOffset.value
|
||||
shadowElevation = animatedElevation.value.toPx()
|
||||
})
|
||||
```
|
||||
|
||||
```kotlin
|
||||
// BAD: recomposes every frame
|
||||
Box(Modifier.scale(scaleX))
|
||||
|
||||
// GOOD: transforms in draw phase
|
||||
Box(Modifier.graphicsLayer { scaleX = animatedScale.value })
|
||||
```
|
||||
@@ -1,191 +0,0 @@
|
||||
# Animations
|
||||
|
||||
animate*AsState, Animatable, updateTransition, AnimatedVisibility, AnimatedContent, and AnimationSpec patterns. Works on all CMP targets. For shared element transitions, gesture-driven motion, and graphicsLayer, see [animations-advanced.md](animations-advanced.md).
|
||||
|
||||
References:
|
||||
- [Choose an animation API (Android)](https://developer.android.com/develop/ui/compose/animation/choose-api)
|
||||
- [Quick guide (Android)](https://developer.android.com/develop/ui/compose/animation/quick-guide)
|
||||
|
||||
## MVI Rules for Animation State
|
||||
|
||||
- Animation state is **local UI state** — keep in composables, not reducers
|
||||
- Reducer state = business/UI meaning, not visual tween progress
|
||||
- Never put `buttonBounceProgress`, `errorShakeCounter`, `skeletonAlpha`, `rowRemovalAnimationPhase` in ViewModel state
|
||||
|
||||
## Choosing the Right API
|
||||
|
||||
| Question | API |
|
||||
|---|---|
|
||||
| SVG/icon animation? | `AnimatedVectorDrawable` (Android), Lottie/Compottie (CMP) |
|
||||
| Infinite repeat? | `rememberInfiniteTransition` |
|
||||
| Switching composables? | `AnimatedContent` or `Crossfade` |
|
||||
| Appear/disappear? | `AnimatedVisibility` |
|
||||
| Size change? | `Modifier.animateContentSize()` |
|
||||
| Multiple props together? | `updateTransition` |
|
||||
| Different timing per prop? | `Animatable` with sequential `animateTo` |
|
||||
| Single prop with target? | `animate*AsState` |
|
||||
| Gesture-driven? | `Animatable` with `animateTo`/`snapTo` |
|
||||
| List item insert/remove/reorder? | `Modifier.animateItem()` |
|
||||
|
||||
## AnimationSpec Reference
|
||||
|
||||
| Spec | When to use | Key detail |
|
||||
|---|---|---|
|
||||
| `spring` (default) | General purpose, interruption-safe | Maintains velocity on target change; `dampingRatio` (bounciness), `stiffness` (speed) |
|
||||
| `tween` | Need exact duration control | `durationMillis`, `delayMillis`, `easing` (`FastOutSlowInEasing`, `LinearEasing`, etc.) |
|
||||
| `keyframes` | Specific values at timestamps | `value at millis using easing` |
|
||||
| `keyframesWithSplines` | Smooth 2D curved paths | `Offset at fraction` |
|
||||
| `repeatable` / `infiniteRepeatable` | Looping | `iterations`, `repeatMode` (Reverse/Restart) |
|
||||
| `snap` | Instant jump | Optional `delayMillis` |
|
||||
|
||||
**Prefer `spring`** — handles interruption smoothly. `tween` snaps to a new curve on interruption, which feels jarring.
|
||||
|
||||
## animate*AsState — Single Value
|
||||
|
||||
```kotlin
|
||||
val alpha by animateFloatAsState(if (enabled) 1f else 0.5f, label = "alpha")
|
||||
val color by animateColorAsState(if (selected) Color.Blue else Color.Gray, label = "color")
|
||||
val padding by animateDpAsState(if (expanded) 16.dp else 0.dp, label = "padding")
|
||||
val offset by animateIntOffsetAsState(if (moved) IntOffset(100, 100) else IntOffset.Zero, label = "offset")
|
||||
```
|
||||
|
||||
Available types: `Float`, `Color`, `Dp`, `Size`, `Offset`, `Rect`, `Int`, `IntOffset`, `IntSize`. Custom types via `animateValueAsState` with `TwoWayConverter`.
|
||||
|
||||
**Performance tips:**
|
||||
- `Modifier.drawBehind { drawRect(animatedColor) }` is more performant than `Modifier.background()` for animated colors
|
||||
- `Modifier.graphicsLayer { scaleX = scale; scaleY = scale }` for transforms — Drawing phase only
|
||||
- Set `textMotion = TextMotion.Animated` for smooth text scale transitions
|
||||
|
||||
## Animatable — Coroutine-Based Control
|
||||
|
||||
```kotlin
|
||||
val offset = remember { Animatable(Offset.Zero, Offset.VectorConverter) }
|
||||
|
||||
LaunchedEffect(targetPosition) { offset.animateTo(targetPosition) }
|
||||
Box(Modifier.offset { offset.value.toIntOffset() })
|
||||
```
|
||||
|
||||
| Operation | Purpose |
|
||||
|---|---|
|
||||
| `animateTo(target)` | Animate to target (suspends) |
|
||||
| `snapTo(value)` | Instant set (gesture sync) |
|
||||
| `animateDecay(velocity, decay)` | Fling deceleration |
|
||||
| `stop()` | Cancel animation |
|
||||
| `updateBounds(lower, upper)` | Constrain range |
|
||||
|
||||
```kotlin
|
||||
// Sequential
|
||||
LaunchedEffect(Unit) {
|
||||
alphaAnim.animateTo(1f)
|
||||
yAnim.animateTo(100f)
|
||||
}
|
||||
|
||||
// Concurrent
|
||||
LaunchedEffect(Unit) {
|
||||
launch { alphaAnim.animateTo(1f) }
|
||||
launch { yAnim.animateTo(100f) }
|
||||
}
|
||||
```
|
||||
|
||||
New `animateTo` cancels ongoing animation and continues from current value/velocity — no jumpiness.
|
||||
|
||||
## updateTransition — Multi-Property State Machine
|
||||
|
||||
```kotlin
|
||||
enum class CardState { Collapsed, Expanded }
|
||||
|
||||
val transition = updateTransition(cardState, label = "card")
|
||||
val size by transition.animateDp(label = "size") { state ->
|
||||
when (state) { CardState.Collapsed -> 64.dp; CardState.Expanded -> 128.dp }
|
||||
}
|
||||
val color by transition.animateColor(label = "color") { state ->
|
||||
when (state) { CardState.Collapsed -> Color.Gray; CardState.Expanded -> Color.Red }
|
||||
}
|
||||
```
|
||||
|
||||
Per-transition timing: `transitionSpec = { when { Expanded isTransitioningTo Collapsed -> spring(stiffness = 50f); else -> tween(500) } }`.
|
||||
|
||||
Start immediately: `MutableTransitionState(Collapsed).apply { targetState = Expanded }`.
|
||||
|
||||
Coordinated children: `transition.AnimatedVisibility(visible = { it == Expanded }) { ... }` and `transition.AnimatedContent { ... }`.
|
||||
|
||||
## rememberInfiniteTransition
|
||||
|
||||
Shimmer, pulsing indicators, loading spinners:
|
||||
|
||||
```kotlin
|
||||
val infiniteTransition = rememberInfiniteTransition(label = "infinite")
|
||||
val alpha by infiniteTransition.animateFloat(
|
||||
initialValue = 0.3f, targetValue = 1f,
|
||||
animationSpec = infiniteRepeatable(tween(800), RepeatMode.Reverse),
|
||||
label = "alpha",
|
||||
)
|
||||
```
|
||||
|
||||
## AnimatedVisibility
|
||||
|
||||
```kotlin
|
||||
AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = fadeIn() + slideInVertically { -40.dp.roundToPx() },
|
||||
exit = slideOutVertically() + fadeOut(),
|
||||
) { Text("Hello") }
|
||||
```
|
||||
|
||||
| Enter | Exit |
|
||||
|---|---|
|
||||
| `fadeIn` | `fadeOut` |
|
||||
| `slideIn` / `slideInHorizontally` / `slideInVertically` | `slideOut` / `slideOutHorizontally` / `slideOutVertically` |
|
||||
| `scaleIn` | `scaleOut` |
|
||||
| `expandIn` / `expandHorizontally` / `expandVertically` | `shrinkOut` / `shrinkHorizontally` / `shrinkVertically` |
|
||||
|
||||
Combine with `+`. Per-child: `Modifier.animateEnterExit(enter = ..., exit = ...)`. Use `EnterTransition.None`/`ExitTransition.None` on parent to let children define their own.
|
||||
|
||||
## AnimatedContent
|
||||
|
||||
```kotlin
|
||||
AnimatedContent(
|
||||
targetState = uiState,
|
||||
transitionSpec = {
|
||||
if (targetState > initialState)
|
||||
slideInVertically { it } + fadeIn() togetherWith slideOutVertically { -it } + fadeOut()
|
||||
else
|
||||
slideInVertically { -it } + fadeIn() togetherWith slideOutVertically { it } + fadeOut()
|
||||
using SizeTransform(clip = false)
|
||||
},
|
||||
label = "content",
|
||||
) { target ->
|
||||
when (target) {
|
||||
UiState.Loading -> LoadingScreen()
|
||||
UiState.Success -> SuccessScreen()
|
||||
UiState.Error -> ErrorScreen()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`SizeTransform` controls size animation between states. Always use the lambda parameter (`target`), not the outer variable.
|
||||
|
||||
## Performance Rules
|
||||
|
||||
- `spring` as default — handles interruption, physically natural
|
||||
- `Modifier.offset { }` (lambda) defers to Layout phase
|
||||
- `graphicsLayer { }` for visual transforms — Drawing phase only, cheapest
|
||||
- `drawBehind` for animated colors instead of `background()`
|
||||
- `animateContentSize` BEFORE size modifiers in chain
|
||||
- In `AnimatedContent`/`AnimatedVisibility`: use lambda parameter, not outer variable
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why | Fix |
|
||||
|---|---|---|
|
||||
| Animation state in ViewModel | Pollutes business state | Local `animate*AsState` or `Animatable` |
|
||||
| `Modifier.scale()`/`.offset()` | Recomposition every frame | `graphicsLayer { scaleX = ...; translationX = ... }` |
|
||||
| Animating every change | Jittery UI | Animate meaningful transitions only |
|
||||
| `animateContentSize` after size modifiers | No effect | Place BEFORE `size`/`fillMaxWidth` |
|
||||
| Outer variable in AnimatedContent | Stale during exit | Use lambda parameter |
|
||||
| `tween`/`snap` everywhere | Jarring interruption | Prefer `spring` |
|
||||
| Animating padding/size every frame | Expensive Layout phase | Prefer `graphicsLayer` transforms |
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
For shared element transitions, gesture-driven animations, Canvas, and graphicsLayer optimization, see [animations-advanced.md](animations-advanced.md).
|
||||
@@ -1,109 +0,0 @@
|
||||
# Anti-Patterns
|
||||
|
||||
Quick-reference table of cross-cutting patterns that hurt MVI Compose Multiplatform codebases. Domain-specific anti-patterns (navigation, networking, paging, DI, etc.) live in their respective reference files — see the "Detailed in" column.
|
||||
|
||||
For overengineering patterns (bloated base classes, unnecessary use cases, 4-type MVI), see [clean-code.md](clean-code.md).
|
||||
|
||||
## Cross-Cutting Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it is harmful | Better replacement | Detailed in |
|
||||
|---|---|---|---|
|
||||
| Business logic inside composables | forks source of truth, hurts testability, reruns during composition | move logic into ViewModel/domain services | [architecture.md](architecture.md) |
|
||||
| Giant god-ViewModel | blast radius too large, slow reasoning, hard ownership | one ViewModel per screen or independent flow | [architecture.md](architecture.md) |
|
||||
| Scattered `updateState`/`sendEffect` with no structure | state transitions hard to trace, mutations across callbacks | disciplined `onEvent()` as single entry point | [clean-code.md](clean-code.md) |
|
||||
| Unstable state models (mutable collections, lambdas in state) | defeats Compose skipping, more recomposition | immutable data classes, immutable collections | [performance.md](performance.md) |
|
||||
| Duplicated derived data (`total`, `formattedTotal`, `hasTotal` all stored) | bugs from drift, harder transitions | keep canonical value + derive via computed property | [architecture.md](architecture.md) |
|
||||
| Broad state reads in parent composables | recomposition cascades to all children | slice state, pass only required props to each child | [performance.md](performance.md) |
|
||||
| Mutable state passed deep into tree | hidden writes, unpredictable data flow | explicit props + callbacks | [compose-essentials.md](compose-essentials.md) |
|
||||
| One-off events stored as consumable state (`showSnackbarOnce = true`) | event replay on config change, stale effects | separate `Effect` via `Channel` | [architecture.md](architecture.md) |
|
||||
| No-op state emissions (copy state when nothing changed) | wasted recomposition cycles | guard unchanged values before updating | [performance.md](performance.md) |
|
||||
| Full-screen loading wipes existing content | bad UX, layout jumps, lost user trust | keep old content + inline refresh indicator | [ui-ux.md](ui-ux.md) |
|
||||
| ViewModel doing platform work directly (share, analytics, navigation) | breaks testability, platform coupling | emit effects, handle in Route composable | [architecture.md](architecture.md) |
|
||||
| Animation state in ViewModel for no reason (`shakeCount`, `alpha`) | pollutes business state | local composable animation state | [animations.md](animations.md) |
|
||||
| Display strings stored too early (ViewModel emits pre-baked formatted text) | locale inflexibility, state duplication, harder reuse | keep canonical values until presentation boundary | [architecture.md](architecture.md) |
|
||||
| Poor lazy list keys (no key or index-based) | state jumps between rows, broken animations | stable key by domain ID | [lists-grids.md](lists-grids.md) |
|
||||
| Too many trivial composables (wrappers around single `Text`/`Spacer`) | fragmentation, harder reading | extract only meaningful boundaries | [clean-code.md](clean-code.md) |
|
||||
| Platform abstraction too early (interfaces for everything before pain) | unnecessary indirection, poor fit | share business logic first, abstract real platform capabilities only | [cross-platform.md](cross-platform.md) |
|
||||
| Forcing MVI migration on existing codebase | churn without value, team friction | respect existing patterns, introduce MVI for new features only | [clean-code.md](clean-code.md) |
|
||||
| Inline fully qualified package paths | hurts readability, clutters business logic, hides intent behind package noise | import at file top; use `import ... as ...` for name clashes | [clean-code.md](clean-code.md) |
|
||||
|
||||
## Examples
|
||||
|
||||
### Business logic inside composables
|
||||
|
||||
```kotlin
|
||||
// BAD — logic in composable; untestable, reruns on every recomposition
|
||||
@Composable
|
||||
fun CheckoutScreen(viewModel: CheckoutViewModel) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val total = state.items.sumOf { it.price * it.qty } // business logic here
|
||||
val tax = total * 0.08
|
||||
Text("Total: $${"$"}total Tax: $${"$"}tax")
|
||||
}
|
||||
|
||||
// GOOD — derive in ViewModel/state, composable only renders
|
||||
data class CheckoutState(
|
||||
val items: List<LineItem> = emptyList(),
|
||||
val total: Double = 0.0,
|
||||
val tax: Double = 0.0,
|
||||
)
|
||||
|
||||
@Composable
|
||||
fun CheckoutScreen(state: CheckoutState, onEvent: (CheckoutEvent) -> Unit) {
|
||||
Text("Total: ${state.total} Tax: ${state.tax}")
|
||||
}
|
||||
```
|
||||
|
||||
### One-off events as consumable state booleans
|
||||
|
||||
```kotlin
|
||||
// BAD — event replays on config change, race between read and reset
|
||||
data class UiState(val showSnackbar: Boolean = false)
|
||||
|
||||
LaunchedEffect(state.showSnackbar) {
|
||||
if (state.showSnackbar) {
|
||||
snackbarHostState.showSnackbar("Saved")
|
||||
viewModel.onEvent(DismissSnackbar) // consumer must remember to reset
|
||||
}
|
||||
}
|
||||
|
||||
// GOOD — Channel delivers exactly once, survives config change
|
||||
sealed interface Effect { data class ShowSnackbar(val msg: String) : Effect }
|
||||
|
||||
CollectEffect(viewModel.effects) { effect ->
|
||||
when (effect) {
|
||||
is Effect.ShowSnackbar -> snackbarHostState.showSnackbar(effect.msg)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Domain-Specific Anti-Patterns
|
||||
|
||||
These reference files contain their own anti-pattern sections with detailed BAD/GOOD code examples:
|
||||
|
||||
| Domain | Reference | What it covers |
|
||||
|---|---|---|
|
||||
| Architecture & MVI | [architecture.md](architecture.md) | event handling, state modeling, effect misuse, domain layer violations |
|
||||
| Overengineering | [clean-code.md](clean-code.md) | bloated base classes, 4-type MVI, use case wrappers, naming |
|
||||
| Coroutines & Flow | [coroutines-flow.md](coroutines-flow.md) | GlobalScope, blocking dispatchers, unbound scopes, stateIn misuse |
|
||||
| Performance | [performance.md](performance.md) | recomposition, stability, state shape, read boundaries |
|
||||
| Compose Essentials | [compose-essentials.md](compose-essentials.md) | side effects, modifier ordering, CompositionLocal |
|
||||
| Animations | [animations.md](animations.md) | ViewModel animation state, graphicsLayer misuse, over-animating |
|
||||
| Lists & Grids | [lists-grids.md](lists-grids.md) | keys, nested scrolling, contentType |
|
||||
| UI/UX | [ui-ux.md](ui-ux.md) | disappearing content, layout jumps, loading states |
|
||||
| Navigation (shared) | [navigation.md](navigation.md) | MVI navigation rules, anti-patterns for both Nav 2 and Nav 3 |
|
||||
| Paging | [paging-mvi-testing.md](paging-mvi-testing.md) | PagingData in UiState, key misuse, LoadState handling |
|
||||
| Networking | [networking-ktor-testing.md](networking-ktor-testing.md) | MockEngine, DI integration, testing anti-patterns |
|
||||
| Network Architecture | [networking-ktor-architecture.md](networking-ktor-architecture.md) | plugin composition, error strategy, client lifecycle, result wrapper choice |
|
||||
| Room Database | [room-database.md](room-database.md) | entity design, DAO patterns, migrations |
|
||||
| DataStore | [datastore.md](datastore.md) | singleton enforcement, blocking reads, corruption |
|
||||
| DI (Koin) | [koin.md](koin.md) | module organization, scoping, ViewModel injection |
|
||||
| DI (Hilt) | [hilt.md](hilt.md) | module structure, scoping, testing |
|
||||
| Image Loading | [image-loading.md](image-loading.md) | cache policy, transformations, placeholder usage |
|
||||
| Testing | [testing.md](testing.md) | missing ViewModel tests, mocking DI, testing internals |
|
||||
| Cross-Platform | [cross-platform.md](cross-platform.md) | expect/actual misuse, premature abstraction |
|
||||
| iOS Interop | [ios-swift-interop.md](ios-swift-interop.md) | naming, nullability, Flow bridging |
|
||||
| Resources | [resources.md](resources.md) | Android R vs CMP Res, qualifier usage |
|
||||
| Material Design | [material-design.md](material-design.md) | theme setup, component choice, adaptive layouts |
|
||||
| Accessibility | [accessibility.md](accessibility.md) | missing semantics, touch targets, contrast |
|
||||
| Gradle & Build | [gradle-build.md](gradle-build.md) | hardcoded versions, buildSrc, convention plugin timing |
|
||||
@@ -1,204 +1,100 @@
|
||||
# Architecture & State Management
|
||||
# VniDrop presentation architecture
|
||||
|
||||
Shared architecture concepts for MVI and MVVM. Load first for architecture questions, then see [mvi.md](mvi.md) or [mvvm.md](mvvm.md) for pattern-specific details.
|
||||
Use for feature structure, state ownership, deepening, or ViewModel/UI seams.
|
||||
|
||||
Preservation rule: if the project already has a coherent screen architecture pattern (MVI, MVVM, or variant), preserve it unless the user explicitly asks to migrate or the current pattern cannot satisfy a required constraint.
|
||||
|
||||
## Source of Truth
|
||||
|
||||
Per screen:
|
||||
|
||||
- **Screen behavior:** `StateFlow<ScreenState>` owned by the screen state holder, often a ViewModel
|
||||
- **Persisted data:** repository / database / remote service
|
||||
- **Local visual-only concerns:** local Compose state in the route or leaf composable
|
||||
|
||||
Do not mix them.
|
||||
|
||||
## Choosing a State Owner
|
||||
|
||||
| Situation | Default owner | Why |
|
||||
|---|---|---|
|
||||
| Visual state for one composable subtree | Local Compose state | Smallest scope, easiest reuse |
|
||||
| Complex UI logic, no business/data responsibilities | Plain state holder class | Testable without ViewModel |
|
||||
| Screen-level business rules, async, persistence, effects | ViewModel | Lifecycle integration, screen state ownership |
|
||||
|
||||
A ViewModel is one implementation of a screen state holder, not a requirement for every composable.
|
||||
|
||||
## MVI vs MVVM Decision Guide
|
||||
|
||||
Both use unidirectional data flow with `StateFlow<State>` and `Channel<Effect>`. The difference is how UI actions reach the ViewModel.
|
||||
|
||||
| Criterion | MVI | MVVM |
|
||||
|---|---|---|
|
||||
| UI-to-VM contract | `sealed interface Event` + `onEvent()` | Named public functions |
|
||||
| Boilerplate | Higher (sealed class + when) | Lower (direct calls) |
|
||||
| Testing input | Single `onEvent()` entry point | Multiple function entry points |
|
||||
| Best for | Many events, event logging, analytics | Simpler screens, less ceremony |
|
||||
|
||||
**Choose MVI when:** project uses MVI, many user actions to enumerate, need exhaustive event contracts.
|
||||
**Choose MVVM when:** project uses MVVM, few actions, team prefers direct function calls.
|
||||
**Default:** preserve the project's existing pattern.
|
||||
|
||||
## When to Use Lighter Patterns
|
||||
|
||||
- Purely presentational leaf composables
|
||||
- Small screens with trivial local state and no async/persistence
|
||||
- Prototypes unless user asks to formalize
|
||||
- Do not invent reducers, result types, or global frameworks unless they earn their keep
|
||||
|
||||
## Domain Layer
|
||||
|
||||
Pure business logic. Zero platform dependencies — runs in `commonTest` without emulators.
|
||||
|
||||
| Rule | Rationale |
|
||||
|---|---|
|
||||
| Zero platform imports | Testable anywhere, shareable |
|
||||
| Domain models ≠ DTOs or entities | Decouples from API/DB schema |
|
||||
| Repository interfaces in domain, impls in data | Dependency inversion |
|
||||
| Mappers at data boundary | Domain ignores serialization (see [networking-ktor.md](networking-ktor.md)) |
|
||||
| Use cases only for multi-step orchestration | Don't wrap single repo calls |
|
||||
|
||||
```kotlin
|
||||
data class Item(val id: String, val name: String, val status: ItemStatus)
|
||||
|
||||
interface ItemRepository {
|
||||
suspend fun getById(id: String): Item?
|
||||
suspend fun save(item: Item)
|
||||
}
|
||||
|
||||
class CreateItemUseCase(private val repository: ItemRepository, private val validator: ItemValidator) {
|
||||
suspend operator fun invoke(name: String, status: ItemStatus): Result<Item> {
|
||||
val errors = validator.validate(name)
|
||||
if (errors.isNotEmpty()) return Result.failure(ValidationException(errors))
|
||||
val item = Item(id = uuid(), name = name.trim(), status = status)
|
||||
repository.save(item)
|
||||
return Result.success(item)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Inter-Feature Communication
|
||||
|
||||
| Need | Pattern | Why |
|
||||
|---|---|---|
|
||||
| React to event from another feature | Event bus (`SharedFlow`) | Fire-and-forget, many listeners |
|
||||
| Navigate to another feature | Feature API contract (`:api` module) | Type-safe, no impl dependency |
|
||||
| Pass data back | Feature API + callback | Structured return, testable |
|
||||
| Shared data stream (current user) | Shared repository in `core` | Persistent state, not one-shot |
|
||||
|
||||
**Anti-patterns:** importing another feature's ViewModel, global "god event bus" with 50 events, cross-feature data via `CompositionLocal`.
|
||||
|
||||
For the full api/impl split pattern, see [navigation-3-di.md](navigation-3-di.md) Modularization section.
|
||||
|
||||
## Module Dependency Rules
|
||||
## Module shape
|
||||
|
||||
```text
|
||||
app -> feature:*:impl, feature:*:api, core:*
|
||||
feature:*:impl -> feature:*:api (any feature), core:*
|
||||
feature:*:api -> core:designsystem (route types only)
|
||||
core:data -> core:network, core:database, core:datastore
|
||||
Route
|
||||
├─ collects StateFlow
|
||||
├─ invokes platform adapters
|
||||
├─ collects semantic effects
|
||||
└─ navigates
|
||||
↓
|
||||
Deep presentation module
|
||||
├─ immutable state
|
||||
├─ named methods
|
||||
├─ domain orchestration
|
||||
└─ internal seams
|
||||
↓
|
||||
CoreGateway + platform adapters
|
||||
```
|
||||
|
||||
| Forbidden | Why |
|
||||
| Concern | Owner |
|
||||
|---|---|
|
||||
| `feature:impl` → another `feature:impl` | Circular risk |
|
||||
| `feature:api` → any `feature` | API contracts must be leaf dependencies |
|
||||
| `core:*` → `feature:*` or `app` | Core cannot depend on consumers |
|
||||
| Domain → Data layer | Domain declares interfaces, data implements |
|
||||
| Domain-facing presentation state | Feature ViewModel/module |
|
||||
| Platform picker invocation | Route |
|
||||
| Navigation | Route/app navigation |
|
||||
| Transfer creation and durable state | Rust through `CoreGateway` |
|
||||
| Source preparation | Android/JVM adapter |
|
||||
| Rendering | Screen and leaf composables |
|
||||
| One-shot feedback | Existing effect/`UiMessageController` pattern |
|
||||
|
||||
## State Modeling for Forms and Calculators
|
||||
## Depth checks
|
||||
|
||||
Split into four buckets:
|
||||
- Apply the deletion test: deleting a deep module must spread meaningful behavior back across multiple callers.
|
||||
- The interface is the test surface. If tests need private state, reshape the module.
|
||||
- Keep adapters internal unless callers genuinely select them.
|
||||
- Prefer a concrete class when only one implementation exists.
|
||||
- Do not create an `Actions` bag to conceal a wide interface.
|
||||
- Do not split by arbitrary file size when the pieces still share one interface and invariant set.
|
||||
|
||||
1. **Editable input** — raw text/choice values as the user edits
|
||||
2. **Derived/computed** — parsed, validated, calculated values
|
||||
3. **Persisted snapshot** — existing saved entity for dirty tracking
|
||||
4. **Transient UI-only** — only when purely visual and not business-significant
|
||||
## State rules
|
||||
|
||||
| Concern | Where | Example |
|
||||
|---|---|---|
|
||||
| Raw field text | `state` | `"12"`, `"12."`, `""` |
|
||||
| Parsed value | computed property or `state` | `val amount get() = amountText.toDoubleOrNull()` |
|
||||
| Validation | `state.errors` | `mapOf("area" to "Required")` |
|
||||
| Calculated totals | `state` or computed | subtotal, tax |
|
||||
| Loading/refresh | `state` flags | `isSaving`, `isLoading` |
|
||||
| One-off commands | `Effect` via Channel | snackbar, navigate |
|
||||
| Scroll/focus/animation | local Compose state | `LazyListState`, expansion toggle |
|
||||
- Make states immutable and equality-friendly.
|
||||
- Store durable/product-significant state in the module; keep focus, scroll, animation, and transient expansion local to Compose.
|
||||
- Derive values instead of storing duplicates.
|
||||
- Track provenance when derivation must stop after user editing, such as automatic Transfer draft names.
|
||||
- Model loading without discarding valid content.
|
||||
- Freeze invalid concurrent operations explicitly in state.
|
||||
|
||||
Use computed properties for trivial derivations:
|
||||
## Transfer draft seam
|
||||
|
||||
The external seam is a session-scoped MVVM module used by both callers.
|
||||
|
||||
```kotlin
|
||||
data class CreateItemState(
|
||||
val title: String = "",
|
||||
val amount: String = "",
|
||||
val isSaving: Boolean = false,
|
||||
val errors: Map<String, String> = emptyMap()
|
||||
) {
|
||||
val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
|
||||
val hasErrors: Boolean get() = errors.isNotEmpty()
|
||||
class TransferDraftViewModel(...) : ViewModel() {
|
||||
val state: StateFlow<TransferDraftState>
|
||||
val outputs: Flow<TransferDraftOutput>
|
||||
|
||||
fun openInvitation(defaultSenderName: String)
|
||||
fun openTargeted(device: LockedSavedDevice)
|
||||
fun chooseFiles()
|
||||
fun chooseFolder()
|
||||
fun onPickerResult(requestId: Long, result: Result<List<PickedShareFile>>)
|
||||
fun changeTransferName(value: String)
|
||||
fun removeSource(id: DraftSourceId)
|
||||
fun submit()
|
||||
fun dismiss()
|
||||
}
|
||||
```
|
||||
|
||||
**Avoid duplicated state:** don't store `total` + `formattedTotal` + `totalText`, or `showErrorDialog` + `pendingError` when one implies the other.
|
||||
The exact implementation may evolve, but preserve these invariants:
|
||||
|
||||
## Where Logic Belongs
|
||||
- One immutable destination per open session.
|
||||
- One in-flight picker and submission.
|
||||
- Correlate picker callbacks; discard stale owned copies.
|
||||
- Atomic source replacement.
|
||||
- Failure preserves the draft.
|
||||
- Success/dismissal releases owned copies exactly once.
|
||||
- Semantic creation output; no navigation inside the module.
|
||||
- Invitation and Targeted results remain distinct.
|
||||
|
||||
| Logic | Where |
|
||||
## Migration order
|
||||
|
||||
1. Extract Invitation composition and interface-level tests.
|
||||
2. Switch Targeted creation to the same module.
|
||||
3. Delete the one-shot Saved-device picker flow.
|
||||
4. Promote Saved devices into its own route.
|
||||
5. Add Targeted transfer detail and lifecycle presentation.
|
||||
6. Remove the experimental UI gate atomically after parity.
|
||||
|
||||
## Anti-patterns
|
||||
|
||||
| Anti-pattern | Better replacement |
|
||||
|---|---|
|
||||
| Validation | ViewModel/domain — never in composable body |
|
||||
| Calculations | Pure calculator/domain service called by ViewModel |
|
||||
| Async orchestration | ViewModel — launch/cancel, debounce, ignore stale |
|
||||
| Side effects | ViewModel via `Effect` or `viewModelScope.launch` |
|
||||
| Local UI state | Composable — `LazyListState`, focus, animation, expansion, tooltip |
|
||||
|
||||
Not acceptable in composables: validation, derived totals, data loading, submit enablement, business decisions.
|
||||
|
||||
## Effect Delivery
|
||||
|
||||
`Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()` — default for single-consumer effects. Buffers for reliable delivery, single consumer, no replay. `SharedFlow(replay=0)` acceptable for truly fire-and-forget signals. Preserve existing `SharedFlow` effect mechanism when consistent.
|
||||
|
||||
## Reactive Data Collection
|
||||
|
||||
```kotlin
|
||||
private fun collectData() {
|
||||
viewModelScope.launch {
|
||||
repository.observe()
|
||||
.catch { sendEffect(ShowError(it.message ?: "Load failed")) }
|
||||
.collect { data -> updateState { copy(items = data, isLoading = false) } }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Room and DataStore `Flow` queries auto-re-emit on changes. Map data-layer types to domain models at the repository boundary.
|
||||
|
||||
## State Collection and Slicing
|
||||
|
||||
**Default:** collect whole screen state once at the route boundary, slice downward.
|
||||
|
||||
- `Route` collects `StateFlow<ScreenState>`
|
||||
- `Screen` receives `ScreenState`
|
||||
- Leaves receive **only what they need**
|
||||
- Do **not** make leaves observe the ViewModel directly
|
||||
|
||||
### Callbacks at Boundaries
|
||||
|
||||
- MVI: `onEvent(Event)` at route/screen boundary; leaves prefer specific callbacks
|
||||
- MVVM: individual callbacks at screen boundary; same narrowing for leaves
|
||||
- Reusable components must not know your event contract or ViewModel type
|
||||
|
||||
## Adapting to Existing Projects
|
||||
|
||||
| Project has | Action |
|
||||
|---|---|
|
||||
| MVI with base class (`MviHost`, `BaseViewModel`) | Use it. Don't introduce competing base. See [mvi.md](mvi.md) |
|
||||
| MVVM without strict MVI | Preserve it. Match conventions. See [mvvm.md](mvvm.md) |
|
||||
| Plain state holder classes | Valid. Only move to ViewModel when screen needs async/persistence/lifecycle |
|
||||
| 4-type MVI (Event, Result, State, Effect) | Use `Result` as project expects. Don't strip out |
|
||||
| No architecture | Choose MVI or MVVM per guide above. Trivial screens: local state is fine |
|
||||
|
||||
## Scaling Notes
|
||||
|
||||
- Small screens: one file for contract + ViewModel
|
||||
- Medium: split contract, ViewModel, screen, route
|
||||
- Large: extract calculation, validation, formatting into dedicated collaborators
|
||||
- Do **not** create nested state holders for every card/section by default — only when independent lifecycle, async, tests, and real reuse justify it
|
||||
| Parent ViewModels duplicate source rules | One Transfer draft module |
|
||||
| `FileSystemService` creates transfers | Source adapter prepares; module calls `CoreGateway` |
|
||||
| Generic transfer type erases domain | Explicit Invitation/Targeted variants |
|
||||
| Route contains state machine | Route binds adapters and navigation only |
|
||||
| Tests mock internals | Test through module state/methods/outputs |
|
||||
|
||||
@@ -1,290 +0,0 @@
|
||||
# CI/CD & Distribution
|
||||
|
||||
CI/CD and native distribution for Compose Multiplatform: Android, Desktop (JVM), and iOS.
|
||||
|
||||
## 1. Distribution Overview
|
||||
|
||||
| Platform | Output | Gradle Task | Notes |
|
||||
|----------|--------|-------------|-------|
|
||||
| Android | APK/AAB | `assembleRelease`/`bundleRelease` | Standard distribution |
|
||||
| Desktop macOS | DMG | `packageDmg` | Needs signing for Gatekeeper |
|
||||
| Desktop Windows | MSI | `packageMsi` | Optional signing |
|
||||
| Desktop Linux | DEB | `packageDeb` | Package manager format |
|
||||
| iOS | .app/.ipa | Xcode Archive | Gradle builds framework only |
|
||||
|
||||
## 2. GitHub Actions — Android
|
||||
|
||||
```yaml
|
||||
name: Android Build
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with:
|
||||
java-version: '21'
|
||||
distribution: 'temurin'
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
|
||||
- run: ./gradlew :androidApp:assembleRelease
|
||||
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: android-apk
|
||||
path: androidApp/build/outputs/apk/release/*.apk
|
||||
```
|
||||
|
||||
### With Signing
|
||||
|
||||
```yaml
|
||||
- name: Decode Keystore
|
||||
run: echo "${{ secrets.KEYSTORE_BASE64 }}" | base64 --decode > release.keystore
|
||||
|
||||
- run: ./gradlew :androidApp:assembleRelease
|
||||
env:
|
||||
KEYSTORE_PASSWORD: ${{ secrets.KEYSTORE_PASSWORD }}
|
||||
KEY_ALIAS: ${{ secrets.KEY_ALIAS }}
|
||||
KEY_PASSWORD: ${{ secrets.KEY_PASSWORD }}
|
||||
```
|
||||
|
||||
## 3. GitHub Actions — Desktop Multi-Platform
|
||||
|
||||
```yaml
|
||||
name: Desktop Build
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
build_macos: { type: boolean, default: true }
|
||||
build_windows: { type: boolean, default: true }
|
||||
build_linux: { type: boolean, default: true }
|
||||
|
||||
jobs:
|
||||
build-macos:
|
||||
if: ${{ inputs.build_macos }}
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with: { java-version: '21', distribution: 'temurin' }
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- run: ./gradlew :desktopApp:packageDmg
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: macos-dmg
|
||||
path: desktopApp/build/compose/binaries/main/dmg/*.dmg
|
||||
|
||||
build-windows:
|
||||
if: ${{ inputs.build_windows }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with: { java-version: '21', distribution: 'temurin' }
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- run: ./gradlew :desktopApp:packageMsi
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: windows-msi
|
||||
path: desktopApp/build/compose/binaries/main/msi/*.msi
|
||||
|
||||
build-linux:
|
||||
if: ${{ inputs.build_linux }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-java@v4
|
||||
with: { java-version: '21', distribution: 'temurin' }
|
||||
- uses: gradle/actions/setup-gradle@v4
|
||||
- run: ./gradlew :desktopApp:packageDeb
|
||||
- uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: linux-deb
|
||||
path: desktopApp/build/compose/binaries/main/deb/*.deb
|
||||
```
|
||||
|
||||
## 4. Desktop App Module
|
||||
|
||||
```kotlin
|
||||
import org.jetbrains.compose.desktop.application.dsl.TargetFormat
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvm()
|
||||
sourceSets {
|
||||
jvmMain.dependencies {
|
||||
implementation(compose.desktop.currentOs)
|
||||
implementation(projects.composeApp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
application {
|
||||
mainClass = "com.example.MainKt"
|
||||
|
||||
// Required for DataStore/serialization
|
||||
jvmArgs += listOf(
|
||||
"--add-opens", "java.base/java.lang=ALL-UNNAMED",
|
||||
"--add-opens", "java.base/sun.nio.ch=ALL-UNNAMED"
|
||||
)
|
||||
|
||||
nativeDistributions {
|
||||
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
|
||||
packageName = "MyApp"
|
||||
packageVersion = "1.0.0"
|
||||
modules("jdk.unsupported")
|
||||
|
||||
macOS {
|
||||
bundleID = "com.example.myapp"
|
||||
iconFile.set(project.file("icons/icon.icns"))
|
||||
// signing { sign.set(true); identity.set("Developer ID Application: ...") }
|
||||
}
|
||||
windows {
|
||||
iconFile.set(project.file("icons/icon.ico"))
|
||||
upgradeUuid = "YOUR-UUID" // Keep constant across versions
|
||||
}
|
||||
linux {
|
||||
iconFile.set(project.file("icons/icon.png"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 5. iOS Xcode Integration
|
||||
|
||||
iOS uses Xcode, not Gradle. Gradle builds the shared framework; Xcode embeds it.
|
||||
|
||||
### Framework in `composeApp`
|
||||
|
||||
```kotlin
|
||||
kotlin {
|
||||
listOf(iosArm64(), iosSimulatorArm64()).forEach {
|
||||
it.binaries.framework {
|
||||
baseName = "ComposeApp"
|
||||
isStatic = true // Required for App Store
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Xcode Build Phase Script
|
||||
|
||||
Add "Run Script" before "Compile Sources":
|
||||
|
||||
```bash
|
||||
cd "$SRCROOT/.."
|
||||
./gradlew :composeApp:embedAndSignAppleFrameworkForXcode
|
||||
```
|
||||
|
||||
### Swift Entry Point
|
||||
|
||||
```swift
|
||||
import SwiftUI
|
||||
import ComposeApp
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
init() { AppKt.doInitKoin() }
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ComposeViewControllerRepresentable().ignoresSafeArea()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct ComposeViewControllerRepresentable: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
MainViewControllerKt.MainViewController()
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
```
|
||||
|
||||
## 6. Signing
|
||||
|
||||
### Android
|
||||
|
||||
```kotlin
|
||||
android {
|
||||
signingConfigs {
|
||||
create("release") {
|
||||
storeFile = file("release.keystore")
|
||||
storePassword = System.getenv("KEYSTORE_PASSWORD")
|
||||
keyAlias = System.getenv("KEY_ALIAS")
|
||||
keyPassword = System.getenv("KEY_PASSWORD")
|
||||
}
|
||||
}
|
||||
buildTypes {
|
||||
release { signingConfig = signingConfigs.getByName("release") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### macOS (Direct Distribution)
|
||||
|
||||
```kotlin
|
||||
macOS {
|
||||
signing {
|
||||
sign.set(true)
|
||||
identity.set("Developer ID Application: Your Name (TEAM_ID)")
|
||||
}
|
||||
notarization {
|
||||
appleID.set("your-email@example.com")
|
||||
password.set("@keychain:AC_PASSWORD")
|
||||
teamID.set("YOUR_TEAM_ID")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### iOS
|
||||
|
||||
Handled by Xcode via `CODE_SIGN_STYLE = Automatic` and `DEVELOPMENT_TEAM`.
|
||||
|
||||
## 7. Adding Desktop to Existing CMP Project
|
||||
|
||||
1. Add `jvm()` target in `composeApp`:
|
||||
```kotlin
|
||||
kotlin {
|
||||
jvm()
|
||||
sourceSets {
|
||||
jvmMain.dependencies { /* desktop deps */ }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Add KSP for JVM: `add("kspJvm", libs.room.compiler)`
|
||||
|
||||
3. Create `desktopApp` module with `compose.desktop {}` config
|
||||
|
||||
4. Add `include(":desktopApp")` to `settings.gradle.kts`
|
||||
|
||||
## 8. Gradle Tasks
|
||||
|
||||
| Platform | Build | Package | Run |
|
||||
|----------|-------|---------|-----|
|
||||
| Android | `assembleRelease` | `bundleRelease` | — |
|
||||
| Desktop | `jvmJar` | `packageDmg`/`packageMsi`/`packageDeb` | `run` |
|
||||
| iOS | `compileKotlinIosArm64` | Xcode Archive | Xcode |
|
||||
|
||||
## 9. Troubleshooting
|
||||
|
||||
| Issue | Solution |
|
||||
|-------|----------|
|
||||
| `InaccessibleObjectException` | Add `--add-opens` JVM args |
|
||||
| "App is damaged" on macOS | Enable code signing |
|
||||
| Framework not found in Xcode | Check `FRAMEWORK_SEARCH_PATHS` |
|
||||
| Windows MSI won't upgrade | Keep `upgradeUuid` constant |
|
||||
@@ -1,210 +0,0 @@
|
||||
# Clean Code & Avoiding Overengineering
|
||||
|
||||
## Disciplined vs Bloated vs Overengineered MVI
|
||||
|
||||
### Disciplined MVI
|
||||
|
||||
One feature ViewModel, one clear state model, one `onEvent()` function, small number of effects, explicit UI contracts, shared business logic, direct feature names.
|
||||
|
||||
### Bloated MVI
|
||||
|
||||
Too many tiny sealed types, every action wrapped twice, separate mapper/presenter/handler for trivial screens, verbose generic layers with little value.
|
||||
|
||||
### Overengineered MVI
|
||||
|
||||
Generic frameworks and base abstractions replace feature code, trivial repository calls get use-case wrappers, and 4-type MVI with mandatory pure reducers appear before screens actually need them.
|
||||
|
||||
## Decision Rules
|
||||
|
||||
### When an Event sealed class is enough
|
||||
|
||||
Almost always. Use one sealed interface per feature.
|
||||
|
||||
### When event hierarchies become excessive
|
||||
|
||||
When you see: `UserEvent`, `UiEvent`, `SystemEvent`, `InternalEvent`, `ViewEvent`, `ActionEvent` — three wrappers before any feature logic — child components that need to know root feature events.
|
||||
|
||||
### When to model effects separately
|
||||
|
||||
When the action leaves the ViewModel's state-management scope: network, persistence, delay/debounce, navigation, snackbar, haptics, share, analytics. Do **not** create an effect for plain synchronous state changes.
|
||||
|
||||
### When you need a Result/PartialState type (4th type)
|
||||
|
||||
Rarely. Consider it only when: the same state transition is triggered by many different sources (events, async completions, WebSocket messages, push notifications) and you want to centralize all transitions in one pure function. For most screens, `onEvent()` handling state updates directly is simpler and more readable.
|
||||
|
||||
### When a generic base ViewModel helps
|
||||
|
||||
When you have 10+ features and the boilerplate of `MutableStateFlow` + `Channel` + `onEvent()` is genuinely repetitive. A thin base class or interface that provides `updateState()`, `sendEffect()`, and `currentState` is fine. A base class that forces `handleEvent()` + `reduce()` + `dispatch()` + `asyncAction()` is overengineering unless the entire team has agreed on it.
|
||||
|
||||
### When a screen should have a dedicated ViewModel
|
||||
|
||||
When the screen has: async data, multi-field editing, validation, derived calculations, navigation effects, retry/refresh flow, persistent draft/original comparison.
|
||||
|
||||
### When a lighter state holder is enough
|
||||
|
||||
For purely visual tab selection, local expansion, local scroll affordance, tooltip/menu visibility. That is local UI state, not architecture.
|
||||
|
||||
### When to extract reusable UI
|
||||
|
||||
When the component has real reuse, a stable API, and a meaningful visual/behavioral boundary. Examples: `MoneyField`, `ResultCard`, `ValidationMessage`, `SettingsToggleRow`.
|
||||
|
||||
### When not to extract
|
||||
|
||||
Do not extract: one-line wrappers around `Text`, wrappers that only forward modifiers, components "reusable" in theory but used once, components whose props are harder to understand than the inline code.
|
||||
|
||||
### When a use case is useful
|
||||
|
||||
When logic is multi-step, reused, policy-heavy, test-worthy on its own, and not just repository pass-through.
|
||||
|
||||
### When a use case is ceremony
|
||||
|
||||
```kotlin
|
||||
class GetSettingsUseCase(private val repository: SettingsRepository) {
|
||||
suspend operator fun invoke() = repository.getSettings()
|
||||
}
|
||||
```
|
||||
|
||||
That is usually ceremony.
|
||||
|
||||
## Comparison Table
|
||||
|
||||
| Area | Good architecture | Overengineering |
|
||||
|---|---|---|
|
||||
| ViewModel | `ProductViewModel` with `onEvent()` | `BaseMviViewModel<State, Intent, Effect, Result>` with `handleEvent()` + `reduce()` |
|
||||
| Events | one feature sealed interface | multi-layer intent taxonomy |
|
||||
| State updates | inline `updateState { copy(...) }` in `onEvent()` | separate `Result` type + pure `reduce()` function for simple screens |
|
||||
| Effects | only for impure one-shot actions | effects for trivial synchronous transitions |
|
||||
| UI | route + dumb screen + meaningful leaves | every row has its own ViewModel/presenter |
|
||||
| Use cases | used for real domain logic | one wrapper per repository call |
|
||||
| Modules | feature-first (see Module Dependency Rules in architecture.md for multi-module arrows) | giant "domain/data/presentation" package islands |
|
||||
| Platform abstractions | introduced when needed | abstracted preemptively everywhere |
|
||||
| Navigation | semantic effect + route binding | global command bus + abstract navigator hierarchy |
|
||||
| Naming | `ProductState`, `ProductEvent` | `FeatureContract.State`, `FeatureContract.Action` |
|
||||
|
||||
### Feature-first organization
|
||||
|
||||
**Default:** organize by feature first, then by internal layers only when needed.
|
||||
|
||||
Good:
|
||||
|
||||
```text
|
||||
feature-product/
|
||||
domain/
|
||||
data/
|
||||
presentation/
|
||||
ui/
|
||||
```
|
||||
|
||||
Bad:
|
||||
|
||||
```text
|
||||
presentation/
|
||||
product/
|
||||
settings/
|
||||
history/
|
||||
domain/
|
||||
product/
|
||||
settings/
|
||||
history/
|
||||
data/
|
||||
product/
|
||||
settings/
|
||||
history/
|
||||
```
|
||||
|
||||
The second form becomes a horizontal maze fast.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Concept | Recommended | Avoid |
|
||||
|---|---|---|
|
||||
| Event | `ProductEvent` | `ProductActionEventIntent` |
|
||||
| State | `ProductState` | `ProductViewState`, `Contract.State` |
|
||||
| Effect | `ProductEffect` | `ProductCommandEffectSideEffect`, `SingleLiveEvent` |
|
||||
| Contract file | `ProductContract.kt` | separate files per type for small screens |
|
||||
| ViewModel | `ProductViewModel` | `BaseProductViewModel` |
|
||||
| Route | `ProductRoute` | `ProductContainerFragmentLikeThing` |
|
||||
| Screen | `ProductScreen` | `ProductView` |
|
||||
| Leaf component | `ResultCard`, `ProductForm` | `ProductFormWidgetComponentView` |
|
||||
|
||||
## Import Hygiene
|
||||
|
||||
**Strict rule:** never write fully qualified package paths inline. Always import at the top of the file. Use `import ... as ...` with a descriptive alias when two types share the same simple name.
|
||||
|
||||
### BAD — inline fully qualified name
|
||||
|
||||
```kotlin
|
||||
val unit = com.example.app.data.db.entity.enums.WeightUnit.entries
|
||||
.find { it.name == rawValue }
|
||||
```
|
||||
|
||||
### GOOD — proper import
|
||||
|
||||
```kotlin
|
||||
import com.example.app.data.db.entity.enums.WeightUnit
|
||||
|
||||
val unit = WeightUnit.entries.find { it.name == rawValue }
|
||||
```
|
||||
|
||||
### GOOD — import alias for name clashes
|
||||
|
||||
```kotlin
|
||||
import com.example.app.data.db.entity.enums.WeightUnit as DbWeightUnit
|
||||
import com.example.app.domain.model.WeightUnit
|
||||
|
||||
val dbUnit = DbWeightUnit.entries.find { it.name == rawValue }
|
||||
val domainUnit = WeightUnit.fromDb(dbUnit)
|
||||
```
|
||||
|
||||
**Alias naming:** prefix or suffix with the distinguishing layer — `Db`, `Domain`, `Ui`, `Api`, `Dto`.
|
||||
|
||||
## Code Examples
|
||||
|
||||
For base ViewModel patterns (abstract class and interface + delegate), see [architecture.md](architecture.md).
|
||||
For when a thin base helps versus an overengineered stack, see Decision Rules → "When a generic base ViewModel helps."
|
||||
|
||||
### BAD: 4-type MVI forced on every screen
|
||||
|
||||
Event → Result mapping is 1:1 with no transformation; the `Result` type adds nothing for a simple currency picker.
|
||||
|
||||
```kotlin
|
||||
class CurrencyViewModel : MviViewModel<CurrencyEvent, CurrencyResult, CurrencyState, CurrencyEffect>(...) {
|
||||
override fun handleEvent(e: CurrencyEvent) = when (e) {
|
||||
is CurrencyEvent.OnSelected -> dispatch(CurrencyResult.CurrencySelected(e.currency))
|
||||
}
|
||||
override fun reduce(r: CurrencyResult, s: CurrencyState) = reduce(s) {
|
||||
when (r) {
|
||||
is CurrencyResult.CurrencySelected -> {
|
||||
effect(CurrencyEffect.NavigateBack(r.currency))
|
||||
state(s.copy(selected = r.currency))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: same screen with 3-type MVI
|
||||
|
||||
```kotlin
|
||||
sealed interface CurrencyEvent { data class OnSelected(val currency: Currency) : CurrencyEvent }
|
||||
data class CurrencyState(val selected: Currency? = null)
|
||||
sealed interface CurrencyEffect { data class NavigateBack(val currency: Currency) : CurrencyEffect }
|
||||
class CurrencyViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(CurrencyState())
|
||||
val state = _state.asStateFlow()
|
||||
private val _effect = Channel<CurrencyEffect>(Channel.BUFFERED)
|
||||
val effect = _effect.receiveAsFlow()
|
||||
fun onEvent(event: CurrencyEvent) = when (event) {
|
||||
is CurrencyEvent.OnSelected -> {
|
||||
_state.update { it.copy(selected = event.currency) }
|
||||
_effect.trySend(CurrencyEffect.NavigateBack(event.currency))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Direct, readable, testable. No intermediate type.
|
||||
|
||||
### GOOD: MVI ViewModel with async work
|
||||
|
||||
Full annotated example with `CreateItemViewModel` (standalone and base-class variants): see [architecture.md](architecture.md) Code Examples section.
|
||||
@@ -1,232 +0,0 @@
|
||||
# Compose Essentials
|
||||
|
||||
Foundational Compose patterns that complement MVI architecture. Consult this when working with Compose APIs directly.
|
||||
|
||||
## Three Phases Model
|
||||
|
||||
Every frame consists of three phases. Understanding which phase reads state prevents unnecessary recompositions.
|
||||
|
||||
1. **Composition** — executes composable functions, evaluates state reads. State reads here trigger recomposition of the entire scope.
|
||||
2. **Layout** — calculates size and position, runs `measure` and `layout` blocks. Can read state without triggering composition recomposition.
|
||||
3. **Drawing** — emits draw operations, runs `Canvas` and custom `DrawScope`.
|
||||
|
||||
This is why deferred state reads via lambda modifiers work:
|
||||
|
||||
```kotlin
|
||||
// BAD: reads in composition phase, triggers recomposition on every offset change
|
||||
Box(modifier = Modifier.offset(offsetX.dp, 0.dp))
|
||||
|
||||
// GOOD: reads in layout phase, skips composition entirely
|
||||
Box(modifier = Modifier.offset { IntOffset(offsetX.value.toInt(), 0) })
|
||||
```
|
||||
|
||||
Similarly, `Modifier.graphicsLayer { alpha = animatedAlpha.value }` reads state in the draw phase, avoiding recomposition for visual-only changes.
|
||||
|
||||
## State Primitives
|
||||
|
||||
### Primitive Specializations
|
||||
|
||||
Use type-specific state holders to avoid boxing overhead:
|
||||
|
||||
```kotlin
|
||||
val count = mutableIntStateOf(0) // no boxing
|
||||
val progress = mutableFloatStateOf(0f) // no boxing
|
||||
val enabled = mutableStateOf(true) // Boolean has no specialization
|
||||
val name = mutableStateOf("Alice") // general-purpose
|
||||
```
|
||||
|
||||
**Pitfall:** Using `mutableStateOf<Int>()` instead of `mutableIntStateOf()` causes unnecessary boxing on every read/write.
|
||||
|
||||
### SnapshotStateList and SnapshotStateMap
|
||||
|
||||
Observable collections that trigger recomposition on structural changes:
|
||||
|
||||
```kotlin
|
||||
val items = remember { mutableStateListOf<Item>() }
|
||||
items.add(Item(1, "First")) // triggers recomposition
|
||||
items[0] = items[0].copy(name = "Updated") // triggers recomposition
|
||||
items[0].name = "Updated" // does NOT trigger recomposition (in-place mutation)
|
||||
```
|
||||
|
||||
In MVI, prefer immutable collections (`ImmutableList`) in state models. `SnapshotStateList` is acceptable for UI-local state only.
|
||||
|
||||
### Saver for rememberSaveable
|
||||
|
||||
Custom types require explicit `Saver` for `rememberSaveable`:
|
||||
|
||||
```kotlin
|
||||
data class FilterState(val query: String, val category: Int)
|
||||
|
||||
val filterSaver = Saver<FilterState, String>(
|
||||
save = { "${it.query}:${it.category}" },
|
||||
restore = { parts -> FilterState(parts.split(":")[0], parts.split(":")[1].toInt()) }
|
||||
)
|
||||
|
||||
var filter by rememberSaveable(stateSaver = filterSaver) {
|
||||
mutableStateOf(FilterState("", 0))
|
||||
}
|
||||
```
|
||||
|
||||
In MVI, `rememberSaveable` is only for small UI-local state — screen business state belongs in the ViewModel. `rememberSaveable` is multiplatform and works in CMP `commonMain`.
|
||||
|
||||
## Side Effects
|
||||
|
||||
### LaunchedEffect — Coroutines Scoped to Composition
|
||||
|
||||
Launches a coroutine tied to the composable's lifecycle. Cancelled when the key changes or composable leaves composition.
|
||||
|
||||
```kotlin
|
||||
// Key = Unit: runs once when composable enters composition
|
||||
LaunchedEffect(Unit) { setupOnce() }
|
||||
|
||||
// Key = specific value: reruns when value changes
|
||||
LaunchedEffect(userId) { loadUserData(userId) }
|
||||
|
||||
// Multiple keys: reruns if ANY key changes
|
||||
LaunchedEffect(userId, postId) { loadUserAndPost(userId, postId) }
|
||||
```
|
||||
|
||||
In MVI, `LaunchedEffect` belongs at the route level for collecting UI effects. Do not use it for business logic in leaf composables.
|
||||
|
||||
### DisposableEffect — For Cleanup
|
||||
|
||||
```kotlin
|
||||
DisposableEffect(lifecycle) {
|
||||
val observer = LifecycleEventObserver { _, event -> /* handle */ }
|
||||
lifecycle.addObserver(observer)
|
||||
onDispose { lifecycle.removeObserver(observer) }
|
||||
}
|
||||
```
|
||||
|
||||
Always pair registration with `onDispose` cleanup.
|
||||
|
||||
### rememberCoroutineScope — From Event Handlers
|
||||
|
||||
```kotlin
|
||||
val scope = rememberCoroutineScope()
|
||||
Button(onClick = { scope.launch { fetchData() } }) { Text("Fetch") }
|
||||
```
|
||||
|
||||
In MVI, prefer dispatching events to the ViewModel instead. Use `rememberCoroutineScope` only for UI-local async work (e.g., scroll animation, snackbar).
|
||||
|
||||
Use `rememberUpdatedState` to capture latest callback values in long-running effects without restarting them.
|
||||
|
||||
`SideEffect { }` runs after every successful composition — use sparingly for stateless synchronization.
|
||||
|
||||
`produceState` bridges imperative state sources into Compose state; prefer ViewModel's `StateFlow` in MVI.
|
||||
|
||||
### Effect Ordering
|
||||
|
||||
Effects execute in declaration order after composition. `SideEffect` runs after every composition, `DisposableEffect` setup runs after composition, `LaunchedEffect` coroutines are scheduled asynchronously.
|
||||
|
||||
### collectAsStateWithLifecycle
|
||||
|
||||
Use `collectAsStateWithLifecycle()` instead of `collectAsState()` to collect only when the composable is in STARTED state:
|
||||
|
||||
```kotlin
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
```
|
||||
|
||||
This prevents collection during background states and avoids unnecessary work. `collectAsStateWithLifecycle` is available in both Android and Compose Multiplatform via `androidx.lifecycle:lifecycle-runtime-compose`. Verify your project's lifecycle version supports your KMP targets before using it in `commonMain`.
|
||||
|
||||
### CollectEffect — Lifecycle-Aware Effect Collection
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun <E> CollectEffect(effect: Flow<E>, onEffect: (E) -> Unit) {
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
LaunchedEffect(effect, lifecycleOwner) {
|
||||
lifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
effect.collect { onEffect(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Collect one-off effects at the route level when STARTED; usage patterns live in [mvi.md](mvi.md).
|
||||
|
||||
## Modifier Ordering
|
||||
|
||||
Order matters. Modifiers apply left-to-right in the chain:
|
||||
|
||||
```kotlin
|
||||
// Red background wraps padded content
|
||||
Modifier.background(Color.Red).padding(16.dp).size(100.dp)
|
||||
|
||||
// Padding is inside the sized box, then background wraps everything
|
||||
Modifier.size(100.dp).padding(16.dp).background(Color.Red)
|
||||
```
|
||||
|
||||
### Always accept Modifier parameter
|
||||
|
||||
```kotlin
|
||||
// GOOD: composable accepts modifier for caller customization
|
||||
@Composable
|
||||
fun ResultCard(derived: ProductDerived?, modifier: Modifier = Modifier) {
|
||||
Card(modifier = modifier) { /* ... */ }
|
||||
}
|
||||
```
|
||||
|
||||
## Slot Pattern
|
||||
|
||||
Accept `@Composable` lambda parameters for flexible, reusable containers:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun SectionCard(
|
||||
modifier: Modifier = Modifier,
|
||||
title: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Card(modifier = modifier) {
|
||||
Column(Modifier.padding(16.dp)) {
|
||||
title()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Usage
|
||||
SectionCard(
|
||||
title = { Text("Breakdown", style = MaterialTheme.typography.titleMedium) },
|
||||
content = { ProductBreakdownContent(derived) },
|
||||
)
|
||||
```
|
||||
|
||||
Slots accept `@Composable` lambdas, not pre-composed values. This ensures composition is deferred and scope-aware.
|
||||
|
||||
## Composable Extraction Guidelines
|
||||
|
||||
| Signal | Prefer |
|
||||
|--------|--------|
|
||||
| Reused in multiple places, or a single clear visual/behavioral responsibility | Extract |
|
||||
| Easier to test in isolation, or independent recomposition skipping helps | Extract |
|
||||
| Single use, trivial wrapper around one `Text`/`Icon`, or more parameters than inline clarity | Don't extract |
|
||||
| Tightly coupled logic that reads clearer inline | Don't extract |
|
||||
|
||||
## CompositionLocal
|
||||
|
||||
Provides implicit parameters without threading through the hierarchy.
|
||||
|
||||
### When to use
|
||||
|
||||
- Theming (`MaterialTheme`, `Colors`, `Typography`)
|
||||
- Platform integration (`LocalDensity`, `LocalLifecycleOwner`; `LocalContext` on Android, `LocalPlatformContext` in CMP)
|
||||
- Infrequently changing cross-cutting concerns
|
||||
|
||||
### When NOT to use
|
||||
|
||||
- Frequently changing values (causes widespread recomposition)
|
||||
- Values only 1-2 levels deep (pass directly)
|
||||
- Dependencies that should use DI
|
||||
|
||||
```kotlin
|
||||
// GOOD: theme/density accessed via CompositionLocal
|
||||
val density = LocalDensity.current
|
||||
|
||||
// BAD: custom CompositionLocal for a value only used in one subtree
|
||||
val LocalTitle = staticCompositionLocalOf<String> { "" }
|
||||
```
|
||||
|
||||
In MVI, avoid custom CompositionLocals for feature state. State flows through the ViewModel → route → screen → leaves via explicit parameters.
|
||||
@@ -1,119 +0,0 @@
|
||||
# Coroutines & Flow — Advanced Patterns
|
||||
|
||||
Backpressure strategies, bridging callback APIs to Flow, concurrency primitives, and testing with Turbine. For core coroutine and Flow patterns (StateFlow/SharedFlow/Channel, operators, dispatchers, scopes, exception handling, stateIn/shareIn), see [coroutines-flow.md](coroutines-flow.md).
|
||||
|
||||
## Backpressure
|
||||
|
||||
When producer emits faster than consumer processes:
|
||||
|
||||
| Strategy | Behavior | Use when |
|
||||
|---|---|---|
|
||||
| Default (no buffer) | Producer suspends until consumer processes | Simple sequential work |
|
||||
| `buffer(capacity)` | Queue between producer and consumer | Smooth speed spikes, process every item |
|
||||
| `conflate()` | Drop old values, keep only latest | UI updates, progress bars — stale data unnecessary |
|
||||
| `collectLatest { }` | Cancel previous processing when new value arrives | Search — only final result matters |
|
||||
|
||||
```kotlin
|
||||
// Search with collectLatest: only the last query completes
|
||||
queryFlow
|
||||
.debounce(300)
|
||||
.distinctUntilChanged()
|
||||
.collectLatest { query ->
|
||||
val results = repository.search(query) // cancelled if new query arrives
|
||||
_state.update { it.copy(results = results) }
|
||||
}
|
||||
```
|
||||
|
||||
### flowOn
|
||||
|
||||
`flowOn` changes the dispatcher for upstream operators and automatically buffers at the context switch:
|
||||
|
||||
```kotlin
|
||||
repository.observeProducts() // runs on IO
|
||||
.map { it.toDomain() } // runs on IO
|
||||
.flowOn(Dispatchers.IO) // everything above runs on IO
|
||||
.collect { updateUi(it) } // runs on caller's dispatcher (Main)
|
||||
```
|
||||
|
||||
## callbackFlow and channelFlow
|
||||
|
||||
### callbackFlow — bridge listener APIs to Flow
|
||||
|
||||
Use `callbackFlow` to convert callback-based platform APIs into a `Flow`. In CMP, place these wrappers in `expect/actual` declarations or platform source sets.
|
||||
|
||||
```kotlin
|
||||
// Android example — LocationManager (place in androidMain for CMP)
|
||||
fun LocationManager.locationUpdates(): Flow<Location> = callbackFlow {
|
||||
val listener = LocationListener { location ->
|
||||
trySend(location) // non-blocking, thread-safe
|
||||
}
|
||||
requestLocationUpdates(GPS_PROVIDER, 1000L, 0f, listener)
|
||||
awaitClose { removeUpdates(listener) } // mandatory cleanup
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- Use `trySend()` (non-blocking) not `send()` (suspending) from callbacks
|
||||
- `awaitClose { }` is mandatory — omitting it throws `IllegalStateException`
|
||||
- The cleanup block in `awaitClose` unregisters the listener
|
||||
|
||||
### channelFlow — concurrent production
|
||||
|
||||
```kotlin
|
||||
fun loadDashboard(): Flow<DashboardSection> = channelFlow {
|
||||
launch { send(DashboardSection.Profile(fetchProfile())) }
|
||||
launch { send(DashboardSection.Stats(fetchStats())) }
|
||||
launch { send(DashboardSection.Feed(fetchFeed())) }
|
||||
}
|
||||
```
|
||||
|
||||
Use `channelFlow` when producing values from multiple concurrent coroutines. Use `callbackFlow` specifically for wrapping external callback APIs.
|
||||
|
||||
## Concurrency Primitives
|
||||
|
||||
### Mutex — mutual exclusion
|
||||
|
||||
```kotlin
|
||||
private val mutex = Mutex()
|
||||
private var tokenCache: String? = null
|
||||
|
||||
suspend fun getToken(): String = mutex.withLock {
|
||||
tokenCache ?: refreshToken().also { tokenCache = it }
|
||||
}
|
||||
```
|
||||
|
||||
Use Mutex for: token refresh synchronization, shared mutable state protection, sequential access to resources.
|
||||
|
||||
### Semaphore — limited concurrency
|
||||
|
||||
```kotlin
|
||||
private val semaphore = Semaphore(permits = 3)
|
||||
|
||||
suspend fun downloadFile(url: String): ByteArray = semaphore.withPermit {
|
||||
httpClient.get(url).body()
|
||||
}
|
||||
```
|
||||
|
||||
Use Semaphore for: rate-limiting concurrent network calls, limiting parallel file operations.
|
||||
|
||||
### Why not synchronized?
|
||||
|
||||
`synchronized` blocks the thread. Coroutines suspend — blocking a thread holding a coroutine defeats the purpose. Use `Mutex.withLock` instead of `synchronized` in coroutine code.
|
||||
|
||||
## Testing with Turbine
|
||||
|
||||
### Turbine API quick reference
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `flow.test { }` | Start collecting and asserting |
|
||||
| `awaitItem()` | Wait for next emission, fail if timeout |
|
||||
| `awaitComplete()` | Assert flow completes |
|
||||
| `awaitError()` | Assert flow throws |
|
||||
| `expectNoEvents()` | Assert no emissions pending |
|
||||
| `cancelAndIgnoreRemainingEvents()` | Clean up after assertions |
|
||||
| `cancelAndConsumeRemainingEvents()` | Cancel and return remaining events |
|
||||
|
||||
`runTest` from `kotlinx-coroutines-test` provides deterministic coroutine execution — delays are skipped automatically. Use `advanceUntilIdle()` to process all pending coroutines.
|
||||
|
||||
For full ViewModel event→state→effect testing patterns with Turbine, see [testing.md](testing.md).
|
||||
@@ -1,188 +0,0 @@
|
||||
# Kotlin Coroutines & Flow
|
||||
|
||||
Coroutines and Flow primitives for Compose apps: StateFlow, SharedFlow, Channel, operators, dispatchers, scopes, and exception handling. Works on all CMP targets.
|
||||
|
||||
References:
|
||||
- [Coroutines best practices (Android)](https://developer.android.com/kotlin/coroutines/coroutines-best-practices)
|
||||
- [Exception handling (Kotlin docs)](https://kotlinlang.org/docs/exception-handling.html)
|
||||
- [Turbine (GitHub)](https://github.com/cashapp/turbine)
|
||||
|
||||
## StateFlow vs SharedFlow vs Channel
|
||||
|
||||
| | StateFlow | SharedFlow | Channel |
|
||||
|---|---|---|---|
|
||||
| Holds current value | Yes (replay=1, conflated) | No (configurable replay) | No |
|
||||
| New collector gets | Latest value immediately | Replayed values (if configured) | Nothing (consumed) |
|
||||
| Delivery | All collectors | All collectors | One receiver |
|
||||
| Duplicate filtering | `distinctUntilChanged` built-in | None | None |
|
||||
| Use for | UI state | Broadcasting events | One-off effects |
|
||||
|
||||
### MVI mapping
|
||||
|
||||
```kotlin
|
||||
class ProductViewModel : ViewModel() {
|
||||
private val _state = MutableStateFlow(ProductState())
|
||||
val state: StateFlow<ProductState> = _state.asStateFlow()
|
||||
|
||||
private val _effects = Channel<ProductEffect>(Channel.BUFFERED)
|
||||
val effects: Flow<ProductEffect> = _effects.receiveAsFlow()
|
||||
}
|
||||
```
|
||||
|
||||
### When to use which
|
||||
|
||||
- **Screen state** (loading, data, errors, form input) → `StateFlow`
|
||||
- **One-off UI effects** (navigate, snackbar, haptic) → `Channel(BUFFERED)` collected via `CollectEffect`
|
||||
- **Broadcasting to multiple collectors** (analytics, logging) → `SharedFlow` with appropriate replay
|
||||
- **Hot data streams** (search results reacting to query) → cold `Flow` converted via `stateIn`
|
||||
|
||||
### Common mistakes
|
||||
|
||||
- StateFlow for one-off events → shows twice on config change (new collector gets latest)
|
||||
- `SharedFlow(replay=0)` for mandatory effects → lost when UI detached
|
||||
- `Channel()` default (RENDEZVOUS) → suspends sender if no receiver; use `Channel.BUFFERED`
|
||||
|
||||
## Flow Operators Quick Reference
|
||||
|
||||
### Transforming
|
||||
|
||||
| Operator | Purpose |
|
||||
|---|---|
|
||||
| `map { }` | Transform each value |
|
||||
| `mapNotNull { }` | Transform and drop nulls |
|
||||
| `filter { }` | Keep values matching predicate |
|
||||
| `take(n)` / `drop(n)` | Take first n / skip first n |
|
||||
|
||||
### Flattening
|
||||
|
||||
| Operator | Behavior | Use when |
|
||||
|---|---|---|
|
||||
| `flatMapLatest { }` | Cancel previous inner flow | Search queries — only latest |
|
||||
| `flatMapConcat { }` | Sequential, wait for completion | Order matters |
|
||||
| `flatMapMerge { }` | Concurrent inner flows | Parallel, order irrelevant |
|
||||
|
||||
### Combining
|
||||
|
||||
| Operator | Behavior | Use when |
|
||||
|---|---|---|
|
||||
| `combine(flowA, flowB) { a, b -> }` | Emit when ANY emits, latest from each | Multiple independent state sources |
|
||||
| `zip(flowA, flowB) { a, b -> }` | Paired emissions only | Synchronized pairs |
|
||||
| `merge(flowA, flowB)` | Interleave emissions | Unified event stream |
|
||||
|
||||
**Gotcha:** `combine` waits until every upstream emits at least once before producing output.
|
||||
|
||||
### Timing / Error / Side effects
|
||||
|
||||
| Operator | Purpose |
|
||||
|---|---|
|
||||
| `debounce(300)` | Wait for pause (search input) |
|
||||
| `sample(1000)` | Latest at fixed intervals |
|
||||
| `distinctUntilChanged()` | Skip consecutive duplicates |
|
||||
| `catch { }` | Handle upstream errors, can `emit()` fallback |
|
||||
| `retry(3)` / `retryWhen { cause, attempt -> }` | Retry with optional backoff |
|
||||
| `onEach { }` / `onStart { }` / `onCompletion { }` | Side effects |
|
||||
|
||||
### Terminal operators
|
||||
|
||||
| Operator | Purpose |
|
||||
|---|---|
|
||||
| `collect { }` / `collectLatest { }` | Collect values (suspends) |
|
||||
| `first()` / `toList()` | Single value / all values |
|
||||
| `launchIn(scope)` | Start collection in scope |
|
||||
| `stateIn(scope)` / `shareIn(scope)` | Convert to hot StateFlow/SharedFlow |
|
||||
|
||||
## Dispatchers
|
||||
|
||||
| Dispatcher | Use for | CMP support |
|
||||
|---|---|---|
|
||||
| `Dispatchers.Main` | UI state updates, composable callbacks | All targets |
|
||||
| `Dispatchers.IO` | Network, database, file I/O | All targets (since 1.7+) |
|
||||
| `Dispatchers.Default` | CPU-heavy computation, sorting, parsing | All targets |
|
||||
|
||||
**Main-safe rule:** the callee switches dispatchers, not the caller:
|
||||
|
||||
```kotlin
|
||||
class ProductRepository(
|
||||
private val api: ProductApi,
|
||||
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO,
|
||||
) {
|
||||
suspend fun getProducts(): List<Product> = withContext(ioDispatcher) {
|
||||
api.getProducts().toDomain()
|
||||
}
|
||||
}
|
||||
// Caller: viewModelScope.launch { repository.getProducts() } — safe from Main
|
||||
```
|
||||
|
||||
Inject dispatchers as constructor params for testability.
|
||||
|
||||
## Structured Concurrency and Scopes
|
||||
|
||||
| Scope | Lifecycle | Use for |
|
||||
|---|---|---|
|
||||
| `viewModelScope` | ViewModel cleared | ViewModel coroutines (CMP `commonMain` since lifecycle 2.8+) |
|
||||
| `lifecycleScope` | Lifecycle destroyed | Android Activity/Fragment only |
|
||||
| `rememberCoroutineScope()` | Leaves composition | Compose event handlers |
|
||||
| `coroutineScope { }` | All children complete | Parallel decomposition (one fails → all cancel) |
|
||||
| `supervisorScope { }` | Child failure independent | Independent parallel tasks |
|
||||
|
||||
Use `supervisorScope` when tasks are independent (dashboard sections). Use `coroutineScope` when all must succeed together. Never use `GlobalScope` — no lifecycle, memory leak. Never create unbound `CoroutineScope(Job())` without lifecycle management.
|
||||
|
||||
## Exception Handling
|
||||
|
||||
### launch vs async
|
||||
|
||||
`launch`: exception propagates immediately. `async`: exception deferred until `await()`.
|
||||
|
||||
```kotlin
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val data = repository.fetchData()
|
||||
_state.update { it.copy(data = data, isLoading = false) }
|
||||
} catch (e: IOException) {
|
||||
_state.update { it.copy(error = "Network error", isLoading = false) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### CancellationException — never swallow
|
||||
|
||||
```kotlin
|
||||
// BAD: catch(e: Exception) catches CancellationException — zombie coroutine
|
||||
// GOOD:
|
||||
try { suspendingWork() }
|
||||
catch (e: CancellationException) { throw e }
|
||||
catch (e: Exception) { handleError(e) }
|
||||
```
|
||||
|
||||
## stateIn and shareIn
|
||||
|
||||
Convert cold `Flow` to hot `StateFlow`/`SharedFlow`. Always declare as `val`, never per function call.
|
||||
|
||||
```kotlin
|
||||
val products: StateFlow<List<Product>> = repository.observeProducts()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList())
|
||||
```
|
||||
|
||||
| Strategy | Starts | Stops | Use for |
|
||||
|---|---|---|---|
|
||||
| `WhileSubscribed(5000)` | First collector | 5s after last gone | ViewModel state — stops upstream when UI gone |
|
||||
| `Lazily` | First collector | Never (scope cancel) | Expensive-to-restart shared resources |
|
||||
| `Eagerly` | Immediately | Never (scope cancel) | Data needed before first collector |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it hurts | Fix |
|
||||
|---|---|---|
|
||||
| `GlobalScope.launch { }` | No lifecycle, memory leak | `viewModelScope` or structured scope |
|
||||
| `runBlocking` on Main | Blocks UI, ANR | `launch` / `async` from coroutine scope |
|
||||
| Swallowing `CancellationException` | Zombie coroutines | Always rethrow |
|
||||
| Blocking I/O on `Dispatchers.Default` | Starves CPU pool | `Dispatchers.IO` |
|
||||
| Non-suspending loop without `ensureActive()` | Ignores cancellation | Check `isActive` / `ensureActive()` |
|
||||
| `stateIn` per function call | Leaks hot flows | Declare as `val`, create once |
|
||||
| `catch (e: Throwable)` | Catches everything including OOM | `catch (e: Exception)` + rethrow `CancellationException` |
|
||||
| Hardcoded `Dispatchers.IO` | Untestable | Inject dispatcher as constructor param |
|
||||
| `combine` without initial values | No output until all emit | `onStart { emit(default) }` |
|
||||
|
||||
## Advanced Patterns
|
||||
|
||||
For backpressure, callbackFlow/channelFlow, Mutex/Semaphore, and Turbine testing, see [coroutines-flow-advanced.md](coroutines-flow-advanced.md).
|
||||
@@ -1,232 +0,0 @@
|
||||
# Cross-Platform (KMP) Specifics
|
||||
|
||||
## Sharing Strategy
|
||||
|
||||
Share these first: reducers, ViewModels, validators, calculators, formatting policies, screen state models, most screen UI.
|
||||
|
||||
Keep platform-specific until proven otherwise: permissions, share sheets, clipboard, haptics, file pickers, notifications, deep links, review prompts, platform input traits, OS navigation shell.
|
||||
|
||||
## Placement Guide
|
||||
|
||||
### What belongs in `commonMain`
|
||||
|
||||
Feature state, intents/messages, reducer/ViewModel logic, calculators, validators, eligibility, repository interfaces, use cases that earn their keep, shared composables, presentation mapping, semantic nav effects and error keys.
|
||||
|
||||
### What should remain platform-specific
|
||||
|
||||
Runtime permissions, share/open sheet, haptics, clipboard, URLs, billing, notifications, biometrics, manifest/delegate deep links, OS widgets/shortcuts.
|
||||
|
||||
### Placement Table
|
||||
|
||||
| Concern | Default placement | Why |
|
||||
|---|---|---|
|
||||
| reducer/ViewModel | `commonMain` | pure, testable, reusable |
|
||||
| validator/calculator | `commonMain` | pure domain logic |
|
||||
| repository contract | `commonMain` | shared dependency boundary |
|
||||
| haptics/share/clipboard | interface + platform impl | app capability, easy to fake |
|
||||
| locale/number/date formatter | interface or shared library | locale-sensitive behavior |
|
||||
| resource identifiers | `commonMain` UI | shared UI uses shared resources |
|
||||
| permission prompt flow | platform-specific | OS-specific behavior |
|
||||
| safe-area / keyboard handling | route/UI boundary | platform behavior differs |
|
||||
| navigation controller binding | platform/UI shell | ViewModel should not know controller type |
|
||||
| analytics SDK integration | platform or shared facade | real implementation differs |
|
||||
|
||||
### Dependency Verification for commonMain
|
||||
|
||||
**Before claiming `commonMain`:** confirm multiplatform artifacts exist. Much of AndroidX is still Android-only; some libs publish KMP (e.g. `lifecycle-viewmodel`, `datastore-preferences`) with version-dependent surfaces. Check Maven for `-jvm`, `-iosarm64`, `-iosX64`, etc.; use context7 `resolve-library-id` + `query-docs` when available. **If unverifiable**, say so—use platform placement or wrapper interfaces.
|
||||
|
||||
## Interfaces vs expect/actual
|
||||
|
||||
### Default recommendation
|
||||
|
||||
Use **interfaces** for app capabilities: haptics, clipboard, share, URL opener, analytics, date/number formatting, file opener.
|
||||
|
||||
Use **`expect/actual`** for thin platform facts or one-off helpers when an interface buys little.
|
||||
|
||||
### Practical rule
|
||||
|
||||
- **Interface** when the capability has lifetime, DI, fakes, or multiple implementations
|
||||
- **`expect/actual`** when it is a tiny platform hook with no domain meaning
|
||||
|
||||
### Dependency Injection
|
||||
|
||||
Heavy/async/hardware services (GPS, biometrics, keystore): `commonMain` interface + Koin (or similar) for platform impls. Reserve `expect/actual` for tiny sync primitives (UUID, dates, clipboard).
|
||||
|
||||
## Platform Bridge Patterns
|
||||
|
||||
The rules above cover *when* to prefer interfaces vs `expect/actual`; below is *how* to wire each pattern.
|
||||
|
||||
### Choosing the Right Bridge
|
||||
|
||||
| Need | Pattern | Why |
|
||||
|---|---|---|
|
||||
| Service with lifecycle, state, or async (player, auth, payments, analytics) | Interface + DI | Testable, fakeable, swappable impls |
|
||||
| Stateless platform fact (UUID, platform name, default locale) | `expect/actual` function | No DI overhead for a one-liner |
|
||||
| Reuse existing platform type in common signature | `expect class` + `actual typealias` | Rare — prefer interface when possible |
|
||||
|
||||
### Pattern 1: Interface + DI (Primary)
|
||||
|
||||
Contract in `commonMain`; platform modules supply impls; DI binds them. ViewModel depends only on the interface. Koin setup: [koin.md](koin.md).
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
interface Player { fun play(uri: String); fun pause(); fun release() }
|
||||
|
||||
// androidMain
|
||||
class AndroidPlayer(private val context: Context) : Player {
|
||||
private val mp = MediaPlayer()
|
||||
override fun play(uri: String) { mp.setDataSource(context, uri.toUri()); mp.start() }
|
||||
override fun pause() = mp.pause()
|
||||
override fun release() = mp.release()
|
||||
}
|
||||
|
||||
// iosMain
|
||||
class IosPlayer : Player {
|
||||
private var av: AVPlayer? = null
|
||||
override fun play(uri: String) { av = AVPlayer(uRL = NSURL(string = uri)); av?.play() }
|
||||
override fun pause() { av?.pause() }
|
||||
override fun release() { av = null }
|
||||
}
|
||||
|
||||
// androidMain
|
||||
val androidPlayerModule = module { single<Player> { AndroidPlayer(get()) } }
|
||||
// iosMain
|
||||
val iosPlayerModule = module { single<Player> { IosPlayer() } }
|
||||
|
||||
class PlayerViewModel(private val player: Player) : ViewModel() {
|
||||
fun onEvent(e: PlayerEvent) {
|
||||
when (e) { is PlayerEvent.Play -> player.play(e.uri); PlayerEvent.Pause -> player.pause() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Pattern 2: expect/actual for Thin Primitives
|
||||
|
||||
Stateless one-liners, no DI/interface/fakes:
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect fun randomUUID(): String
|
||||
|
||||
// androidMain
|
||||
actual fun randomUUID(): String = java.util.UUID.randomUUID().toString()
|
||||
|
||||
// iosMain
|
||||
actual fun randomUUID(): String = platform.Foundation.NSUUID().UUIDString()
|
||||
```
|
||||
|
||||
### Pattern 3: expect/actual with Typealias
|
||||
|
||||
When a platform type already matches the contract:
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect class PlatformDate {
|
||||
fun toEpochMillis(): Long
|
||||
}
|
||||
|
||||
// jvmMain
|
||||
actual typealias PlatformDate = java.time.Instant
|
||||
|
||||
// nativeMain
|
||||
actual class PlatformDate(private val nsDate: NSDate) {
|
||||
actual fun toEpochMillis(): Long = (nsDate.timeIntervalSince1970 * 1000).toLong()
|
||||
}
|
||||
```
|
||||
|
||||
Prefer interface+DI for fakes or when types do not match 1:1.
|
||||
|
||||
### Bridge Anti-Patterns
|
||||
|
||||
- `expect/actual` for lifecycle/state/async → interface+DI
|
||||
- Platform imports in `commonMain` (compiler flags; still catch in review)
|
||||
- Fat `expect/actual` → thin bridge, logic in impls
|
||||
- Skipping interfaces when tests need fakes
|
||||
|
||||
## Lifecycle
|
||||
|
||||
`lifecycle-viewmodel` / `lifecycle-runtime-compose` can expose `ViewModel`, `viewModelScope`, `collectAsStateWithLifecycle` in `commonMain`; not all Lifecycle APIs are MP—depends on androidx/KMP.
|
||||
|
||||
- Artifact must publish KMP targets (`-jvm`, `-iosarm64`, …) and expose the API on MP (many APIs stay Android-only); match project targets.
|
||||
- **Confirm versions** via context7 or AndroidX notes; if not, say so—wrap platform lifecycle behind interfaces if needed.
|
||||
- **Typical in `commonMain` (re-verify):** `ViewModel`, `viewModelScope`, `collectAsStateWithLifecycle`, `koinViewModel()`.
|
||||
|
||||
## State Restoration
|
||||
|
||||
- `rememberSaveable`: small local UI state only
|
||||
- Cross-platform drafts: rehydrate from persistence, not assumed OS restoration parity
|
||||
- Serialize ViewModel state only when product requires it
|
||||
|
||||
## Keyboard, Focus, and Input
|
||||
|
||||
- Test text input on real iOS hardware; isolate quirks at the UI/platform edge
|
||||
- No keyboard workaround flags in reducer state; shared UI uses inset/safe-area layout
|
||||
- Keep selection/composition local per field when needed
|
||||
|
||||
## Safe Area and Layout
|
||||
|
||||
Insets-aware shared layouts; verify safe areas, keyboard overlap, sheets, nav chrome. Never put “iOS safe-area hack” into feature state.
|
||||
|
||||
## Platform Capabilities
|
||||
|
||||
Model haptics, clipboard, share as semantic effects; shell executes them.
|
||||
|
||||
```kotlin
|
||||
enum class HapticType { Confirm, Error, Selection }
|
||||
interface Haptics { fun perform(type: HapticType) }
|
||||
sealed interface ProductEffect {
|
||||
data class TriggerHaptic(val type: HapticType) : ProductEffect
|
||||
data class ShareQuote(val text: String) : ProductEffect
|
||||
}
|
||||
interface ShareText { suspend fun share(text: String) }
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
CMP shared resources (strings, images, fonts, qualifiers, localization, Gradle setup). Full API surface: **[Multiplatform Resources](resources.md)**.
|
||||
|
||||
```kotlin
|
||||
enum class ValidationMessageKey { Required, InvalidNumber, MustBePositive }
|
||||
|
||||
@Composable
|
||||
fun ValidationMessage(messageKey: ValidationMessageKey?) {
|
||||
val text = when (messageKey) {
|
||||
ValidationMessageKey.Required -> stringResource(Res.string.error_required)
|
||||
ValidationMessageKey.InvalidNumber -> stringResource(Res.string.error_invalid_number)
|
||||
ValidationMessageKey.MustBePositive -> stringResource(Res.string.error_must_be_positive)
|
||||
null -> return
|
||||
}
|
||||
Text(text = text, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall)
|
||||
}
|
||||
```
|
||||
|
||||
## Code Examples
|
||||
|
||||
### GOOD: shared calculator
|
||||
|
||||
```kotlin
|
||||
class PriceCalculator {
|
||||
fun calculate(d: PriceDraft): PriceDerived {
|
||||
val w = if (d.includeWaste) 1.10 else 1.0
|
||||
val mat = d.area * d.materialRate * w
|
||||
val lab = d.area * d.laborRate
|
||||
val sub = mat + lab
|
||||
val tax = sub * (d.taxPercent / 100.0)
|
||||
return PriceDerived(mat, lab, sub, tax, sub + tax)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### BAD: platform leakage in state
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class ProductState(
|
||||
val input: ProductInput = ProductInput(),
|
||||
val iosKeyboardInsetHack: Int = 0,
|
||||
val androidHapticPattern: String = "",
|
||||
val shareSheetPresented: Boolean = false,
|
||||
)
|
||||
```
|
||||
|
||||
Platform leakage.
|
||||
@@ -1,197 +0,0 @@
|
||||
# DataStore
|
||||
|
||||
Key-value and typed preferences via Kotlin coroutines and Flow. For structured/relational data, use [Room](room-database.md).
|
||||
|
||||
References:
|
||||
- [DataStore documentation](https://developer.android.com/topic/libraries/architecture/datastore)
|
||||
- [Set up DataStore for KMP](https://developer.android.com/kotlin/multiplatform/datastore)
|
||||
|
||||
## When to Use
|
||||
|
||||
| Need | Solution | Why |
|
||||
|------|----------|-----|
|
||||
| Key-value settings (theme, locale, flags) | Preferences DataStore | No schema, simple key-value, reactive Flow |
|
||||
| Typed settings object with multiple fields | Typed DataStore (JSON serializer) | Type-safe, schema evolution via `@Serializable` data class |
|
||||
| Structured data with queries, indexes, relations | Room | SQL-backed, compile-time verified, supports Paging |
|
||||
| Large binary blobs or files | Filesystem | DataStore is not designed for large payloads |
|
||||
|
||||
**Scope rule:** If you need `WHERE`, `JOIN`, or more than ~100 entries, use Room.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
1. **One instance per file** — never create multiple `DataStore` instances for the same file. Enforce via DI singleton.
|
||||
2. **Immutable types only** — `T` in `DataStore<T>` must be immutable. Mutating breaks transactional consistency.
|
||||
3. **No mixing SingleProcess / MultiProcess** — if any access point uses `MultiProcessDataStoreFactory`, all must.
|
||||
|
||||
## Setup
|
||||
|
||||
> **Always search online for the latest stable versions** before adding dependencies.
|
||||
|
||||
```kotlin
|
||||
// KMP: shared/build.gradle.kts
|
||||
commonMain.dependencies {
|
||||
implementation("androidx.datastore:datastore-preferences:<latest>")
|
||||
// For Typed DataStore: also add androidx.datastore:datastore + kotlinx-serialization-json
|
||||
}
|
||||
```
|
||||
|
||||
For Typed DataStore, also add the `kotlin.plugin.serialization` Gradle plugin. See [official setup](https://developer.android.com/topic/libraries/architecture/datastore#setup).
|
||||
|
||||
## KMP Instance Creation
|
||||
|
||||
Define factory in `commonMain`; platform source sets provide the file path:
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
fun createDataStore(producePath: () -> String): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.createWithPath(produceFile = { producePath().toPath() })
|
||||
|
||||
internal const val PREFS_FILE = "app_settings.preferences_pb"
|
||||
|
||||
// androidMain
|
||||
fun createDataStore(context: Context): DataStore<Preferences> = createDataStore(
|
||||
producePath = { context.filesDir.resolve(PREFS_FILE).absolutePath }
|
||||
)
|
||||
|
||||
// iosMain
|
||||
fun createDataStore(): DataStore<Preferences> = createDataStore(
|
||||
producePath = {
|
||||
val dir = NSFileManager.defaultManager.URLForDirectory(
|
||||
NSDocumentDirectory, NSUserDomainMask, null, false, null
|
||||
)
|
||||
requireNotNull(dir).path + "/$PREFS_FILE"
|
||||
}
|
||||
)
|
||||
|
||||
// jvmMain (Desktop) — use app-specific folder, NOT java.io.tmpdir
|
||||
fun createDataStore(): DataStore<Preferences> = createDataStore(
|
||||
producePath = {
|
||||
val appDir = File(System.getProperty("user.home"), ".myapp").apply { mkdirs() }
|
||||
File(appDir, PREFS_FILE).absolutePath
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
**Android-only shortcut:** `val Context.settingsDataStore by preferencesDataStore(name = "settings")`.
|
||||
|
||||
## Preferences DataStore
|
||||
|
||||
| Type | Factory |
|
||||
|------|---------|
|
||||
| `Int` | `intPreferencesKey("name")` |
|
||||
| `Long` | `longPreferencesKey("name")` |
|
||||
| `Double` | `doublePreferencesKey("name")` |
|
||||
| `Float` | `floatPreferencesKey("name")` |
|
||||
| `Boolean` | `booleanPreferencesKey("name")` |
|
||||
| `String` | `stringPreferencesKey("name")` |
|
||||
| `Set<String>` | `stringSetPreferencesKey("name")` |
|
||||
|
||||
### Repository pattern (read + write)
|
||||
|
||||
```kotlin
|
||||
object PrefsKeys {
|
||||
val DARK_MODE = booleanPreferencesKey("dark_mode")
|
||||
val LOCALE = stringPreferencesKey("locale")
|
||||
val ONBOARDING_DONE = booleanPreferencesKey("onboarding_done")
|
||||
}
|
||||
|
||||
class SettingsRepository(private val dataStore: DataStore<Preferences>) {
|
||||
val settings: Flow<UserSettings> = dataStore.data
|
||||
.catch { if (it is IOException) emit(emptyPreferences()) else throw it }
|
||||
.map { prefs -> UserSettings(darkMode = prefs[PrefsKeys.DARK_MODE] ?: false) }
|
||||
|
||||
suspend fun setDarkMode(enabled: Boolean) {
|
||||
dataStore.edit { it[PrefsKeys.DARK_MODE] = enabled }
|
||||
}
|
||||
|
||||
suspend fun clearAll() { dataStore.edit { it.clear() } }
|
||||
}
|
||||
```
|
||||
|
||||
Always handle `IOException` with `.catch` — the file may be unreadable on first launch or after corruption. `edit` is an atomic read-write-modify transaction.
|
||||
|
||||
## Typed DataStore (JSON)
|
||||
|
||||
For settings with multiple related fields, use `DataStore<T>` with `kotlinx.serialization`:
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class AppSettings(
|
||||
val darkMode: Boolean = false,
|
||||
val locale: String = "en",
|
||||
val itemsPerPage: Int = 20,
|
||||
)
|
||||
|
||||
object AppSettingsSerializer : Serializer<AppSettings> {
|
||||
override val defaultValue = AppSettings()
|
||||
override suspend fun readFrom(input: InputStream): AppSettings =
|
||||
try { Json.decodeFromString(input.readBytes().decodeToString()) }
|
||||
catch (e: SerializationException) { throw CorruptionException("Cannot read settings", e) }
|
||||
override suspend fun writeTo(t: AppSettings, output: OutputStream) =
|
||||
output.write(Json.encodeToString(t).encodeToByteArray())
|
||||
}
|
||||
|
||||
val settingsDataStore: DataStore<AppSettings> = DataStoreFactory.create(
|
||||
serializer = AppSettingsSerializer,
|
||||
corruptionHandler = ReplaceFileCorruptionHandler { AppSettings() },
|
||||
produceFile = { File(context.filesDir, "app_settings.json") }
|
||||
)
|
||||
|
||||
// Read: settingsDataStore.data
|
||||
// Write: settingsDataStore.updateData { it.copy(locale = "fr") }
|
||||
```
|
||||
|
||||
## SharedPreferences Migration
|
||||
|
||||
```kotlin
|
||||
val dataStore: DataStore<Preferences> by preferencesDataStore(
|
||||
name = "settings",
|
||||
produceMigrations = { context ->
|
||||
listOf(SharedPreferencesMigration(context, "legacy_shared_prefs"))
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Migration runs once on first access. Old file deleted after success.
|
||||
|
||||
## MVI Integration
|
||||
|
||||
Map `Preferences` to domain models at the repository boundary — never pass `Preferences` or raw key lookups into the ViewModel or UI.
|
||||
|
||||
For the ViewModel collection pattern (collecting repository `Flow` into state via `viewModelScope`), see [architecture.md](architecture.md) — Reactive Data Collection.
|
||||
|
||||
## DI Integration
|
||||
|
||||
Always provide `DataStore` as a **singleton** — multiple instances for the same file cause `IllegalStateException`.
|
||||
|
||||
```kotlin
|
||||
// Koin: single<DataStore<Preferences>> { createDataStore(get()) }
|
||||
// Hilt: @Provides @Singleton fun provideDataStore(...): DataStore<Preferences> = ...
|
||||
```
|
||||
|
||||
For full module patterns, see [koin.md](koin.md) or [hilt.md](hilt.md).
|
||||
|
||||
## Testing
|
||||
|
||||
```kotlin
|
||||
private fun createTestDataStore(testDir: File): DataStore<Preferences> =
|
||||
PreferenceDataStoreFactory.create(
|
||||
scope = TestScope(UnconfinedTestDispatcher()),
|
||||
produceFile = { File(testDir, "test.preferences_pb") }
|
||||
)
|
||||
```
|
||||
|
||||
Use a temp directory per test and `deleteRecursively()` in teardown. For ViewModel tests, bypass DataStore with a fake repository backed by `MutableStateFlow`. For testing patterns, see [testing.md](testing.md).
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it is harmful | Better replacement |
|
||||
|---|---|---|
|
||||
| Multiple `DataStore` instances for same file | `IllegalStateException`, data corruption | DI singleton (`@Singleton` / `single`) |
|
||||
| `runBlocking` on main thread | Blocks UI, ANRs | Collect `data` Flow in `viewModelScope` |
|
||||
| Large objects/lists in DataStore | Entire file read/written every operation | Use Room for structured/large data |
|
||||
| Missing `.catch` on `dataStore.data` | `IOException` crashes app | `.catch { if (it is IOException) emit(default) }` |
|
||||
| No corruption handler | Corrupted file breaks reads permanently | `ReplaceFileCorruptionHandler` with defaults |
|
||||
| `java.io.tmpdir` for Desktop | Data lost on reboot | Use app data dir (`~/Library/Application Support/` etc.) |
|
||||
| Reading preferences inside composables | Recomposition storms | Read in repository/ViewModel, expose as `StateFlow` |
|
||||
| Passing raw `Preferences` to UI | Leaks storage implementation | Map to domain model at repository boundary |
|
||||
@@ -1,82 +0,0 @@
|
||||
# Dependency Injection in Compose Projects
|
||||
|
||||
Shared DI guidance for Jetpack Compose and Compose Multiplatform. For framework-specific setup, see [Koin](koin.md) or [Hilt](hilt.md).
|
||||
|
||||
References:
|
||||
- [Koin](koin.md) — Koin setup, modules, Nav 3 integration, scopes, testing
|
||||
- [Hilt](hilt.md) — Hilt setup, modules, scopes, instrumented testing
|
||||
|
||||
## When to Use Hilt vs Koin
|
||||
|
||||
| Criterion | Hilt | Koin |
|
||||
|---|---|---|
|
||||
| Platform | Android-only | Multiplatform (Android, iOS, Desktop, Web) |
|
||||
| Dependency resolution | Compile-time | Runtime (DSL) or compile-time (Koin Annotations + KSP) |
|
||||
| Error detection | Build-time | Runtime — use `verify()` in tests; KSP annotations add compile-time checks |
|
||||
| Setup complexity | Higher (Gradle plugins, annotations) | Lower (DSL modules); annotations optional |
|
||||
| Compose Multiplatform | Not supported | Full support |
|
||||
| Navigation 3 | `hiltViewModel()` in `entry<T>` blocks; multibinding entry providers — see [navigation-3-di.md](navigation-3-di.md) | `navigation<T>` DSL + `koinEntryProvider()` — see [navigation-3-di.md](navigation-3-di.md) |
|
||||
| Navigation 2 | `hiltViewModel()` in composable destinations; graph-scoped VMs — see [navigation-2-di.md](navigation-2-di.md) | `koinViewModel()`, `koinNavViewModel()`, `sharedKoinViewModel()` — see [navigation-2-di.md](navigation-2-di.md) |
|
||||
|
||||
**Default recommendation:**
|
||||
- **Android-only projects**: Hilt is the default recommendation. Koin is also valid if the team prefers it or the project may become multiplatform later.
|
||||
- **Compose Multiplatform projects**: Use Koin — Hilt does not support non-Android targets.
|
||||
|
||||
For detailed setup, modules, scoping, and testing, see the dedicated references: [koin.md](koin.md) and [hilt.md](hilt.md). This file stays focused on the framework decision — do not duplicate implementation details here.
|
||||
|
||||
## Shared DI Concepts
|
||||
|
||||
These principles apply regardless of framework choice:
|
||||
|
||||
### Constructor injection as the default
|
||||
|
||||
Always inject dependencies through the constructor. Field injection (`@Inject lateinit var`) couples the class to the DI framework and makes testing harder.
|
||||
|
||||
### Interface-based design
|
||||
|
||||
Bind interfaces to implementations — repositories, data sources, and platform services should be defined as interfaces. This enables swapping implementations in tests without mocking the DI framework.
|
||||
|
||||
```kotlin
|
||||
// Define interface
|
||||
interface UserRepository {
|
||||
suspend fun getUser(id: String): User
|
||||
}
|
||||
|
||||
// Bind implementation via DI
|
||||
// Koin: single<UserRepository> { UserRepositoryImpl(get()) }
|
||||
// Hilt: @Binds abstract fun bind(impl: UserRepositoryImpl): UserRepository
|
||||
```
|
||||
|
||||
### Scope lifecycle alignment
|
||||
|
||||
| Scope | When to use | Examples |
|
||||
|---|---|---|
|
||||
| Singleton | Lives for app lifetime | API client, database, analytics |
|
||||
| Activity-retained | Survives config changes | User session, auth state |
|
||||
| ViewModel-scoped | Tied to a feature screen | Feature-specific calculators, validators |
|
||||
| Factory (new each time) | Stateless or short-lived | Formatters, mappers |
|
||||
|
||||
Over-scoping wastes memory; under-scoping creates redundant instances. Match the scope to the dependency's actual lifetime.
|
||||
|
||||
### Module organization
|
||||
|
||||
Organize DI modules by feature, not by type. Each feature module declares its own dependencies:
|
||||
|
||||
```text
|
||||
feature-product/
|
||||
ProductModule → repository, calculator, validator, ViewModel
|
||||
feature-settings/
|
||||
SettingsModule → repository, ViewModel
|
||||
core/
|
||||
CoreModule → API client, database, platform bindings
|
||||
```
|
||||
|
||||
Combine feature modules in the app module. Platform-specific bindings go in platform modules (`androidMain`, `iosMain`).
|
||||
|
||||
### Testing principle
|
||||
|
||||
Swap real implementations with fakes via DI configuration — don't mock the DI framework itself. Both Koin and Hilt support module replacement in tests:
|
||||
- **Koin**: `appModule.verify()` for graph verification, module overrides in tests
|
||||
- **Hilt**: `@TestInstallIn` to replace modules, `hilt-android-testing` for instrumented tests
|
||||
|
||||
For ViewModel unit testing (framework-agnostic), see [testing.md](testing.md).
|
||||
@@ -1,298 +0,0 @@
|
||||
# Gradle & Build Configuration
|
||||
|
||||
Gradle best practices for Compose Multiplatform (CMP) and Android-only Jetpack Compose projects, including AGP 9+ changes.
|
||||
|
||||
## 1. Project Structure Patterns
|
||||
|
||||
### CMP Project (Android + iOS + optional Desktop)
|
||||
|
||||
```text
|
||||
MyApp/
|
||||
├── settings.gradle.kts
|
||||
├── build.gradle.kts # Root: plugins with apply false
|
||||
├── gradle.properties
|
||||
├── gradle/libs.versions.toml
|
||||
├── composeApp/ # KMP shared library
|
||||
│ └── src/{commonMain,androidMain,iosMain,jvmMain}
|
||||
├── androidApp/ # Thin Android shell (required by AGP 9+)
|
||||
├── desktopApp/ # Optional: Desktop JVM entry point
|
||||
└── iosApp/ # Xcode project (NOT a Gradle module)
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- `composeApp` is a KMP library containing all shared code
|
||||
- `androidApp` is a thin shell — AGP 9's `com.android.application` cannot coexist with KMP plugin
|
||||
- `iosApp` is a standalone Xcode project, not a Gradle module
|
||||
|
||||
### Android-Only Project
|
||||
|
||||
```text
|
||||
MyApp/
|
||||
├── settings.gradle.kts
|
||||
├── build.gradle.kts
|
||||
├── gradle/libs.versions.toml
|
||||
├── app/ # Main application module
|
||||
├── feature-*/ # Feature modules
|
||||
└── core-*/ # Shared modules (ui, data, domain)
|
||||
```
|
||||
|
||||
## 2. Version Catalog (`libs.versions.toml`)
|
||||
|
||||
Four sections: `[versions]`, `[libraries]`, `[plugins]`, `[bundles]`. Use comment headers to group by domain.
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
# ---- Build ----
|
||||
agp = "9.0.1"
|
||||
kotlin = "2.3.10"
|
||||
ksp = "2.3.10-1.0.30"
|
||||
compose-multiplatform = "1.10.1"
|
||||
|
||||
# ---- AndroidX ----
|
||||
androidx-lifecycle = "2.9.1"
|
||||
|
||||
# ---- Networking ----
|
||||
ktor = "3.2.0"
|
||||
|
||||
[libraries]
|
||||
# BOM-managed libs omit version.ref
|
||||
compose-bom = { module = "androidx.compose:compose-bom", version = "2026.03.00" }
|
||||
compose-material3 = { module = "androidx.compose.material3:material3" }
|
||||
|
||||
# Regular libs use version.ref
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
|
||||
[plugins]
|
||||
android-application = { id = "com.android.application", version.ref = "agp" }
|
||||
android-kmp-library = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
||||
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||
compose-multiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||
|
||||
```
|
||||
|
||||
**Naming:** kebab-case keys → dot accessors (`koin-core` → `libs.koin.core`). BOM-managed libraries omit `version.ref`. Use `# ---- Section ----` comment headers to visually group entries by domain.
|
||||
|
||||
## 3. Bundles (`[bundles]`)
|
||||
|
||||
`[bundles]` groups libraries **always added together** into one alias — convenience only; no change to resolution or alignment. Create bundles when two+ libs are added as a set; group by domain and use comment headers like `[versions]`/`[libraries]`.
|
||||
|
||||
```kotlin
|
||||
implementation(libs.bundles.androidx.base)
|
||||
implementation(libs.bundles.androidx.lifecycle)
|
||||
```
|
||||
|
||||
**CMP projects** rarely need bundles because `commonMain.dependencies` already groups everything in one place.
|
||||
|
||||
## 4. `settings.gradle.kts`
|
||||
|
||||
```kotlin
|
||||
rootProject.name = "MyApp"
|
||||
enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS")
|
||||
|
||||
pluginManagement {
|
||||
repositories {
|
||||
google { content { includeGroupByRegex("com\\.android.*|com\\.google.*|androidx.*") } }
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
dependencyResolutionManagement {
|
||||
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||
repositories {
|
||||
google { content { includeGroupByRegex("com\\.android.*|com\\.google.*|androidx.*") } }
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
include(":composeApp", ":androidApp")
|
||||
```
|
||||
|
||||
## 5. Root `build.gradle.kts`
|
||||
|
||||
Declare plugins with `apply false`. No `allprojects {}`/`subprojects {}` — use convention plugins at scale.
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.android.application) apply false
|
||||
alias(libs.plugins.android.kmp.library) apply false
|
||||
alias(libs.plugins.kotlin.multiplatform) apply false
|
||||
alias(libs.plugins.compose.multiplatform) apply false
|
||||
alias(libs.plugins.compose.compiler) apply false
|
||||
alias(libs.plugins.ksp) apply false
|
||||
}
|
||||
```
|
||||
|
||||
## 6. AGP 9+ Changes
|
||||
|
||||
### Built-in Kotlin
|
||||
|
||||
AGP 9 includes Kotlin. Do NOT apply `org.jetbrains.kotlin.android` in Android app modules.
|
||||
|
||||
```kotlin
|
||||
// ✅ AGP 9+
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
}
|
||||
```
|
||||
|
||||
### New KMP Library Plugin
|
||||
|
||||
Use `com.android.kotlin.multiplatform.library` for KMP modules targeting Android.
|
||||
|
||||
```kotlin
|
||||
// ✅ AGP 9+ KMP module
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kmp.library)
|
||||
}
|
||||
```
|
||||
|
||||
### New `compileSdk` DSL
|
||||
|
||||
```kotlin
|
||||
// Application modules
|
||||
android {
|
||||
compileSdk { version = release(35) }
|
||||
}
|
||||
|
||||
// KMP library modules (inside kotlin { androidLibrary {} })
|
||||
kotlin {
|
||||
androidLibrary {
|
||||
compileSdk = 35 // Integer still works here
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Kotlin Block Outside Android
|
||||
|
||||
On AGP 9+, `kotlin {}` must NOT be nested inside `android {}`.
|
||||
|
||||
```kotlin
|
||||
// ✅ Correct
|
||||
kotlin { jvmToolchain(21) }
|
||||
android { /* ... */ }
|
||||
|
||||
// ❌ Wrong
|
||||
android { kotlin { jvmToolchain(21) } }
|
||||
```
|
||||
|
||||
## 7. Module Patterns
|
||||
|
||||
### CMP Shared Module (`composeApp`)
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.multiplatform)
|
||||
alias(libs.plugins.android.kmp.library)
|
||||
alias(libs.plugins.compose.multiplatform)
|
||||
alias(libs.plugins.compose.compiler)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidLibrary {
|
||||
namespace = "com.example.shared"
|
||||
compileSdk = 35
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
listOf(iosArm64(), iosSimulatorArm64()).forEach {
|
||||
it.binaries.framework {
|
||||
baseName = "ComposeApp"
|
||||
isStatic = true
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(compose.runtime)
|
||||
implementation(compose.material3)
|
||||
// Add other common dependencies
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
listOf("kspAndroid", "kspIosArm64", "kspIosSimulatorArm64").forEach {
|
||||
add(it, libs.room.compiler)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Android app module: thin shell with `com.android.application` + `compose-compiler` plugins, depending on `projects.composeApp`.
|
||||
|
||||
Desktop module: KMP plugin + `compose.desktop.currentOs`, entry point via `compose.desktop { application { mainClass = "..." } }`.
|
||||
|
||||
## 8. `gradle.properties`
|
||||
|
||||
```properties
|
||||
# Performance
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.parallel=true
|
||||
org.gradle.jvmargs=-Xmx4g -XX:+UseParallelGC
|
||||
|
||||
# Kotlin
|
||||
kotlin.code.style=official
|
||||
|
||||
# Android
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||
# CMP (if targeting iOS)
|
||||
kotlin.mpp.enableCInteropCommonization=true
|
||||
```
|
||||
|
||||
## 9. KSP Wiring
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
listOf("kspAndroid", "kspIosArm64", "kspIosSimulatorArm64").forEach {
|
||||
add(it, libs.room.compiler)
|
||||
add(it, libs.koin.ksp.compiler)
|
||||
}
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("KOIN_USE_COMPOSE_VIEWMODEL", "true")
|
||||
}
|
||||
|
||||
tasks.withType<KotlinCompile>().configureEach {
|
||||
dependsOn(tasks.withType<KspTask>())
|
||||
}
|
||||
```
|
||||
|
||||
## 10. Composite Builds
|
||||
|
||||
Conditional `includeBuild` for local library dev (use `if (path.exists())` so CI works without checkout):
|
||||
|
||||
```kotlin
|
||||
// settings.gradle.kts
|
||||
val localLibPath = file("../my-library")
|
||||
if (localLibPath.exists()) {
|
||||
includeBuild(localLibPath) {
|
||||
dependencySubstitution {
|
||||
substitute(module("com.example:my-library")).using(project(":my-library"))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 11. Convention Plugins
|
||||
|
||||
Introduce convention plugins when 3+ modules duplicate config. Use `build-logic/` included build pattern. Not needed for small projects (≤3 modules).
|
||||
|
||||
## 12. Do / Don't
|
||||
|
||||
| Do | Don't |
|
||||
|----|-------|
|
||||
| Version catalog for all dependencies | Hardcode versions in build files |
|
||||
| Enable configuration cache, build cache | Use `buildSrc` for versions |
|
||||
| `TYPESAFE_PROJECT_ACCESSORS` | `allprojects {}`/`subprojects {}` blocks |
|
||||
| Separate `androidApp` from KMP shared (AGP 9+) | Apply `kotlin-android` on AGP 9+ |
|
||||
| `apply false` at root | Nest `kotlin {}` inside `android {}` |
|
||||
| Conditional `includeBuild` for local dev | Unconditional `includeBuild` (breaks CI) |
|
||||
| Convention plugins for 3+ modules | Over-engineer small projects |
|
||||
@@ -1,278 +0,0 @@
|
||||
# Dependency Injection with Hilt (Android-only)
|
||||
|
||||
Compile-time DI for Android-only Compose projects with ViewModel and lifecycle integration.
|
||||
|
||||
For Hilt vs Koin decision guidance and shared DI concepts, see [dependency-injection.md](dependency-injection.md). For Koin (multiplatform), see [koin.md](koin.md).
|
||||
|
||||
References:
|
||||
- [Hilt Android docs](https://developer.android.com/training/dependency-injection/hilt-android)
|
||||
- [Hilt with Compose](https://developer.android.com/develop/ui/compose/libraries#hilt)
|
||||
- [Hilt ViewModel](https://developer.android.com/training/dependency-injection/hilt-jetpack#viewmodels)
|
||||
|
||||
## Setup
|
||||
|
||||
### Gradle configuration
|
||||
|
||||
```kotlin
|
||||
// project-level build.gradle.kts
|
||||
plugins {
|
||||
alias(libs.plugins.hilt) apply false
|
||||
}
|
||||
|
||||
// app-level build.gradle.kts
|
||||
plugins {
|
||||
alias(libs.plugins.android.application)
|
||||
alias(libs.plugins.kotlin.android)
|
||||
alias(libs.plugins.hilt)
|
||||
alias(libs.plugins.ksp)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(libs.hilt.android)
|
||||
ksp(libs.hilt.compiler)
|
||||
|
||||
// Compose integration
|
||||
implementation(libs.hilt.navigation.compose)
|
||||
}
|
||||
```
|
||||
|
||||
## Application Class
|
||||
|
||||
```kotlin
|
||||
@HiltAndroidApp
|
||||
class MyApplication : Application()
|
||||
```
|
||||
|
||||
Every Hilt app requires an `@HiltAndroidApp`-annotated Application class.
|
||||
|
||||
## Modules
|
||||
|
||||
### @Provides — when you need to construct the instance yourself
|
||||
|
||||
Use for third-party classes, builder patterns, or anything where you control creation logic:
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AppModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideApiClient(): ApiClient = ApiClient()
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideDatabase(@ApplicationContext context: Context): AppDatabase =
|
||||
Room.databaseBuilder(context, AppDatabase::class.java, "app.db").build()
|
||||
}
|
||||
```
|
||||
|
||||
### @Binds — when mapping an interface to its implementation
|
||||
|
||||
Use for interface-to-implementation bindings. More efficient than `@Provides` (no method body needed, generates less code):
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
abstract class RepositoryModule {
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindUserRepository(impl: UserRepositoryImpl): UserRepository
|
||||
|
||||
@Binds
|
||||
@Singleton
|
||||
abstract fun bindProductRepository(impl: ProductRepositoryImpl): ProductRepository
|
||||
}
|
||||
```
|
||||
|
||||
### Feature-scoped modules — @InstallIn(ViewModelComponent)
|
||||
|
||||
Use `ViewModelComponent` when dependencies are only needed within a ViewModel and should be cleaned up when the ViewModel is cleared. Use `SingletonComponent` for app-wide shared instances (API clients, databases).
|
||||
|
||||
```kotlin
|
||||
@Module
|
||||
@InstallIn(ViewModelComponent::class)
|
||||
object ProductModule {
|
||||
@Provides
|
||||
@ViewModelScoped
|
||||
fun provideProductCalculator(): ProductCalculator = ProductCalculator()
|
||||
|
||||
@Provides
|
||||
@ViewModelScoped
|
||||
fun provideProductValidator(): ProductValidator = ProductValidator()
|
||||
}
|
||||
```
|
||||
|
||||
## ViewModel Injection
|
||||
|
||||
### Basic ViewModel
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class ProductViewModel @Inject constructor(
|
||||
private val calculator: ProductCalculator,
|
||||
private val repository: ProductRepository,
|
||||
) : ViewModel() {
|
||||
// StateFlow<State>, Channel<Effect>, onEvent() — see architecture.md
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel with SavedStateHandle — when params come from navigation routes
|
||||
|
||||
Hilt auto-injects `SavedStateHandle` populated with navigation arguments. Use when the ViewModel receives serializable route params:
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class DetailViewModel @Inject constructor(
|
||||
private val repository: ItemRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
private val itemId: String = checkNotNull(savedStateHandle["itemId"])
|
||||
|
||||
init {
|
||||
loadItem(itemId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### ViewModel with @AssistedInject — when params come from the caller, not navigation
|
||||
|
||||
Use when the ViewModel needs values that aren't in navigation arguments (e.g., a complex object, a callback, or a value computed in the composable):
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel(assistedFactory = DetailViewModel.Factory::class)
|
||||
class DetailViewModel @AssistedInject constructor(
|
||||
private val repository: ItemRepository,
|
||||
@Assisted private val itemId: String,
|
||||
) : ViewModel() {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(itemId: String): DetailViewModel
|
||||
}
|
||||
}
|
||||
|
||||
// Caller passes the value explicitly
|
||||
@Composable
|
||||
fun DetailRoute(itemId: String) {
|
||||
val viewModel = hiltViewModel<DetailViewModel, DetailViewModel.Factory> { factory ->
|
||||
factory.create(itemId)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `SavedStateHandle` for navigation arguments (simpler, survives process death). Use `@AssistedInject` only when `SavedStateHandle` can't carry the data.
|
||||
|
||||
## Compose Integration
|
||||
|
||||
```kotlin
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() { /* setContent { ... } */ }
|
||||
|
||||
@Composable
|
||||
fun ProductRoute(viewModel: ProductViewModel = hiltViewModel()) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
ProductScreen(state = state, onEvent = viewModel::onEvent)
|
||||
}
|
||||
```
|
||||
|
||||
Every Activity hosting Hilt-injected composables requires `@AndroidEntryPoint`. Use the standard MVI Route/Screen pattern: collect state via `collectAsStateWithLifecycle()`, collect effects via `CollectEffect`, pass `onEvent` to Screen.
|
||||
|
||||
## Navigation Integration
|
||||
|
||||
**For Nav 3 + Hilt patterns** (entry-scoped ViewModels, multibinding entry providers), see [navigation-3-di.md](navigation-3-di.md) — that is the preferred approach for new projects. For Nav 2 + Hilt patterns (graph-scoped VMs, `@AssistedInject`), see [navigation-2-di.md](navigation-2-di.md).
|
||||
|
||||
The patterns below apply to **Navigation Compose (Nav 2)** projects that use Hilt. They remain valid for existing codebases but should not be the starting point for new work.
|
||||
|
||||
### Nav 2: hiltViewModel() in composable destinations
|
||||
|
||||
Use `hiltViewModel()` as a default parameter in any `composable()` destination — each destination gets its own ViewModel instance scoped to the `NavBackStackEntry`.
|
||||
|
||||
### Nav 2: Navigation-scoped ViewModel — when multiple destinations share state
|
||||
|
||||
Use when destinations within the same Nav 2 navigation graph need a shared ViewModel (e.g., a multi-step checkout flow where Cart, Shipping, and Payment screens share `CheckoutViewModel`):
|
||||
|
||||
```kotlin
|
||||
val parentEntry = remember(navController) {
|
||||
navController.getBackStackEntry("checkout_graph")
|
||||
}
|
||||
val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
|
||||
```
|
||||
|
||||
## Scopes
|
||||
|
||||
| Scope | Lifecycle | Use case |
|
||||
|---|---|---|
|
||||
| `@Singleton` | Application | API clients, databases, shared preferences |
|
||||
| `@ActivityRetainedScoped` | Activity (survives config change) | User session, auth state |
|
||||
| `@ViewModelScoped` | ViewModel | Feature-specific services, calculators |
|
||||
| `@ActivityScoped` | Activity instance | Activity-bound resources |
|
||||
| `@FragmentScoped` | Fragment instance | Fragment-bound resources (rare in Compose) |
|
||||
|
||||
## Hilt in MVI
|
||||
|
||||
The only Hilt-specific wiring is `@HiltViewModel` + `@Inject constructor`. The MVI pattern (Event/State/Effect, `onEvent()`) is framework-agnostic — DI only affects constructor injection and injection-site calls.
|
||||
|
||||
## Testing
|
||||
|
||||
For ViewModel unit tests (no Hilt needed), see [testing.md](testing.md).
|
||||
|
||||
### Dependencies
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
androidTestImplementation(libs.hilt.android.testing)
|
||||
kspAndroidTest(libs.hilt.compiler)
|
||||
}
|
||||
```
|
||||
|
||||
### Hilt instrumented testing
|
||||
|
||||
```kotlin
|
||||
@HiltAndroidTest
|
||||
class CreateItemScreenTest {
|
||||
@get:Rule(order = 0)
|
||||
val hiltRule = HiltAndroidRule(this)
|
||||
|
||||
@get:Rule(order = 1)
|
||||
val composeRule = createAndroidComposeRule<MainActivity>()
|
||||
|
||||
@Inject
|
||||
lateinit var repository: ItemRepository
|
||||
|
||||
@Before
|
||||
fun setup() {
|
||||
hiltRule.inject()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun saveButton_enabledWhenFieldsFilled() {
|
||||
composeRule.setContent {
|
||||
CreateItemScreen(
|
||||
state = CreateItemState(title = "Test", amount = "100"),
|
||||
onEvent = {},
|
||||
)
|
||||
}
|
||||
|
||||
composeRule.onNodeWithText("Save").assertIsEnabled()
|
||||
}
|
||||
}
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
@TestInstallIn(components = [SingletonComponent::class], replaces = [RepositoryModule::class])
|
||||
object FakeRepositoryModule {
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideItemRepository(): ItemRepository = FakeItemRepository()
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it is harmful | Better approach |
|
||||
|---|---|---|
|
||||
| Injecting Context into ViewModel | Lifecycle mismatch, leaks | Use `@ApplicationContext` or move platform code to Repository |
|
||||
| Injecting Activity/Fragment into ViewModel | Memory leaks | Pass data via SavedStateHandle or route arguments |
|
||||
| `@Inject` on ViewModel without `@HiltViewModel` | ViewModel not managed by Hilt | Always use `@HiltViewModel` with `@Inject constructor` |
|
||||
| Manual ViewModel instantiation | Bypasses Hilt injection | Use `hiltViewModel()` in Compose |
|
||||
| Installing ViewModel dependencies in `SingletonComponent` | Unnecessary lifecycle extension | Use `ViewModelComponent` or `ViewModelScoped` |
|
||||
@@ -1,198 +0,0 @@
|
||||
# Image Loading (Coil 3 + Compose Multiplatform)
|
||||
|
||||
Production-focused guidance for loading remote and local images in Jetpack Compose and Compose Multiplatform using Coil 3.
|
||||
|
||||
References:
|
||||
- [Coil Compose docs](https://coil-kt.github.io/coil/compose/)
|
||||
- [Coil Getting Started](https://coil-kt.github.io/coil/getting_started/)
|
||||
- [Coil Image Loaders](https://coil-kt.github.io/coil/image_loaders/)
|
||||
- [Coil Network Images](https://coil-kt.github.io/coil/network/)
|
||||
- [Coil Extending the Image Pipeline](https://raw.githubusercontent.com/coil-kt/coil/main/docs/image_pipeline.md)
|
||||
- [Coil SVG support](https://coil-kt.github.io/coil/svgs/)
|
||||
- [Coil Recipes](https://coil-kt.github.io/coil/recipes/)
|
||||
- [Coil 3 upgrade notes](https://coil-kt.github.io/coil/upgrading_to_coil3/)
|
||||
|
||||
## Setup and Dependencies
|
||||
|
||||
Coil 3 does not include network loading by default. Add `coil-compose` and exactly one network integration.
|
||||
|
||||
```kotlin
|
||||
// Shared for Compose UI
|
||||
implementation("io.coil-kt.coil3:coil-compose:<version>")
|
||||
|
||||
// Android/JVM only
|
||||
implementation("io.coil-kt.coil3:coil-network-okhttp:<version>")
|
||||
|
||||
// Multiplatform-friendly network options
|
||||
implementation("io.coil-kt.coil3:coil-network-ktor2:<version>")
|
||||
// or
|
||||
implementation("io.coil-kt.coil3:coil-network-ktor3:<version>")
|
||||
```
|
||||
|
||||
If you use Ktor networking, add platform engines for your targets (Android, Apple, JVM).
|
||||
|
||||
## Choose the Right API
|
||||
|
||||
| Use case | Best API | Why |
|
||||
|---|---|---|
|
||||
| Most image rendering in UI | `AsyncImage` | Best default; resolves image size from constraints |
|
||||
| Need a `Painter` or manual request restart/state observation | `rememberAsyncImagePainter` | More control, lower-level painter API |
|
||||
| Need composable slots per loading state and need first-frame state correctness | `SubcomposeAsyncImage` | Slot API with immediate state, but slower |
|
||||
|
||||
### Performance note
|
||||
|
||||
`SubcomposeAsyncImage` uses subcomposition and is generally less suitable for dense `LazyColumn`/`LazyGrid` cells. Prefer `AsyncImage` for list-heavy screens.
|
||||
|
||||
## Default AsyncImage Pattern
|
||||
|
||||
Prefer one reusable pattern for avatar/card/list images:
|
||||
|
||||
```kotlin
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(LocalPlatformContext.current)
|
||||
.data(imageUrl)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
placeholder = painterResource(Res.drawable.placeholder),
|
||||
error = painterResource(Res.drawable.image_error),
|
||||
fallback = painterResource(Res.drawable.image_fallback),
|
||||
contentDescription = title, // null only for decorative images
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.clip(RoundedCornerShape(12.dp)),
|
||||
)
|
||||
```
|
||||
|
||||
For accessibility, provide `contentDescription` unless the image is purely decorative.
|
||||
|
||||
## ImageLoader Configuration
|
||||
|
||||
Create one shared `ImageLoader` per app process. Multiple loaders fragment memory/disk caches and reduce hit rates.
|
||||
|
||||
```kotlin
|
||||
setSingletonImageLoaderFactory { context ->
|
||||
ImageLoader.Builder(context)
|
||||
.crossfade(true)
|
||||
.memoryCache {
|
||||
MemoryCache.Builder()
|
||||
.maxSizePercent(context, 0.25)
|
||||
.build()
|
||||
}
|
||||
.diskCache {
|
||||
DiskCache.Builder()
|
||||
.directory(context.cacheDir.resolve("image_cache"))
|
||||
.maxSizePercent(0.02)
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
}
|
||||
```
|
||||
|
||||
For libraries, prefer `coil-core` and pass your own `ImageLoader` instead of overriding the app singleton.
|
||||
|
||||
## Extended Pipeline
|
||||
|
||||
Coil's pipeline is extensible and executes in this order:
|
||||
|
||||
1. `Interceptor`
|
||||
2. `Mapper`
|
||||
3. `Keyer`
|
||||
4. `Fetcher`
|
||||
5. `Decoder`
|
||||
|
||||
Register custom components once when building `ImageLoader`:
|
||||
|
||||
```kotlin
|
||||
val imageLoader = ImageLoader.Builder(context)
|
||||
.components {
|
||||
add(CustomCacheInterceptor())
|
||||
add(ItemMapper())
|
||||
add(ItemKeyer())
|
||||
add(PartialUrlFetcher.Factory())
|
||||
add(SvgDecoder.Factory())
|
||||
}
|
||||
.build()
|
||||
```
|
||||
|
||||
### Decision table: Need X -> Customize Y
|
||||
|
||||
| Need | Customize | Why |
|
||||
|---|---|---|
|
||||
| Add request retry/short-circuit/global policy | `Interceptor` | Wraps entire pipeline; can modify/proceed/return early. Cross-cutting: timeouts, retries, custom cache layer, metrics. |
|
||||
| Accept custom model type in `.data(...)` | `Mapper` | Normalizes domain data to a supported type (for example `ProductImage` → URL string). |
|
||||
| Keep custom data memory-cacheable | `Keyer` | Stable memory cache key segment for custom models. If a custom `Fetcher` introduces a new data type, add a matching `Keyer` so memory caching works. |
|
||||
| Support custom source/protocol | `Fetcher.Factory<T>` | Data transport: custom scheme, signed URLs, alternate client. |
|
||||
| Decode custom encoded data/format | `Decoder.Factory` | Converts fetched source to a renderable image. |
|
||||
| Add auth headers for all image requests | Network fetcher + client interceptor | Centralized networking behavior. |
|
||||
| Per-request dynamic headers | `ImageRequest.httpHeaders(...)` | Scoped request-level networking metadata. |
|
||||
|
||||
### Compose Multiplatform placement
|
||||
|
||||
- Domain-level model wrappers and mapping intent in `commonMain`; OkHttp/Android-only client setup in platform source sets; prefer Ktor network for broad CMP.
|
||||
- One shared `ImageLoader` configuration per app entry point.
|
||||
|
||||
### Pipeline anti-patterns
|
||||
|
||||
| Anti-pattern | Problem |
|
||||
|---|---|
|
||||
| Registering pipeline components per screen/composable; duplicating what request options already cover (`httpHeaders`, cache policy, size resolver) | Fragments caches; redundant complexity |
|
||||
| Custom `Fetcher` without a stable `Keyer`; volatile data (timestamps, random values) in cache keys | Poor memory cache hit rate |
|
||||
| Heavy blocking work in `Interceptor` without bounds/timeouts; platform-only types in `commonMain` pipeline contracts | Jank; wrong layering for CMP |
|
||||
|
||||
For HTTP cache semantics with OkHttp, register `CacheControlCacheStrategy` with the network fetcher when you need response `Cache-Control` behavior.
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
Default request cache policies are enabled; override `memoryCachePolicy` / `diskCachePolicy` / `networkCachePolicy` only when you need non-default behavior.
|
||||
|
||||
### Stable keys for smooth transitions
|
||||
|
||||
Use stable keys when the same logical image appears in multiple places (list → detail, shared element).
|
||||
|
||||
```kotlin
|
||||
ImageRequest.Builder(LocalPlatformContext.current)
|
||||
.data(url)
|
||||
.memoryCacheKey("image-$id")
|
||||
.placeholderMemoryCacheKey("image-$id")
|
||||
.build()
|
||||
```
|
||||
|
||||
`placeholderMemoryCacheKey` helps avoid visual flashes by reusing an in-memory result as the placeholder for the next request.
|
||||
|
||||
## Transformations
|
||||
|
||||
Use `.transformations(...)` (for example `RoundedCornersTransformation`) only for pixel-level changes to decoded output. Prefer `Modifier.clip` / shapes for UI-only effects; transformations materialize bitmaps and can collapse animated images to one frame.
|
||||
|
||||
## SVG
|
||||
|
||||
```kotlin
|
||||
implementation("io.coil-kt.coil3:coil-svg:<version>")
|
||||
```
|
||||
|
||||
Coil auto-detects and decodes SVGs after this dependency is on the classpath. Register `SvgDecoder.Factory()` explicitly only if you need non-default wiring.
|
||||
|
||||
## Compose Multiplatform Resources
|
||||
|
||||
To load images from Compose Multiplatform resources with Coil, use `Res.getUri(...)`:
|
||||
|
||||
```kotlin
|
||||
AsyncImage(
|
||||
model = Res.getUri("drawable/sample.jpg"),
|
||||
contentDescription = null,
|
||||
)
|
||||
```
|
||||
|
||||
Use string URIs from `Res.getUri`. Direct compile-safe handles like `Res.drawable.someImage` are not currently passed directly as Coil models.
|
||||
|
||||
## List and Shared-Element Patterns
|
||||
|
||||
- Prefer `AsyncImage` in list cells.
|
||||
- Keep item size predictable to avoid layout thrash.
|
||||
- Use stable item keys (`LazyColumn`/`LazyGrid`) and stable cache keys (`memoryCacheKey`) together.
|
||||
- For shared-element transitions, reuse memory cache key + placeholder memory cache key between source and destination.
|
||||
- If you must use `rememberAsyncImagePainter`, provide a size resolver (`rememberConstraintsSizeResolver`) to avoid always loading original size.
|
||||
|
||||
## Preview, Testing, and Debugging
|
||||
|
||||
- Compose preview has no network access by default. Use `LocalAsyncImagePreviewHandler` to inject deterministic preview images.
|
||||
- Enable `DebugLogger` only in debug builds when diagnosing request/decoder/cache behavior.
|
||||
- For testability in large apps, inject a custom/fake `ImageLoader` instead of relying on global singleton state.
|
||||
@@ -1,208 +0,0 @@
|
||||
# iOS Swift Interop
|
||||
|
||||
## Kotlin → Swift Naming
|
||||
|
||||
| Kotlin construct | Swift equivalent |
|
||||
|---|---|
|
||||
| Top-level function `fun foo()` in `Bar.kt` | `BarKt.foo()` |
|
||||
| `object AppInit` | `AppInit.shared` |
|
||||
| `companion object` member | Direct on class: `MyClass.value` |
|
||||
| `sealed class UiState` | Class hierarchy (or SKIE exhaustive enum) |
|
||||
| `suspend fun load()` | SKIE: `async func load()` |
|
||||
|
||||
```swift
|
||||
// Entry point — top-level function in MainViewController.kt
|
||||
let controller = MainViewControllerKt.MainViewController()
|
||||
```
|
||||
|
||||
## Nullability & Type Bridging
|
||||
|
||||
| Kotlin | Swift | Notes |
|
||||
|---|---|---|
|
||||
| `String` | `String` | Non-null bridged directly |
|
||||
| `String?` | `String?` | Optional bridged directly |
|
||||
| `Int` / `Long` | `Int32` / `Int64` | Not Swift `Int` — use explicit cast |
|
||||
| `Unit` | `KotlinUnit` | Awkward return — avoid in public API |
|
||||
|
||||
**Collections:** Kotlin `List<T>` bridges to `[T]` as a read-only copy. Mutability and structural sharing are lost at the boundary. Pass collections across the boundary sparingly — batch, don't iterate.
|
||||
|
||||
## Coroutines → Swift Async
|
||||
|
||||
| Approach | When to use | Trade-off |
|
||||
|---|---|---|
|
||||
| **SKIE** | Default for new CMP projects | Automatic `async`/`AsyncSequence`; adds build plugin |
|
||||
| **KMP-NativeCoroutines** | Existing projects already using it | Annotation-driven; SKIE preferred for greenfield |
|
||||
|
||||
### SKIE (recommended)
|
||||
|
||||
SKIE converts `suspend` functions to Swift `async` automatically:
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
suspend fun loadItems(): List<Item> = repository.getAll()
|
||||
```
|
||||
```swift
|
||||
let items = try await viewModel.loadItems() // SKIE-generated async bridge
|
||||
```
|
||||
|
||||
## Flow → Swift Observation
|
||||
|
||||
This is how iOS observes `StateFlow<UiState>` — the critical MVI bridge.
|
||||
|
||||
### SKIE: Flow → AsyncSequence
|
||||
|
||||
SKIE converts `Flow` to `AsyncSequence`:
|
||||
|
||||
```swift
|
||||
func observeState() async {
|
||||
for await state in viewModel.state { self.uiState = state }
|
||||
}
|
||||
```
|
||||
|
||||
### Manual StateFlow wrapper
|
||||
|
||||
Without SKIE, expose a callback-based observer from Kotlin; Swift holds the returned cancel closure and invokes it in `deinit`.
|
||||
|
||||
```kotlin
|
||||
// iosMain
|
||||
class IosStateCollector<T>(private val flow: StateFlow<T>, private val scope: CoroutineScope) {
|
||||
private var job: Job? = null
|
||||
fun observe(onChange: (T) -> Unit): () -> Unit {
|
||||
job = scope.launch(Dispatchers.Main) { flow.collect { onChange(it) } }
|
||||
return { job?.cancel() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Sealed Classes in Swift
|
||||
|
||||
### Without SKIE — non-exhaustive
|
||||
|
||||
```swift
|
||||
if let loading = state as? UiState.Loading { showSpinner() }
|
||||
else if let success = state as? UiState.Success { render(items: success.items) }
|
||||
else if let error = state as? UiState.Error { showError(error.message) }
|
||||
// No exhaustiveness check — silent bugs when a new sealed subclass is added
|
||||
```
|
||||
|
||||
### With SKIE — exhaustive Swift enum
|
||||
|
||||
```swift
|
||||
switch onEnum(of: state) {
|
||||
case .loading: showSpinner()
|
||||
case .success(let s): render(items: s.items)
|
||||
case .error(let e): showError(e.message)
|
||||
} // Compiler error if a new sealed subclass is added
|
||||
```
|
||||
|
||||
### Edge cases
|
||||
|
||||
- **Generic sealed classes** — SKIE cannot convert generics to Swift enums; use concrete types at the iOS boundary (e.g., `ItemListState` not `ListState<Item>`)
|
||||
- **Nested sealed hierarchies** — SKIE flattens names: `UiState.Error.Network` → `.errorNetwork`
|
||||
- **Opt out** — annotate with `@SealedInterop.Disabled` to skip SKIE conversion for a specific class
|
||||
|
||||
## iOS API Design Rules
|
||||
|
||||
- Keep the public API surface small — use `internal` visibility + `@HiddenFromObjC` to exclude Kotlin internals from the generated ObjC header
|
||||
- Avoid generics in public iOS-facing API — ObjC/Swift interop erases or boxes them unpredictably
|
||||
- Prefer data classes over deep class hierarchies at the boundary — simpler Swift mapping
|
||||
- Set `isStatic = true` in framework configuration for static linkage (smaller binary, faster startup)
|
||||
- Minimize Kotlin↔Swift boundary crossings in hot paths — batch data, don't iterate across the boundary
|
||||
- Avoid `suspend` functions that return `Unit` — Swift receives `KotlinUnit`, requiring callers to discard it explicitly
|
||||
- Expose sealed classes with concrete (non-generic) type parameters for SKIE compatibility
|
||||
|
||||
## Compose in SwiftUI App
|
||||
|
||||
Use `ComposeUIViewController` to embed a Compose screen inside an existing SwiftUI application. This is the standard path for incremental adoption — add Compose features to a SwiftUI app without rewriting native screens.
|
||||
|
||||
### Kotlin entry point
|
||||
|
||||
```kotlin
|
||||
// iosMain
|
||||
fun MainViewController(): UIViewController = ComposeUIViewController { App() }
|
||||
```
|
||||
|
||||
### Swift bridge
|
||||
|
||||
Wrap the `UIViewController` in a `UIViewControllerRepresentable` for SwiftUI:
|
||||
|
||||
```swift
|
||||
struct ComposeView: UIViewControllerRepresentable {
|
||||
func makeUIViewController(context: Context) -> UIViewController {
|
||||
MainViewControllerKt.MainViewController()
|
||||
}
|
||||
func updateUIViewController(_ uiViewController: UIViewController, context: Context) {}
|
||||
}
|
||||
```
|
||||
|
||||
Use `ComposeView()` anywhere in SwiftUI hierarchy — `NavigationStack`, tab bar, sheet, or as the root view.
|
||||
|
||||
### When to use
|
||||
|
||||
| Scenario | Approach |
|
||||
|---|---|
|
||||
| Entire app is Compose | `ComposeUIViewController` as the root in `@main App` |
|
||||
| Hybrid app — some screens SwiftUI, some Compose | Embed `ComposeView` per-feature inside SwiftUI navigation |
|
||||
| Single Compose widget in a SwiftUI screen | Embed `ComposeView` with a fixed `frame` modifier |
|
||||
|
||||
## Native iOS Views in Compose
|
||||
|
||||
Use `UIKitView` to embed UIKit or SwiftUI components inside a Compose screen. This is how you use platform-native views (maps, camera, webview) that have no Compose equivalent on iOS.
|
||||
|
||||
### `UIKitView` basics
|
||||
|
||||
```kotlin
|
||||
UIKitView(
|
||||
factory = { MKMapView() },
|
||||
modifier = Modifier.size(300.dp),
|
||||
update = { mapView -> mapView.setRegion(region, animated = true) }
|
||||
)
|
||||
```
|
||||
|
||||
- **`factory`** — creates the `UIView` instance once (like `AndroidView`'s factory)
|
||||
- **`update`** — called on recomposition to sync Compose state into the native view
|
||||
- **`modifier`** — standard Compose modifier for sizing and layout
|
||||
|
||||
### Embedding SwiftUI views
|
||||
|
||||
SwiftUI views can't be used directly in `UIKitView`. Wrap them in a `UIHostingController` and pass the controller to a Kotlin factory function:
|
||||
|
||||
```kotlin
|
||||
// iosMain
|
||||
@OptIn(ExperimentalForeignApi::class)
|
||||
fun ComposeEntryPointWithNativeView(
|
||||
createViewController: () -> UIViewController
|
||||
): UIViewController = ComposeUIViewController {
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Text("Compose content above")
|
||||
UIKitViewController(
|
||||
factory = createViewController,
|
||||
modifier = Modifier.size(300.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
```
|
||||
```swift
|
||||
MainViewControllerKt.ComposeEntryPointWithNativeView {
|
||||
UIHostingController(rootView: MySwiftUIMapView())
|
||||
}
|
||||
```
|
||||
|
||||
### Decision table
|
||||
|
||||
| Need | Use |
|
||||
|---|---|
|
||||
| UIKit view (`MKMapView`, `WKWebView`, `AVCaptureSession`) | `UIKitView(factory = { ... })` directly in Kotlin |
|
||||
| SwiftUI view (`Map`, custom SwiftUI component) | Wrap in `UIHostingController`, pass via `UIKitViewController` |
|
||||
| Complex native screen with its own navigation | Keep it in SwiftUI/UIKit, embed Compose screens via `ComposeUIViewController` instead |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Generic `Resource<T>` sealed class exposed to Swift** — SKIE can't convert it; use concrete result types like `ItemListResult`
|
||||
- **Observing StateFlow without cancellation cleanup** — memory leak when the view controller is deallocated
|
||||
- **Returning `Unit` from public API** — becomes `KotlinUnit` in Swift; use a callback or return a meaningful type
|
||||
- **Crossing ObjC boundary in a loop** — each call has marshaling overhead; collect results in Kotlin, return the batch
|
||||
- **Exposing mutable Kotlin collections to Swift** — mutations won't reflect; return immutable snapshots
|
||||
- **Skipping `@HiddenFromObjC`** — pollutes the Swift API surface with internal helpers
|
||||
- **Recreating UIKit views on every recomposition** — `factory` in `UIKitView` runs once; put state-dependent updates in `update`, not `factory`
|
||||
- **Skipping `update` in `UIKitView`** — Compose state changes won't propagate to the native view; always implement `update` to sync mutable properties
|
||||
@@ -1,260 +0,0 @@
|
||||
# Dependency Injection with Koin
|
||||
|
||||
Multiplatform DI for Compose projects with ViewModel, Compose, and Navigation 3 integration.
|
||||
|
||||
For Hilt vs Koin decision guidance and shared DI concepts, see [dependency-injection.md](dependency-injection.md). For Hilt (Android-only), see [hilt.md](hilt.md).
|
||||
|
||||
References:
|
||||
- [Koin for Compose](https://insert-koin.io/docs/reference/koin-compose/compose)
|
||||
- [Koin Navigation 3](https://insert-koin.io/docs/reference/koin-compose/navigation3)
|
||||
|
||||
## Package Selection
|
||||
|
||||
### CMP projects (recommended)
|
||||
|
||||
```kotlin
|
||||
commonMain.dependencies {
|
||||
implementation(platform("io.insert-koin:koin-bom:$koin_version"))
|
||||
implementation("io.insert-koin:koin-core")
|
||||
implementation("io.insert-koin:koin-compose")
|
||||
implementation("io.insert-koin:koin-compose-viewmodel")
|
||||
implementation("io.insert-koin:koin-compose-viewmodel-navigation") // Nav 3
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serialization_version")
|
||||
}
|
||||
```
|
||||
|
||||
### Android-only projects
|
||||
|
||||
```kotlin
|
||||
dependencies {
|
||||
implementation("io.insert-koin:koin-androidx-compose:$koin_version") // includes compose + viewmodel
|
||||
implementation("io.insert-koin:koin-compose-viewmodel-navigation:$koin_version")
|
||||
}
|
||||
```
|
||||
|
||||
| Package | Purpose |
|
||||
|---|---|
|
||||
| `koin-core` | Core DI engine (multiplatform) |
|
||||
| `koin-compose` | Base Compose API (`koinInject`) |
|
||||
| `koin-compose-viewmodel` | ViewModel injection (`koinViewModel`) |
|
||||
| `koin-compose-viewmodel-navigation` | Nav 3 entry provider integration |
|
||||
| `koin-androidx-compose` | Android convenience (includes compose + viewmodel) |
|
||||
|
||||
Platform support: Android, iOS, Desktop — full. Web — experimental.
|
||||
|
||||
## Setup and Starting Koin
|
||||
|
||||
Initialize outside Compose with a shared `initKoin` and platform-specific config lambda:
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
fun initKoin(config: KoinAppDeclaration? = null) {
|
||||
startKoin {
|
||||
config?.invoke(this)
|
||||
modules(appModule, featureModules)
|
||||
}
|
||||
}
|
||||
|
||||
// Android — Application class
|
||||
class MyApplication : Application() {
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
initKoin { androidContext(this@MyApplication); androidLogger() }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
iOS — call from Swift. `do` prefix added because `init` is reserved:
|
||||
|
||||
```swift
|
||||
import ComposeApp
|
||||
@main struct iOSApp: App {
|
||||
init() { InitKoinKt.doInitKoin(config: nil) }
|
||||
var body: some Scene { WindowGroup { ContentView() } }
|
||||
}
|
||||
```
|
||||
|
||||
Alternative — Compose-managed: `KoinApplication(configuration = koinConfiguration { modules(appModule) }) { MainScreen() }`
|
||||
|
||||
## Defining Modules
|
||||
|
||||
```kotlin
|
||||
val appModule = module {
|
||||
// Classic DSL (manual wiring)
|
||||
single<UserRepository> { UserRepositoryImpl() }
|
||||
factory { ProductValidator() }
|
||||
viewModelOf(::ProductViewModel)
|
||||
|
||||
// Compiler Plugin DSL (auto-wiring — requires Koin Compiler Plugin)
|
||||
single<ProductCalculator>() // auto-resolves constructor params
|
||||
single<UserRepositoryImpl>() bind UserRepository::class // bind exposes impl as interface
|
||||
viewModel<ProductViewModel>()
|
||||
}
|
||||
```
|
||||
|
||||
| DSL | Lifecycle | When to use |
|
||||
|---|---|---|
|
||||
| `single { }` | App lifetime (singleton) | Stateless services, repositories, API clients, databases |
|
||||
| `factory { }` | New instance per call | Stateful/short-lived — validators, formatters, use-cases with request state |
|
||||
| `scoped { }` | Bound to a Koin scope | Shared within a flow (e.g., checkout) but not globally |
|
||||
| `viewModelOf(::Class)` | ViewModel lifecycle | Survives recomposition + config changes, cleared when owner destroyed |
|
||||
|
||||
### Annotations (KSP)
|
||||
|
||||
Compile-time safety with multiplatform support. Requires KSP plugin + `koin-annotations`.
|
||||
|
||||
```kotlin
|
||||
plugins { id("com.google.devtools.ksp") }
|
||||
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
implementation("io.insert-koin:koin-annotations:$koin_annotations_version")
|
||||
}
|
||||
sourceSets.named("commonMain").configure {
|
||||
kotlin.srcDir("build/generated/ksp/metadata/commonMain/kotlin")
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
add("kspCommonMainMetadata", "io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
|
||||
add("kspAndroid", "io.insert-koin:koin-ksp-compiler:$koin_annotations_version")
|
||||
// ... add for each target (kspIosArm64, kspIosSimulatorArm64, etc.)
|
||||
}
|
||||
|
||||
ksp {
|
||||
arg("KOIN_USE_COMPOSE_VIEWMODEL", "true") // multiplatform ViewModel DSL
|
||||
arg("KOIN_CONFIG_CHECK", "true") // compile-time verification
|
||||
}
|
||||
```
|
||||
|
||||
| Annotation | Equivalent DSL | Purpose |
|
||||
|---|---|---|
|
||||
| `@Single` | `single { }` | Singleton |
|
||||
| `@Factory` | `factory { }` | New instance each time |
|
||||
| `@KoinViewModel` | `viewModelOf(::Class)` | ViewModel declaration |
|
||||
| `@InjectedParam` | `parametersOf(...)` | Runtime parameter |
|
||||
| `@Module` + `@ComponentScan` | `module { }` | Auto-discover annotated classes in package |
|
||||
|
||||
Use generated `.module` property: `modules(AppModule().module)`.
|
||||
|
||||
### Feature-first module organization
|
||||
|
||||
```kotlin
|
||||
val productModule = module {
|
||||
single<ProductRepository> { ProductRepositoryImpl(get()) }
|
||||
viewModelOf(::ProductViewModel)
|
||||
}
|
||||
val appModule = module { includes(productModule, settingsModule, coreModule) }
|
||||
```
|
||||
|
||||
### Platform-specific implementations
|
||||
|
||||
Use `expect/actual` modules when implementations differ per platform:
|
||||
|
||||
```kotlin
|
||||
// commonMain
|
||||
expect val platformModule: Module
|
||||
|
||||
// androidMain
|
||||
actual val platformModule = module { single<HapticFeedback> { AndroidHapticFeedback(get()) } }
|
||||
|
||||
// iosMain
|
||||
actual val platformModule = module { single<HapticFeedback> { IosHapticFeedback() } }
|
||||
|
||||
startKoin { modules(appModule, platformModule) }
|
||||
```
|
||||
|
||||
For platform dependencies (e.g., Android `Context`) in `expect/actual` classes, use `KoinComponent` with `inject()` — justified because constructors must match across platforms. Avoid `KoinComponent` elsewhere.
|
||||
|
||||
## Injection in Compose
|
||||
|
||||
```kotlin
|
||||
// Any dependency
|
||||
val service: MyService = koinInject()
|
||||
|
||||
// ViewModel — lifecycle-aware
|
||||
val viewModel = koinViewModel<HomeViewModel>()
|
||||
|
||||
// With runtime parameters
|
||||
val viewModel = koinViewModel<DetailViewModel> { parametersOf(itemId) }
|
||||
|
||||
// Keyed — unique instance per entity
|
||||
val viewModel = koinViewModel<DetailViewModel>(key = "detail_$itemId", parameters = { parametersOf(itemId) })
|
||||
```
|
||||
|
||||
Inject as default parameters for testability: `fun MyScreen(service: MyService = koinInject())`.
|
||||
|
||||
| Function | Platform | When to use |
|
||||
|---|---|---|
|
||||
| `koinInject<T>()` | All | Non-ViewModel dependencies inside `@Composable` |
|
||||
| `koinViewModel<T>()` | All | ViewModel — lifecycle-aware, survives recomposition |
|
||||
| `koinActivityViewModel<T>()` | Android | Share ViewModel across all composables in an Activity |
|
||||
| `koinEntryProvider<T>()` | All | Wire Nav 3 `NavDisplay` to Koin `navigation<T>` entries |
|
||||
| `parametersOf(...)` | All | Pass runtime values to `koinViewModel` or `koinInject` |
|
||||
| `get<T>()` | All | Resolve inside `module { }` only — never in composables |
|
||||
|
||||
## Navigation 3 Integration
|
||||
|
||||
Two approaches for Nav 3 + DI. For full patterns, entry-scoped ViewModels, and modularization, see [navigation-3-di.md](navigation-3-di.md).
|
||||
|
||||
```kotlin
|
||||
// Koin DSL — entries declared in modules
|
||||
val appModule = module {
|
||||
navigation<HomeRoute> { HomeScreen(viewModel = koinViewModel()) }
|
||||
navigation<DetailRoute> { route -> DetailScreen(viewModel = koinViewModel { parametersOf(route.id) }) }
|
||||
}
|
||||
NavDisplay(backStack = backStack, onBack = { backStack.removeLastOrNull() }, entryProvider = koinEntryProvider())
|
||||
```
|
||||
|
||||
For Nav 2 patterns, see [navigation-2-di.md](navigation-2-di.md). For migration, see [navigation-migration.md](navigation-migration.md).
|
||||
|
||||
## Scopes
|
||||
|
||||
```kotlin
|
||||
val appModule = module {
|
||||
scope<CheckoutFlow> {
|
||||
scoped { CheckoutState() }
|
||||
viewModel<CheckoutViewModel>()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`scope<T>` works on all platforms. On Android, `activityRetainedScope { }` survives config changes (same idea, platform-specific).
|
||||
|
||||
## Koin in MVI
|
||||
|
||||
MVI is framework-agnostic — see [architecture.md](architecture.md). The Koin-specific parts are constructor injection and `koinViewModel()`:
|
||||
|
||||
```kotlin
|
||||
class ProductViewModel(private val repository: ProductRepository) : ViewModel() {
|
||||
// StateFlow<State>, Channel<Effect>, onEvent() — see architecture.md
|
||||
}
|
||||
// Module: viewModelOf(::ProductViewModel)
|
||||
// Route: val viewModel = koinViewModel<ProductViewModel>()
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
`verify()` performs a dry-run check — catches missing declarations before runtime:
|
||||
|
||||
```kotlin
|
||||
class KoinModuleCheck : KoinTest {
|
||||
@Test
|
||||
fun verifyAllModules() {
|
||||
appModule.verify(extraTypes = listOf(SavedStateHandle::class))
|
||||
}
|
||||
}
|
||||
// commonTest.dependencies { implementation("io.insert-koin:koin-test:$koin_version") }
|
||||
```
|
||||
|
||||
For ViewModel event→state→effect testing, see [testing.md](testing.md).
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it is harmful | Better approach |
|
||||
|---|---|---|
|
||||
| `factory { MyViewModel() }` for ViewModels | Not lifecycle-aware, new instance on recomposition | `viewModelOf(::MyViewModel)` |
|
||||
| Not using `parametersOf` for runtime params | Constructor params unresolved | `koinViewModel { parametersOf(id) }` |
|
||||
| `koin-compose` without `koin-compose-viewmodel` | `koinViewModel()` unavailable | Add `koin-compose-viewmodel` |
|
||||
| Calling `startKoin` multiple times | `KoinAppAlreadyStartedException` | Call once, use `loadKoinModules` for dynamic additions |
|
||||
| Android `Context` in `commonMain` modules | Breaks multiplatform | `expect/actual` platform modules |
|
||||
@@ -1,161 +0,0 @@
|
||||
# Lists & Grids
|
||||
|
||||
Compose patterns for lazy layouts, applied within MVI architecture.
|
||||
|
||||
## LazyColumn and LazyRow
|
||||
|
||||
Only compose visible items — use for large or dynamic lists. For small fixed lists (<10 items), prefer `Column`/`Row`.
|
||||
|
||||
```kotlin
|
||||
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||
item { HeaderSection() }
|
||||
items(items = users, key = { it.id }) { user ->
|
||||
UserRow(user = user, onOpen = onOpenUser)
|
||||
}
|
||||
item { FooterSection() }
|
||||
}
|
||||
```
|
||||
|
||||
### DSL patterns
|
||||
|
||||
- `item { }` — single composable (header, footer, divider)
|
||||
- `items(list, key) { }` — from a list with stable keys
|
||||
- `itemsIndexed(list) { index, item -> }` — when index is needed
|
||||
|
||||
## Keys
|
||||
|
||||
Always provide stable, unique keys when the list can change.
|
||||
|
||||
```kotlin
|
||||
// GOOD: stable domain ID
|
||||
items(users, key = { it.id }) { user -> UserRow(user) }
|
||||
|
||||
// BAD: index-based — state corrupts on reorder/remove
|
||||
items(users, key = { index }) { user -> UserRow(user) }
|
||||
|
||||
// BAD: no key — Compose can't distinguish items reliably
|
||||
items(users) { user -> UserRow(user) }
|
||||
```
|
||||
|
||||
**Rule:** Use domain IDs, not indices. Without stable keys, removing an item corrupts the state of remaining items.
|
||||
|
||||
## ContentType for Recycling
|
||||
|
||||
Use `contentType` when rendering different item types to enable layout reuse:
|
||||
|
||||
```kotlin
|
||||
sealed class FeedItem {
|
||||
data class Header(val title: String) : FeedItem()
|
||||
data class Post(val id: String, val content: String) : FeedItem()
|
||||
}
|
||||
|
||||
LazyColumn {
|
||||
items(
|
||||
items = feedItems,
|
||||
key = { when (it) { is FeedItem.Header -> it.title; is FeedItem.Post -> it.id } },
|
||||
contentType = { when (it) { is FeedItem.Header -> "header"; is FeedItem.Post -> "post" } }
|
||||
) { item ->
|
||||
when (item) {
|
||||
is FeedItem.Header -> SectionHeader(item.title)
|
||||
is FeedItem.Post -> PostCard(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Without `contentType`, all items compete for one reuse pool. With it, items reuse layout state efficiently within their type.
|
||||
|
||||
## Grids and Pager
|
||||
|
||||
### LazyVerticalGrid
|
||||
|
||||
```kotlin
|
||||
// Fixed columns
|
||||
LazyVerticalGrid(columns = GridCells.Fixed(3)) {
|
||||
items(items, key = { it.id }) { item -> GridItem(item) }
|
||||
}
|
||||
|
||||
// Adaptive columns (responsive) — preferred for responsive layouts
|
||||
LazyVerticalGrid(columns = GridCells.Adaptive(minSize = 120.dp)) {
|
||||
items(items, key = { it.id }) { item -> GridItem(item) }
|
||||
}
|
||||
```
|
||||
|
||||
### LazyVerticalStaggeredGrid
|
||||
|
||||
For Pinterest-style variable-height layouts:
|
||||
|
||||
```kotlin
|
||||
LazyVerticalStaggeredGrid(columns = StaggeredGridCells.Fixed(2)) {
|
||||
items(images, key = { it.id }) { image -> ImageCard(image) }
|
||||
}
|
||||
```
|
||||
|
||||
### HorizontalPager / VerticalPager
|
||||
|
||||
```kotlin
|
||||
val pagerState = rememberPagerState(pageCount = { pages.size })
|
||||
|
||||
HorizontalPager(state = pagerState) { page ->
|
||||
PageContent(pages[page])
|
||||
}
|
||||
|
||||
// Programmatic scroll
|
||||
LaunchedEffect(targetPage) { pagerState.animateScrollToPage(targetPage) }
|
||||
```
|
||||
|
||||
## Scroll State and Derived Logic
|
||||
|
||||
```kotlin
|
||||
val listState = rememberLazyListState()
|
||||
|
||||
// GOOD: derivedStateOf for scroll-dependent UI
|
||||
val showScrollToTop by remember {
|
||||
derivedStateOf { listState.firstVisibleItemIndex > 2 }
|
||||
}
|
||||
|
||||
LazyColumn(state = listState) {
|
||||
items(items, key = { it.id }) { item -> ItemRow(item) }
|
||||
}
|
||||
|
||||
if (showScrollToTop) {
|
||||
FloatingActionButton(onClick = { scope.launch { listState.animateScrollToItem(0) } }) {
|
||||
Icon(Icons.Default.ArrowUpward, contentDescription = "Scroll to top")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Keep `LazyListState` local — do not put scroll position in the MVI ViewModel state.
|
||||
|
||||
## Nested Scrolling
|
||||
|
||||
```kotlin
|
||||
// BAD: verticalScroll inside LazyColumn — two scroll containers fight for input
|
||||
LazyColumn {
|
||||
item {
|
||||
Column(Modifier.verticalScroll(rememberScrollState())) { /* conflict */ }
|
||||
}
|
||||
}
|
||||
|
||||
// OK: nested LazyRow inside LazyColumn (different axes)
|
||||
LazyColumn {
|
||||
item { LazyRow { items(horizontalItems) { HorizontalCard(it) } } }
|
||||
items(verticalItems) { VerticalRow(it) }
|
||||
}
|
||||
```
|
||||
|
||||
For complex scenarios, use `Modifier.nestedScroll()` with a custom `NestedScrollConnection`.
|
||||
|
||||
## List Anti-Patterns
|
||||
|
||||
| Anti-pattern | Fix |
|
||||
|---|---|
|
||||
| No keys on mutable lists | Always provide stable domain ID keys |
|
||||
| Index-based keys | Use `it.id`, not position index |
|
||||
| Expensive computation inside item lambda | Compute upstream in reducer, pass pre-computed data |
|
||||
| Inline `filter`/`sort` inside `items {}` | Sort/filter in reducer or ViewModel before emitting state |
|
||||
| `LazyColumn` for 5 fixed items | Use `Column` for small fixed lists |
|
||||
| Creating new objects in `key` lambda | Use primitive stable identifiers |
|
||||
| Missing `contentType` on multi-type lists | Provide `contentType` for efficient reuse |
|
||||
|
||||
For paginated lists with network/database loading, see [Paging 3](paging.md).
|
||||
@@ -1,246 +0,0 @@
|
||||
# Material 3 Theming & Components
|
||||
|
||||
## TL;DR Defaults
|
||||
|
||||
| Concern | Default |
|
||||
|---|---|
|
||||
| Theme entry point | `MaterialTheme(colorScheme, typography, shapes)` wrapping app content |
|
||||
| Dynamic color | Enable on Android 12+; fall back to brand `ColorScheme` on older APIs |
|
||||
| Dark/light | Follow system via `isSystemInDarkTheme()`; expose user override if needed |
|
||||
| Color pairing | Always pair `primary`/`onPrimary`, `surface`/`onSurface`, `*Container`/`on*Container` |
|
||||
| Typography | Use default M3 type scale; override only specific slots for branding |
|
||||
| Shapes | Use default M3 shape scale; override per-slot (`small`, `medium`, `large`) |
|
||||
| Scaffold | Use `Scaffold` for screens with app bars, FAB, snackbar, or bottom bar |
|
||||
| Navigation | `NavigationSuiteScaffold` auto-switches bar/rail by window size |
|
||||
| Snackbar | `SnackbarHostState` in Route; show via `Effect` from ViewModel |
|
||||
| Bottom sheet | `ModalBottomSheet` with `SheetState`; control via `show()`/`hide()` |
|
||||
| Dialog | `AlertDialog` for simple confirm/dismiss; custom `Dialog` for complex content |
|
||||
| Adaptive layout | Derive window size class once at app level; pass down as state |
|
||||
|
||||
## Theming Baseline
|
||||
|
||||
### Theme Setup
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppTheme(
|
||||
darkTheme: Boolean = isSystemInDarkTheme(),
|
||||
dynamicColor: Boolean = true,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val colorScheme = when {
|
||||
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
|
||||
val context = LocalContext.current
|
||||
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
|
||||
}
|
||||
darkTheme -> DarkColorScheme
|
||||
else -> LightColorScheme
|
||||
}
|
||||
MaterialTheme(
|
||||
colorScheme = colorScheme,
|
||||
typography = AppTypography,
|
||||
shapes = AppShapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Key Rules
|
||||
|
||||
- Define `LightColorScheme` and `DarkColorScheme` using `lightColorScheme()` / `darkColorScheme()`.
|
||||
- Generate brand colors via [Material Theme Builder](https://m3.material.io/theme-builder) for guaranteed tonal palettes.
|
||||
- Dynamic color is Android-only; CMP projects fall back to brand schemes on non-Android targets.
|
||||
|
||||
## Color Roles and Dark/Light
|
||||
|
||||
### Role Pairing Rules
|
||||
|
||||
| Container | Content on it |
|
||||
|---|---|
|
||||
| `primary` | `onPrimary` |
|
||||
| `primaryContainer` | `onPrimaryContainer` |
|
||||
| `secondary` | `onSecondary` |
|
||||
| `secondaryContainer` | `onSecondaryContainer` |
|
||||
| `tertiary` | `onTertiary` |
|
||||
| `tertiaryContainer` | `onTertiaryContainer` |
|
||||
| `surface` | `onSurface` |
|
||||
| `surfaceVariant` | `onSurfaceVariant` |
|
||||
| `error` | `onError` |
|
||||
| `errorContainer` | `onErrorContainer` |
|
||||
|
||||
### Accessibility Guardrails
|
||||
|
||||
- Always use the correct `on*` color for text/icons on a container.
|
||||
- Do not mix unrelated pairs (e.g., `tertiaryContainer` background with `primaryContainer` text).
|
||||
- M3 tonal palettes guarantee 3:1+ contrast when paired correctly.
|
||||
|
||||
### Do / Don't
|
||||
|
||||
| Do | Don't |
|
||||
|---|---|
|
||||
| `containerColor = primary`, `contentColor = onPrimary` | `containerColor = primary`, `contentColor = tertiaryContainer` |
|
||||
| Access colors via `MaterialTheme.colorScheme.*` | Hardcode hex colors in components |
|
||||
| Test both light and dark themes | Assume light-only usage |
|
||||
|
||||
## Typography and Shapes
|
||||
|
||||
### Typography
|
||||
|
||||
M3 defines 15 text styles across 5 categories:
|
||||
|
||||
| Category | Sizes |
|
||||
|---|---|
|
||||
| Display | `displayLarge`, `displayMedium`, `displaySmall` |
|
||||
| Headline | `headlineLarge`, `headlineMedium`, `headlineSmall` |
|
||||
| Title | `titleLarge`, `titleMedium`, `titleSmall` |
|
||||
| Body | `bodyLarge`, `bodyMedium`, `bodySmall` |
|
||||
| Label | `labelLarge`, `labelMedium`, `labelSmall` |
|
||||
|
||||
**Default**: Use M3 defaults. Override individual slots for brand fonts:
|
||||
|
||||
```kotlin
|
||||
val AppTypography = Typography(
|
||||
titleLarge = TextStyle(fontFamily = BrandFont, fontWeight = FontWeight.SemiBold, fontSize = 22.sp)
|
||||
)
|
||||
```
|
||||
|
||||
### Shapes
|
||||
|
||||
M3 shape scale: `extraSmall`, `small`, `medium`, `large`, `extraLarge`.
|
||||
|
||||
**Default**: Use M3 defaults. Override only when brand requires specific corner radii:
|
||||
|
||||
```kotlin
|
||||
val AppShapes = Shapes(
|
||||
medium = RoundedCornerShape(12.dp),
|
||||
large = RoundedCornerShape(16.dp)
|
||||
)
|
||||
```
|
||||
|
||||
## Component Decision Matrix
|
||||
|
||||
### Scaffold
|
||||
|
||||
| Slot | When to use |
|
||||
|---|---|
|
||||
| `topBar` | Screen has a top app bar |
|
||||
| `bottomBar` | Screen has bottom navigation or bottom app bar |
|
||||
| `floatingActionButton` | Primary action needs FAB |
|
||||
| `snackbarHost` | Screen can show snackbars |
|
||||
| `content` | Main screen content; receives `PaddingValues` to apply |
|
||||
|
||||
**Rule**: Always apply `innerPadding` from `Scaffold` to content root.
|
||||
|
||||
### Top App Bar
|
||||
|
||||
| Variant | Use case | Scroll / default |
|
||||
|---|---|---|
|
||||
| `TopAppBar` (small) | Simple screens, minimal actions | Default: `pinnedScrollBehavior` unless you need collapse |
|
||||
| `CenterAlignedTopAppBar` | Single primary action, centered title | Same bar family as small |
|
||||
| `MediumTopAppBar` | Moderate navigation, collapsible on scroll | `exitUntilCollapsedScrollBehavior` (also `enterAlwaysScrollBehavior` where needed) |
|
||||
| `LargeTopAppBar` | Hero screens, prominent title, collapsible | Same scroll behavior family as medium |
|
||||
|
||||
### Navigation
|
||||
|
||||
| Window size | Component |
|
||||
|---|---|
|
||||
| Compact (phones portrait) | `NavigationBar` (bottom) |
|
||||
| Medium/Expanded (tablets, landscape) | `NavigationRail` (side) |
|
||||
| Auto-switch | `NavigationSuiteScaffold` |
|
||||
|
||||
**Default**: Use `NavigationSuiteScaffold` for apps with 3-5 top-level destinations. It adapts automatically.
|
||||
|
||||
```kotlin
|
||||
NavigationSuiteScaffold(
|
||||
navigationSuiteItems = {
|
||||
destinations.forEach { dest ->
|
||||
item(
|
||||
selected = currentDest == dest,
|
||||
onClick = { currentDest = dest },
|
||||
icon = { Icon(dest.icon, contentDescription = null) },
|
||||
label = { Text(dest.label) }
|
||||
)
|
||||
}
|
||||
}
|
||||
) { DestinationContent(currentDest) }
|
||||
```
|
||||
|
||||
### Bottom Sheet
|
||||
|
||||
| Type | Use case |
|
||||
|---|---|
|
||||
| `ModalBottomSheet` | Overlays content, dismissible |
|
||||
| `BottomSheetScaffold` | Persistent sheet integrated with screen |
|
||||
|
||||
**State control**: Use `rememberModalBottomSheetState()` + `SheetState.show()`/`hide()`.
|
||||
|
||||
**MVI pattern**: ViewModel emits `Effect.ShowSheet`; Route composable calls `sheetState.show()` in `LaunchedEffect`.
|
||||
|
||||
### Snackbar
|
||||
|
||||
**Setup**: `SnackbarHostState` remembered in Route; passed to `Scaffold.snackbarHost`.
|
||||
|
||||
**Pattern**:
|
||||
```kotlin
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
LaunchedEffect(Unit) {
|
||||
viewModel.effects.collect { effect ->
|
||||
when (effect) {
|
||||
is Effect.ShowSnackbar -> {
|
||||
val result = snackbarHostState.showSnackbar(effect.message, effect.actionLabel)
|
||||
if (result == SnackbarResult.ActionPerformed) viewModel.onEvent(Event.SnackbarAction)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { /* ... */ }
|
||||
```
|
||||
|
||||
### Dialog
|
||||
|
||||
| Type | Use case |
|
||||
|---|---|
|
||||
| `AlertDialog` | Simple title + text + confirm/dismiss buttons |
|
||||
| `Dialog` + `Card` | Complex content, forms, custom layouts |
|
||||
|
||||
**MVI pattern**: Dialog visibility controlled by `state.showDialog: Boolean`. Confirm/dismiss dispatch events.
|
||||
|
||||
## Adaptive Layout Defaults
|
||||
|
||||
### Window Size Classes
|
||||
|
||||
| Class | Width breakpoint | Typical devices |
|
||||
|---|---|---|
|
||||
| Compact | < 600dp | Phones portrait |
|
||||
| Medium | 600dp – 840dp | Tablets portrait, large unfolded |
|
||||
| Expanded | ≥ 840dp | Tablets landscape, desktop |
|
||||
|
||||
**Rule**: Compute `WindowSizeClass` once at app/activity level via `currentWindowAdaptiveInfo()`. Pass derived layout decisions down as state.
|
||||
|
||||
### Canonical Layouts
|
||||
|
||||
| Layout | Use case | Compose component |
|
||||
|---|---|---|
|
||||
| List-detail | Master list + detail pane | `ListDetailPaneScaffold`, `NavigableListDetailPaneScaffold` |
|
||||
| Supporting pane | Main content + supplementary info | `SupportingPaneScaffold`, `NavigableSupportingPaneScaffold` |
|
||||
| Feed | Grid of browsable content | `LazyVerticalGrid` with `GridCells.Adaptive` |
|
||||
|
||||
**Default**: For list-detail apps, use `NavigableListDetailPaneScaffold` which handles pane visibility and back navigation.
|
||||
|
||||
**Adaptive navigation:** read `windowSizeClass` (or related adaptive info) once at the root and pass derived flags (e.g. whether to show a top app bar) into your main screen composable.
|
||||
|
||||
## M2 to M3 Migration Notes
|
||||
|
||||
| M2 | M3 |
|
||||
|---|---|
|
||||
| `Colors` | `ColorScheme` |
|
||||
| `lightColors()` / `darkColors()` | `lightColorScheme()` / `darkColorScheme()` |
|
||||
| `BottomNavigation` | `NavigationBar` |
|
||||
| `BottomNavigationItem` | `NavigationBarItem` |
|
||||
| `ModalBottomSheetLayout` | `ModalBottomSheet` |
|
||||
| `ModalDrawer` | `ModalNavigationDrawer` |
|
||||
| `Scaffold` with `scaffoldState` | `Scaffold` with `snackbarHost` slot |
|
||||
| `BackdropScaffold` | `BottomSheetScaffold` or custom |
|
||||
| `TopAppBar` elevation | `TopAppBar` with `scrollBehavior` |
|
||||
|
||||
**Key change**: M3 `Scaffold` no longer has `drawerState`. Use `ModalNavigationDrawer` wrapping `Scaffold` instead.
|
||||
@@ -1,220 +0,0 @@
|
||||
# MVI (Event/State/Effect)
|
||||
|
||||
MVI pattern: sealed Event contract processed by a single `onEvent()` entry point. Use when the project has chosen MVI.
|
||||
|
||||
For shared architecture concepts (state owner selection, domain layer, module rules), see [architecture.md](architecture.md).
|
||||
|
||||
## The 3 MVI Types
|
||||
|
||||
A non-trivial screen using MVI defines 3 types: `Event`, `State`, `Effect`.
|
||||
|
||||
### Event
|
||||
|
||||
User actions from UI: button clicks, field changes, lifecycle-start, retry, refresh, back press. Events are the **only** input from the UI into the screen state holder, processed by a single `onEvent()` function.
|
||||
|
||||
### State
|
||||
|
||||
Immutable data class that fully describes what the screen should render. Given the same state, the screen always looks the same. One state per screen, owned by the screen state holder via `StateFlow<State>`.
|
||||
|
||||
State should be **equality-friendly** — use `data class` with immutable collections. Computed properties (`val hasRequiredFields get() = name.isNotBlank()`) are acceptable for trivial derivations. Store canonical values; derive display values at the UI boundary.
|
||||
|
||||
### Effect
|
||||
|
||||
One-off UI commands that don't belong in state: navigate, show snackbar, trigger haptic, copy/share, open browser.
|
||||
|
||||
**Why effects are not state:** if you model "show snackbar" as a boolean in state, you need "consume" logic to flip it back — a classic source of bugs. Effects fire once and are gone.
|
||||
|
||||
## Event Naming
|
||||
|
||||
Events should be named from the **user's perspective** — what happened, not what should happen.
|
||||
|
||||
| Good | Bad |
|
||||
|---|---|
|
||||
| `OnSaveClick` | `SaveCategory` |
|
||||
| `OnTitleChanged` | `UpdateTitle` |
|
||||
| `OnRetryClick` | `RetryRequest` |
|
||||
| `OnBackClick` | `NavigateBack` |
|
||||
|
||||
The event describes a user action; the ViewModel decides how to handle it.
|
||||
|
||||
## State Modeling
|
||||
|
||||
Use immutable `data class` with computed properties for derivations. For detailed guidance (forms, calculators, avoiding duplicated state), see [architecture.md](architecture.md) — State Modeling for Forms and Calculators.
|
||||
|
||||
## Effect Delivery
|
||||
|
||||
For Channel vs SharedFlow guidance, see [architecture.md](architecture.md) — Effect Delivery. Default: `Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()`.
|
||||
|
||||
## Event Processing Flow
|
||||
|
||||
```text
|
||||
UI gesture / lifecycle signal
|
||||
→ Event dispatched via onEvent()
|
||||
→ ViewModel processes the event in a when() block
|
||||
→ Synchronous events: updateState { copy(...) }
|
||||
→ Side effects: sendEffect(effect)
|
||||
→ Async work: viewModelScope.launch { ... }
|
||||
→ On async completion: updateState { copy(...) } + sendEffect(...)
|
||||
```
|
||||
|
||||
**Key insight:** `onEvent()` is the single decision point. It decides what happens for each event — update state, send an effect, launch async work, or some combination. This keeps all event→reaction logic in one place.
|
||||
|
||||
## Screen State Holder Anatomy
|
||||
|
||||
A screen state holder using MVI has three responsibilities:
|
||||
|
||||
1. **State ownership** — holds `MutableStateFlow<State>`, exposes `StateFlow<State>`
|
||||
2. **Effect delivery** — holds `Channel<Effect>` or the project's equivalent, exposes `Flow<Effect>`
|
||||
3. **Event processing** — implements `onEvent()` to handle all events
|
||||
|
||||
State is updated via a thread-safe `update` function (e.g., `MutableStateFlow.update { it.copy(...) }` or a wrapper like `updateState { copy(...) }`). Effects are sent via `channel.trySend(effect)`.
|
||||
|
||||
## UI Rendering Boundary
|
||||
|
||||
### Route composable
|
||||
|
||||
Obtains the screen state holder (via `koinViewModel()`, `hiltViewModel()`, manual construction), collects state once via lifecycle-aware collector, collects effects via `CollectEffect` or equivalent, binds navigation/snackbar/sheet/platform APIs.
|
||||
|
||||
### Screen composable
|
||||
|
||||
Stateless render function receiving state plus `onEvent: (Event) -> Unit` callback.
|
||||
|
||||
### Leaf composables
|
||||
|
||||
Render sub-state, emit specific callbacks, keep only tiny visual-local state. Do not pass `onEvent` to reusable leaves — adapt to specific callbacks.
|
||||
|
||||
### Domain and Data Layer Boundaries
|
||||
|
||||
See [architecture.md](architecture.md) — Domain Layer and Where Logic Belongs.
|
||||
|
||||
## When MVI Is Appropriate
|
||||
|
||||
- Project already uses MVI with a base class or convention
|
||||
- Screen has many user actions and you want them enumerated in one sealed type
|
||||
- Team values explicit event contracts for debugging, analytics, or time-travel debugging
|
||||
- You need exhaustive `when` handling for all UI actions
|
||||
- Complex screens with interrelated state transitions
|
||||
|
||||
## Code Examples
|
||||
|
||||
### BAD: business logic inside composables
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun LoanCalculatorScreen() {
|
||||
var amountText by rememberSaveable { mutableStateOf("") }
|
||||
var rateText by rememberSaveable { mutableStateOf("") }
|
||||
var yearsText by rememberSaveable { mutableStateOf("") }
|
||||
// Calculation/validation omitted — belongs in state holder, not here.
|
||||
Column {
|
||||
OutlinedTextField(value = amountText, onValueChange = { amountText = it })
|
||||
OutlinedTextField(value = rateText, onValueChange = { rateText = it })
|
||||
OutlinedTextField(value = yearsText, onValueChange = { yearsText = it })
|
||||
Text("Monthly payment: …")
|
||||
Button(onClick = { /* … */ }) { Text("Calculate") }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Problems: logic and validation live in the composable, hard to test, and recomposition becomes the execution model.
|
||||
|
||||
### GOOD: MVI contract — Event, State, Effect
|
||||
|
||||
```kotlin
|
||||
sealed interface CreateItemEvent {
|
||||
data class OnTitleChanged(val title: String) : CreateItemEvent
|
||||
data class OnAmountChanged(val amount: String) : CreateItemEvent
|
||||
data object OnSaveClick : CreateItemEvent
|
||||
data object OnBackClick : CreateItemEvent
|
||||
}
|
||||
|
||||
data class CreateItemState(
|
||||
val title: String = "",
|
||||
val amount: String = "",
|
||||
val isSaving: Boolean = false,
|
||||
val errors: Map<String, String> = emptyMap()
|
||||
) {
|
||||
val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
|
||||
}
|
||||
|
||||
sealed interface CreateItemEffect {
|
||||
data object NavigateBack : CreateItemEffect
|
||||
data class ShowMessage(val text: String) : CreateItemEffect
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: ViewModel with onEvent
|
||||
|
||||
Full `save()` (validation + `viewModelScope.launch`): identical body to [mvvm.md](mvvm.md) — **GOOD: ViewModel with named functions**; here it is invoked from `onEvent` instead of public named functions.
|
||||
|
||||
```kotlin
|
||||
class CreateItemViewModel(
|
||||
private val repository: ItemRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(CreateItemState())
|
||||
val state: StateFlow<CreateItemState> = _state.asStateFlow()
|
||||
|
||||
private val _effect = Channel<CreateItemEffect>(Channel.BUFFERED)
|
||||
val effect: Flow<CreateItemEffect> = _effect.receiveAsFlow()
|
||||
|
||||
fun onEvent(event: CreateItemEvent) {
|
||||
when (event) {
|
||||
is CreateItemEvent.OnTitleChanged -> _state.update { it.copy(title = event.title, errors = it.errors - "title") }
|
||||
is CreateItemEvent.OnAmountChanged -> _state.update { it.copy(amount = event.amount, errors = it.errors - "amount") }
|
||||
CreateItemEvent.OnSaveClick -> save()
|
||||
CreateItemEvent.OnBackClick -> _effect.trySend(CreateItemEffect.NavigateBack)
|
||||
}
|
||||
}
|
||||
|
||||
// save(): validate, set isSaving, launch coroutine, update state, trySend ShowMessage / NavigateBack on success or failure
|
||||
private fun save() { /* … */ }
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: Same pattern with a base class or interface
|
||||
|
||||
`class CreateItemViewModel(...) : ViewModel(), MviHost<CreateItemEvent, CreateItemState, CreateItemEffect>` — same `onEvent` / `save()` shape; `updateState` / `sendEffect` from the host. Full base-class pattern: [clean-code.md](clean-code.md), [architecture.md](architecture.md).
|
||||
|
||||
### GOOD: Route/Screen/Leaf split
|
||||
|
||||
Layering: [architecture.md](architecture.md) — **State Collection and Slicing**. Full Route + `CollectEffect` sample: [mvvm.md](mvvm.md) — Route/Screen/Leaf (swap named callbacks for `onEvent`).
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun CreateItemRoute(vm: CreateItemViewModel = koinViewModel(), snackbar: SnackbarHostState, onBack: () -> Unit) {
|
||||
val state by vm.state.collectAsStateWithLifecycle()
|
||||
CollectEffect(vm.effect) { e -> when (e) {
|
||||
CreateItemEffect.NavigateBack -> onBack()
|
||||
is CreateItemEffect.ShowMessage -> snackbar.showSnackbar(e.text)
|
||||
}}
|
||||
CreateItemScreen(state, vm::onEvent)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateItemScreen(state: CreateItemState, onEvent: (CreateItemEvent) -> Unit) {
|
||||
Column {
|
||||
OutlinedTextField(state.title, { onEvent(CreateItemEvent.OnTitleChanged(it)) })
|
||||
OutlinedTextField(state.amount, { onEvent(CreateItemEvent.OnAmountChanged(it)) })
|
||||
Button(onClick = { onEvent(CreateItemEvent.OnSaveClick) }, enabled = !state.isSaving && state.canSave) {
|
||||
Text(if (state.isSaving) "Saving..." else "Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: Event model for form-heavy screens
|
||||
|
||||
```kotlin
|
||||
enum class FormField { Area, MaterialRate, LaborRate, TaxPercent, Notes }
|
||||
|
||||
sealed interface FormEvent {
|
||||
data class FieldChanged(val field: FormField, val raw: String) : FormEvent
|
||||
data class IncludeWasteChanged(val enabled: Boolean) : FormEvent
|
||||
data object SubmitClicked : FormEvent
|
||||
data object RetryClicked : FormEvent
|
||||
data object ScreenShown : FormEvent
|
||||
data object ClearClicked : FormEvent
|
||||
}
|
||||
```
|
||||
|
||||
Pragmatic default for large forms: specific intent names for screen-level actions, generic `FieldChanged(field, raw)` only when many fields are structurally similar.
|
||||
@@ -1,236 +0,0 @@
|
||||
# MVVM (ViewModel with Named Functions)
|
||||
|
||||
MVVM pattern: ViewModel with named public functions instead of sealed events. Use when the project has chosen MVVM.
|
||||
|
||||
For shared architecture concepts (state owner selection, domain layer, module rules), see [architecture.md](architecture.md).
|
||||
|
||||
## The 2 MVVM Types
|
||||
|
||||
A non-trivial screen using MVVM defines 2 types: `State`, `Effect`. User actions call named ViewModel functions directly instead of dispatching sealed events.
|
||||
|
||||
### State
|
||||
|
||||
Immutable data class that fully describes what the screen should render. Given the same state, the screen always looks the same. One state per screen, owned by the ViewModel via `StateFlow<State>`.
|
||||
|
||||
State should be **equality-friendly** — use `data class` with immutable collections. Computed properties (`val hasRequiredFields get() = name.isNotBlank()`) are acceptable for trivial derivations. Store canonical values; derive display values at the UI boundary.
|
||||
|
||||
### Effect
|
||||
|
||||
One-off UI commands that don't belong in state: navigate, show snackbar, trigger haptic, copy/share, open browser.
|
||||
|
||||
**Why effects are not state:** if you model "show snackbar" as a boolean in state, you need "consume" logic to flip it back — a classic source of bugs. Effects fire once and are gone.
|
||||
|
||||
## State Modeling
|
||||
|
||||
Use immutable `data class` with computed properties for derivations. For detailed guidance (forms, calculators, avoiding duplicated state), see [architecture.md](architecture.md) — State Modeling for Forms and Calculators.
|
||||
|
||||
## Effect Delivery
|
||||
|
||||
For Channel vs SharedFlow guidance, see [architecture.md](architecture.md) — Effect Delivery. Default: `Channel<Effect>(Channel.BUFFERED)` with `receiveAsFlow()`.
|
||||
|
||||
### Effects from Named Functions
|
||||
|
||||
Effects are emitted directly from named functions instead of an `onEvent()` dispatcher:
|
||||
|
||||
```kotlin
|
||||
fun onBackClick() {
|
||||
_effect.trySend(CreateItemEffect.NavigateBack)
|
||||
}
|
||||
|
||||
fun save() {
|
||||
// ... validation and async work ...
|
||||
_effect.trySend(CreateItemEffect.ShowMessage("Saved"))
|
||||
_effect.trySend(CreateItemEffect.NavigateBack)
|
||||
}
|
||||
```
|
||||
|
||||
## Screen State Holder Anatomy
|
||||
|
||||
A MVVM ViewModel has three responsibilities:
|
||||
|
||||
1. **State ownership** — holds `MutableStateFlow<State>`, exposes `StateFlow<State>`
|
||||
2. **Effect delivery** — holds `Channel<Effect>` or the project's equivalent, exposes `Flow<Effect>`
|
||||
3. **Named action functions** — public functions for each user action
|
||||
|
||||
State is updated via a thread-safe `update` function (e.g., `MutableStateFlow.update { it.copy(...) }` or a wrapper like `updateState { copy(...) }`). Effects are sent via `channel.trySend(effect)`.
|
||||
|
||||
## UI Rendering Boundary
|
||||
|
||||
### Route composable
|
||||
|
||||
Obtains the ViewModel (via `koinViewModel()`, `hiltViewModel()`, manual construction), collects state once via lifecycle-aware collector, collects effects via `CollectEffect` or equivalent, binds navigation/snackbar/sheet/platform APIs.
|
||||
|
||||
The route passes individual callbacks to the screen:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun CreateItemRoute(
|
||||
viewModel: CreateItemViewModel = koinViewModel(),
|
||||
snackbarHostState: SnackbarHostState,
|
||||
onNavigateBack: () -> Unit,
|
||||
) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
CollectEffect(viewModel.effect) { effect ->
|
||||
when (effect) {
|
||||
CreateItemEffect.NavigateBack -> onNavigateBack()
|
||||
is CreateItemEffect.ShowMessage -> snackbarHostState.showSnackbar(effect.text)
|
||||
}
|
||||
}
|
||||
|
||||
CreateItemScreen(
|
||||
state = state,
|
||||
onTitleChange = viewModel::onTitleChanged,
|
||||
onAmountChange = viewModel::onAmountChanged,
|
||||
onSaveClick = viewModel::save,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Screen composable
|
||||
|
||||
Stateless render function receiving state plus individual callbacks:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun CreateItemScreen(
|
||||
state: CreateItemState,
|
||||
onTitleChange: (String) -> Unit,
|
||||
onAmountChange: (String) -> Unit,
|
||||
onSaveClick: () -> Unit,
|
||||
) {
|
||||
Column {
|
||||
OutlinedTextField(
|
||||
value = state.title,
|
||||
onValueChange = onTitleChange,
|
||||
isError = state.errors.containsKey("title"),
|
||||
label = { Text("Title") },
|
||||
)
|
||||
OutlinedTextField(
|
||||
value = state.amount,
|
||||
onValueChange = onAmountChange,
|
||||
isError = state.errors.containsKey("amount"),
|
||||
label = { Text("Amount") },
|
||||
)
|
||||
Button(
|
||||
onClick = onSaveClick,
|
||||
enabled = !state.isSaving && state.canSave,
|
||||
) {
|
||||
Text(if (state.isSaving) "Saving..." else "Save")
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Leaf composables
|
||||
|
||||
Render sub-state, emit specific callbacks, keep only tiny visual-local state. Receive only what they need; do not pass the ViewModel to leaves.
|
||||
|
||||
### Domain and Data Layer Boundaries
|
||||
|
||||
See [architecture.md](architecture.md) — Domain Layer and Where Logic Belongs.
|
||||
|
||||
## When MVVM Is Appropriate
|
||||
|
||||
- Project already uses MVVM conventions
|
||||
- Screen is straightforward with few user actions
|
||||
- Team prefers less boilerplate and direct function calls
|
||||
- Migrating from Android View-based MVVM to Compose
|
||||
- Named functions provide sufficient discoverability for the screen's complexity
|
||||
|
||||
## Code Examples
|
||||
|
||||
### GOOD: State and Effect definitions
|
||||
|
||||
```kotlin
|
||||
data class CreateItemState(
|
||||
val title: String = "",
|
||||
val amount: String = "",
|
||||
val isSaving: Boolean = false,
|
||||
val errors: Map<String, String> = emptyMap()
|
||||
) {
|
||||
val canSave: Boolean get() = title.isNotBlank() && amount.isNotBlank()
|
||||
}
|
||||
|
||||
sealed interface CreateItemEffect {
|
||||
data object NavigateBack : CreateItemEffect
|
||||
data class ShowMessage(val text: String) : CreateItemEffect
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: ViewModel with named functions
|
||||
|
||||
```kotlin
|
||||
class CreateItemViewModel(
|
||||
private val repository: ItemRepository,
|
||||
) : ViewModel() {
|
||||
private val _state = MutableStateFlow(CreateItemState())
|
||||
val state: StateFlow<CreateItemState> = _state.asStateFlow()
|
||||
|
||||
private val _effect = Channel<CreateItemEffect>(Channel.BUFFERED)
|
||||
val effect: Flow<CreateItemEffect> = _effect.receiveAsFlow()
|
||||
|
||||
fun onTitleChanged(title: String) {
|
||||
_state.update { it.copy(title = title, errors = it.errors - "title") }
|
||||
}
|
||||
|
||||
fun onAmountChanged(amount: String) {
|
||||
_state.update { it.copy(amount = amount, errors = it.errors - "amount") }
|
||||
}
|
||||
|
||||
fun onBackClick() {
|
||||
_effect.trySend(CreateItemEffect.NavigateBack)
|
||||
}
|
||||
|
||||
fun save() {
|
||||
val current = _state.value
|
||||
val errors = /* validate current.title / current.amount */
|
||||
if (errors.isNotEmpty()) {
|
||||
_state.update { it.copy(errors = errors) }
|
||||
return
|
||||
}
|
||||
_state.update { it.copy(isSaving = true, errors = emptyMap()) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.create(current.title.trim(), current.amount.toDouble())
|
||||
_state.update { it.copy(isSaving = false) }
|
||||
_effect.trySend(CreateItemEffect.ShowMessage("Saved"))
|
||||
_effect.trySend(CreateItemEffect.NavigateBack)
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isSaving = false) }
|
||||
_effect.trySend(CreateItemEffect.ShowMessage("Failed: ${e.message}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: Route/Screen/Leaf split
|
||||
|
||||
See the Route example above in **UI Rendering Boundary** for the full Route/Screen split (including `CollectEffect` and callback wiring).
|
||||
|
||||
### GOOD: Callback grouping for complex screens
|
||||
|
||||
For screens with many actions, group related callbacks into a single interface to reduce parameter count:
|
||||
|
||||
```kotlin
|
||||
interface CreateItemActions {
|
||||
fun onTitleChanged(title: String)
|
||||
fun onAmountChanged(amount: String)
|
||||
fun onCategorySelected(category: Category)
|
||||
fun onTagsChanged(tags: List<Tag>)
|
||||
fun onSaveClick()
|
||||
fun onDeleteClick()
|
||||
fun onBackClick()
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CreateItemScreen(
|
||||
state: CreateItemState,
|
||||
actions: CreateItemActions,
|
||||
) {
|
||||
// Use actions.onTitleChanged, actions.onSaveClick, etc.
|
||||
}
|
||||
```
|
||||
|
||||
The ViewModel can implement this interface directly. This provides structure without the ceremony of a sealed event class.
|
||||
@@ -1,139 +0,0 @@
|
||||
# Navigation 2 + Dependency Injection
|
||||
|
||||
DI wiring for Nav 2 destinations: destination-scoped and graph-scoped ViewModels with Hilt and Koin.
|
||||
|
||||
For Nav 2 core reference (NavHost, tabs, deep links, animations), see [navigation-2.md](navigation-2.md).
|
||||
For shared navigation concepts and anti-patterns, see [navigation.md](navigation.md).
|
||||
|
||||
## Hilt Integration
|
||||
|
||||
### hiltViewModel in composable destinations
|
||||
|
||||
Each `composable()` destination gets its own ViewModel instance scoped to the `NavBackStackEntry`:
|
||||
|
||||
```kotlin
|
||||
composable<Detail> { backStackEntry ->
|
||||
val viewModel = hiltViewModel<DetailViewModel>()
|
||||
DetailScreen(viewModel = viewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### SavedStateHandle for navigation arguments
|
||||
|
||||
Hilt auto-injects `SavedStateHandle` populated with navigation arguments. The ViewModel receives route params without manual extraction:
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel
|
||||
class DetailViewModel @Inject constructor(
|
||||
private val repository: ItemRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
private val itemId: String = checkNotNull(savedStateHandle["itemId"])
|
||||
}
|
||||
```
|
||||
|
||||
### Graph-scoped shared ViewModel
|
||||
|
||||
Share a ViewModel across all destinations within a nested navigation graph (e.g., a multi-step checkout flow):
|
||||
|
||||
```kotlin
|
||||
composable("checkout/cart") { entry ->
|
||||
val parentEntry = remember(entry) { navController.getBackStackEntry("checkout") }
|
||||
val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
|
||||
CartScreen(viewModel = sharedViewModel)
|
||||
}
|
||||
```
|
||||
|
||||
All destinations in the `checkout` graph share the same `CheckoutViewModel` instance, which is cleared when the graph is popped from the back stack.
|
||||
|
||||
### @AssistedInject for non-navigation params
|
||||
|
||||
When a ViewModel needs values that aren't in navigation arguments and can't go through `SavedStateHandle`:
|
||||
|
||||
```kotlin
|
||||
@HiltViewModel(assistedFactory = EditorViewModel.Factory::class)
|
||||
class EditorViewModel @AssistedInject constructor(
|
||||
private val repository: DocRepository,
|
||||
@Assisted private val mode: EditMode,
|
||||
) : ViewModel() {
|
||||
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
fun create(mode: EditMode): EditorViewModel
|
||||
}
|
||||
}
|
||||
|
||||
// Composable destination
|
||||
composable<Editor> {
|
||||
val viewModel = hiltViewModel<EditorViewModel, EditorViewModel.Factory> { factory ->
|
||||
factory.create(EditMode.CREATE)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Prefer `SavedStateHandle` for navigation arguments (simpler, survives process death). Use `@AssistedInject` only when `SavedStateHandle` can't carry the data.
|
||||
|
||||
## Koin Integration
|
||||
|
||||
### koinViewModel in composable destinations
|
||||
|
||||
Standard ViewModel injection using `koinViewModel()`:
|
||||
|
||||
```kotlin
|
||||
composable<Detail> {
|
||||
val detail: Detail = it.toRoute()
|
||||
DetailScreen(viewModel = koinViewModel { parametersOf(detail.itemId) })
|
||||
}
|
||||
```
|
||||
|
||||
### koinNavViewModel — auto-populated SavedStateHandle
|
||||
|
||||
`koinNavViewModel()` automatically populates the ViewModel's `SavedStateHandle` with navigation arguments. The ViewModel receives route params via its constructor without manual extraction:
|
||||
|
||||
```kotlin
|
||||
class DetailViewModel(
|
||||
private val repository: ItemRepository,
|
||||
savedStateHandle: SavedStateHandle,
|
||||
) : ViewModel() {
|
||||
private val itemId: String = checkNotNull(savedStateHandle["itemId"])
|
||||
}
|
||||
|
||||
// Module declaration
|
||||
val featureModule = module {
|
||||
viewModelOf(::DetailViewModel)
|
||||
}
|
||||
|
||||
// Composable destination — SavedStateHandle auto-populated with nav args
|
||||
composable("detail/{itemId}") {
|
||||
val viewModel = koinNavViewModel<DetailViewModel>()
|
||||
DetailScreen(viewModel = viewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### sharedKoinViewModel — graph-scoped sharing
|
||||
|
||||
Share a ViewModel within a navigation graph. The shared instance lives as long as the graph's back stack entry:
|
||||
|
||||
```kotlin
|
||||
navigation(startDestination = "checkout/cart", route = "checkout") {
|
||||
composable("checkout/cart") { entry ->
|
||||
val sharedVm = entry.sharedKoinViewModel<CheckoutViewModel>(navController)
|
||||
CartScreen(viewModel = sharedVm)
|
||||
}
|
||||
composable("checkout/shipping") { entry ->
|
||||
val sharedVm = entry.sharedKoinViewModel<CheckoutViewModel>(navController)
|
||||
ShippingScreen(viewModel = sharedVm)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the Koin equivalent of Hilt's `hiltViewModel(navController.getBackStackEntry("checkout"))` pattern.
|
||||
|
||||
### Quick reference — Koin Nav 2 injection functions
|
||||
|
||||
| Function | Purpose |
|
||||
|---|---|
|
||||
| `koinViewModel<T>()` | Standard injection — new instance per destination |
|
||||
| `koinNavViewModel<T>()` | Like `koinViewModel` but auto-populates `SavedStateHandle` with nav arguments |
|
||||
| `sharedKoinViewModel<T>(navController)` | Share ViewModel within a navigation graph (experimental) |
|
||||
| `koinViewModel(parameters = { parametersOf(...) })` | Pass runtime values to the ViewModel constructor |
|
||||
@@ -1,251 +0,0 @@
|
||||
# Navigation 2
|
||||
|
||||
NavHost, NavController, and graph DSL for Jetpack Compose navigation. Nav 2 is **not deprecated** and remains fully supported.
|
||||
|
||||
For shared navigation concepts (MVI rules, anti-patterns, version decision guide), see [navigation.md](navigation.md).
|
||||
For DI wiring (Hilt/Koin + Nav 2), see [navigation-2-di.md](navigation-2-di.md).
|
||||
For migrating to Nav 3, see [navigation-migration.md](navigation-migration.md).
|
||||
|
||||
References:
|
||||
- [Navigation Compose docs](https://developer.android.com/guide/navigation/get-started)
|
||||
- [Type-safe navigation (2.8+)](https://developer.android.com/guide/navigation/design/type-safety)
|
||||
- [Navigation with Compose](https://developer.android.com/develop/ui/compose/navigation)
|
||||
- [Animate transitions](https://developer.android.com/guide/navigation/use-graph/animate-transitions)
|
||||
|
||||
## Core Concepts
|
||||
|
||||
Nav 2 has three building blocks:
|
||||
|
||||
1. **NavController** — imperative controller that manages the back stack and navigation actions
|
||||
2. **NavHost** — composable container that maps routes to composable destinations
|
||||
3. **NavGraph** — the navigation graph defined via the `NavHost` DSL
|
||||
|
||||
## Basic Setup with String Routes
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppNavigation() {
|
||||
val navController = rememberNavController()
|
||||
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
composable("home") {
|
||||
HomeScreen(onNavigateToDetail = { id -> navController.navigate("detail/$id") })
|
||||
}
|
||||
composable("detail/{itemId}") { backStackEntry ->
|
||||
val itemId = backStackEntry.arguments?.getString("itemId") ?: return@composable
|
||||
DetailScreen(itemId = itemId, onBack = { navController.navigateUp() })
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
How you wire ViewModels and state inside each `composable` block depends on your project's architecture — see [navigation.md](navigation.md) for the MVI boundary pattern where navigation is driven by ViewModel effects.
|
||||
|
||||
## Type-Safe Routes (2.8+)
|
||||
|
||||
From Navigation Compose 2.8+, routes can be `@Serializable` types instead of strings. This is the recommended approach for new Nav 2 code:
|
||||
|
||||
```kotlin
|
||||
@Serializable data object Home
|
||||
@Serializable data class Detail(val itemId: String)
|
||||
|
||||
NavHost(navController = navController, startDestination = Home) {
|
||||
composable<Home> {
|
||||
HomeScreen(onNavigateToDetail = { id -> navController.navigate(Detail(id)) })
|
||||
}
|
||||
composable<Detail> { backStackEntry ->
|
||||
val detail: Detail = backStackEntry.toRoute()
|
||||
DetailScreen(itemId = detail.itemId, onBack = { navController.navigateUp() })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Navigation Arguments (Legacy String Routes)
|
||||
|
||||
For pre-2.8 projects using string routes:
|
||||
|
||||
```kotlin
|
||||
composable(
|
||||
route = "detail/{itemId}?sort={sort}",
|
||||
arguments = listOf(
|
||||
navArgument("itemId") { type = NavType.StringType },
|
||||
navArgument("sort") { type = NavType.StringType; defaultValue = "name" },
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val itemId = backStackEntry.arguments?.getString("itemId") ?: return@composable
|
||||
val sort = backStackEntry.arguments?.getString("sort") ?: "name"
|
||||
DetailScreen(itemId = itemId, sortBy = sort)
|
||||
}
|
||||
```
|
||||
|
||||
Type-safe routes (2.8+) are the recommended default — the `navArgument` DSL is for legacy codebases.
|
||||
|
||||
## Common Navigation Actions
|
||||
|
||||
```kotlin
|
||||
navController.navigate("detail/$id")
|
||||
|
||||
navController.navigate("detail/$id") {
|
||||
popUpTo("home") { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
|
||||
navController.navigateUp()
|
||||
|
||||
navController.popBackStack()
|
||||
|
||||
// Type-safe (2.8+)
|
||||
navController.navigate(Detail(id)) {
|
||||
popUpTo<Home> { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
```
|
||||
|
||||
## Top-Level Tabs with NavigationBar
|
||||
|
||||
Use `NavigationBar` with `currentBackStackEntryAsState()`. Track selection with `destination.hierarchy` and `hasRoute(route::class)`.
|
||||
|
||||
```kotlin
|
||||
@Serializable sealed interface TopLevelRoute {
|
||||
@Serializable data object Home : TopLevelRoute
|
||||
@Serializable data object Search : TopLevelRoute
|
||||
@Serializable data object Profile : TopLevelRoute
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MainScreen() {
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentDestination = navBackStackEntry?.destination
|
||||
val tabs = listOf(
|
||||
Triple(TopLevelRoute.Home, Icons.Default.Home, "Home"),
|
||||
Triple(TopLevelRoute.Search, Icons.Default.Search, "Search"),
|
||||
Triple(TopLevelRoute.Profile, Icons.Default.Person, "Profile"),
|
||||
)
|
||||
|
||||
Scaffold(
|
||||
bottomBar = {
|
||||
NavigationBar {
|
||||
tabs.forEach { (route, icon, label) ->
|
||||
val selected =
|
||||
currentDestination?.hierarchy?.any { it.hasRoute(route::class) } == true
|
||||
NavigationBarItem(
|
||||
selected = selected,
|
||||
onClick = {
|
||||
navController.navigate(route) {
|
||||
popUpTo(navController.graph.findStartDestination().id) {
|
||||
saveState = true
|
||||
}
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
},
|
||||
icon = { Icon(icon, contentDescription = label) },
|
||||
label = { Text(label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { padding ->
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = TopLevelRoute.Home,
|
||||
modifier = Modifier.padding(padding),
|
||||
) {
|
||||
composable<TopLevelRoute.Home> { HomeScreen(navController) }
|
||||
composable<TopLevelRoute.Search> { SearchScreen(navController) }
|
||||
composable<TopLevelRoute.Profile> { ProfileScreen(navController) }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Deep Links
|
||||
|
||||
Type-safe (2.8+):
|
||||
|
||||
```kotlin
|
||||
composable<Detail>(
|
||||
deepLinks = listOf(
|
||||
navDeepLink<Detail>(basePath = "https://example.com/detail")
|
||||
)
|
||||
) { backStackEntry ->
|
||||
val detail: Detail = backStackEntry.toRoute()
|
||||
DetailScreen(detail.itemId)
|
||||
}
|
||||
```
|
||||
|
||||
## Navigate with Results
|
||||
|
||||
Pass data back via `SavedStateHandle` on back stack entries (avoids bloating route arguments):
|
||||
|
||||
```kotlin
|
||||
// Sender: set on previous entry, then pop
|
||||
Button(onClick = {
|
||||
navController.previousBackStackEntry?.savedStateHandle?.set("filter_result", selectedFilter)
|
||||
navController.navigateUp()
|
||||
}) { Text("Apply") }
|
||||
|
||||
// Receiver: observe on current entry
|
||||
val filterResult = navController.currentBackStackEntry
|
||||
?.savedStateHandle
|
||||
?.getStateFlow<String?>("filter_result", null)
|
||||
?.collectAsStateWithLifecycle()
|
||||
```
|
||||
|
||||
## Nested Navigation Graphs
|
||||
|
||||
Group related destinations under a nested graph:
|
||||
|
||||
```kotlin
|
||||
NavHost(navController = navController, startDestination = "home") {
|
||||
composable("home") { HomeScreen(navController) }
|
||||
|
||||
navigation(startDestination = "checkout/cart", route = "checkout") {
|
||||
composable("checkout/cart") { CartScreen(navController) }
|
||||
composable("checkout/shipping") { ShippingScreen(navController) }
|
||||
composable("checkout/payment") { PaymentScreen(navController) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Type-safe: use `navigation<Graph>(startDestination = Route)` with `@Serializable` types — same structure as above.
|
||||
|
||||
## Animations
|
||||
|
||||
Default transitions on `NavHost`:
|
||||
|
||||
```kotlin
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = Home,
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) + fadeIn() },
|
||||
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) + fadeOut() },
|
||||
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) + fadeIn() },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) + fadeOut() },
|
||||
) { /* destinations */ }
|
||||
```
|
||||
|
||||
## Conditional Navigation (Auth Guards)
|
||||
|
||||
Redirect via `startDestination` and clear login from the stack after success:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppNavigation(isAuthenticated: Boolean) {
|
||||
val navController = rememberNavController()
|
||||
val startDestination = if (isAuthenticated) Home else Login
|
||||
|
||||
NavHost(navController = navController, startDestination = startDestination) {
|
||||
composable<Login> {
|
||||
LoginScreen(onLoginSuccess = {
|
||||
navController.navigate(Home) {
|
||||
popUpTo<Login> { inclusive = true }
|
||||
}
|
||||
})
|
||||
}
|
||||
composable<Home> { HomeScreen(navController) }
|
||||
composable<Detail> { DetailScreen(navController) }
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -1,174 +0,0 @@
|
||||
# Navigation 3 + Dependency Injection
|
||||
|
||||
DI wiring for Nav 3 entries: entry-scoped ViewModels, modularization, and multi-module entry providers with Hilt and Koin.
|
||||
|
||||
For Nav 3 core reference (routes, NavDisplay, scenes, animations), see [navigation-3.md](navigation-3.md).
|
||||
For shared navigation concepts and anti-patterns, see [navigation.md](navigation.md).
|
||||
|
||||
## Entry-Scoped ViewModels
|
||||
|
||||
Nav 3 scopes ViewModels to entries via `rememberViewModelStoreNavEntryDecorator()`. Each entry gets its own `ViewModelStoreOwner` — VMs are created when the entry is added to the back stack and cleared when popped.
|
||||
|
||||
### BAD: Globally-scoped ViewModel for per-screen data
|
||||
|
||||
```kotlin
|
||||
val viewModel: DetailViewModel = viewModel() // scoped too broadly, not entry-scoped
|
||||
```
|
||||
|
||||
### GOOD: Entry-scoped ViewModel
|
||||
|
||||
```kotlin
|
||||
// Requires rememberViewModelStoreNavEntryDecorator() in entryDecorators
|
||||
val viewModel: DetailViewModel = viewModel() // scoped to entry via decorator
|
||||
```
|
||||
|
||||
For shared state across entries, lift state to a parent composable or use a shared ViewModel at the Activity/App scope.
|
||||
|
||||
## Hilt Integration
|
||||
|
||||
For general Hilt setup, modules, and scopes, see [hilt.md](hilt.md). Below covers Nav 3–specific patterns only.
|
||||
|
||||
### hiltViewModel in entry blocks (Android only)
|
||||
|
||||
```kotlin
|
||||
entry<Home> {
|
||||
val viewModel = hiltViewModel<HomeViewModel>()
|
||||
HomeScreen(viewModel = viewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### Factory parameters with @AssistedInject
|
||||
|
||||
When the ViewModel needs values from the navigation key that aren't in `SavedStateHandle`:
|
||||
|
||||
```kotlin
|
||||
entry<Create> { createKey ->
|
||||
val viewModel = hiltViewModel<CreationViewModel, CreationViewModel.Factory>(
|
||||
creationCallback = { factory -> factory.create(originalImageUrl = createKey.fileName) },
|
||||
)
|
||||
CreationScreen(viewModel = viewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### Multibinding entry providers for modularization
|
||||
|
||||
Each feature module contributes an entry builder via Hilt multibindings. The app module aggregates them automatically:
|
||||
|
||||
```kotlin
|
||||
// Feature module
|
||||
@Module @InstallIn(ActivityRetainedComponent::class)
|
||||
object FeatureAModule {
|
||||
@IntoSet @Provides
|
||||
fun provideEntryBuilder(): EntryProviderScope<NavKey>.() -> Unit = {
|
||||
featureAEntryBuilder()
|
||||
}
|
||||
}
|
||||
|
||||
// App module — MainActivity
|
||||
@Inject
|
||||
lateinit var entryBuilders: Set<@JvmSuppressWildcards EntryProviderScope<NavKey>.() -> Unit>
|
||||
|
||||
NavDisplay(
|
||||
entryProvider = entryProvider {
|
||||
entryBuilders.forEach { builder -> this.builder() }
|
||||
},
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
## Koin Integration
|
||||
|
||||
For general Koin setup, modules, and scopes, see [koin.md](koin.md). Below covers Nav 3–specific patterns only.
|
||||
|
||||
### koinViewModel in entry blocks (Android + CMP)
|
||||
|
||||
```kotlin
|
||||
entry<Details> { key ->
|
||||
val viewModel = koinViewModel<DetailViewModel> { parametersOf(key.id) }
|
||||
DetailScreen(viewModel = viewModel)
|
||||
}
|
||||
```
|
||||
|
||||
### Koin navigation DSL + koinEntryProvider
|
||||
|
||||
Declare navigation entries inside Koin modules. Koin aggregates them automatically — no manual entry provider needed:
|
||||
|
||||
```kotlin
|
||||
val appModule = module {
|
||||
navigation<HomeRoute> { HomeScreen(viewModel = koinViewModel()) }
|
||||
navigation<DetailRoute> { route ->
|
||||
DetailScreen(viewModel = koinViewModel { parametersOf(route.id) })
|
||||
}
|
||||
}
|
||||
|
||||
NavDisplay(
|
||||
backStack = rememberNavBackStack(HomeRoute),
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider = koinEntryProvider(),
|
||||
)
|
||||
```
|
||||
|
||||
### Platform-specific extensions
|
||||
|
||||
| Function | Platform | Description |
|
||||
|---|---|---|
|
||||
| `koinEntryProvider<T>()` | All (CMP) | Composable entry provider — use in `commonMain` |
|
||||
| `getEntryProvider<T>()` | Android | Eager entry provider via `AndroidScopeComponent` |
|
||||
|
||||
## Modularization
|
||||
|
||||
### api / impl module split
|
||||
|
||||
```text
|
||||
feature-home/
|
||||
api/
|
||||
HomeNavKey.kt -- @Serializable data object HomeNavKey : NavKey
|
||||
impl/
|
||||
HomeScreen.kt -- composable UI
|
||||
HomeEntryBuilder.kt -- extension function on EntryProviderScope
|
||||
```
|
||||
|
||||
- **api** — contains only the `NavKey` route definitions. Other features depend on this.
|
||||
- **impl** — contains UI, ViewModels, and entry builder. Depends on its own api + other features' api modules.
|
||||
|
||||
### Entry builder extension functions
|
||||
|
||||
Each feature exposes an extension function; the app module aggregates them:
|
||||
|
||||
```kotlin
|
||||
// feature-home/impl
|
||||
fun EntryProviderScope<NavKey>.homeEntry(navigator: Navigator) {
|
||||
entry<HomeNavKey> {
|
||||
HomeScreen(onItemClick = { navigator.navigate(DetailsNavKey(it)) })
|
||||
}
|
||||
}
|
||||
|
||||
// app module
|
||||
NavDisplay(
|
||||
entryProvider = entryProvider {
|
||||
homeEntry(navigator)
|
||||
searchEntry(navigator)
|
||||
profileEntry(navigator)
|
||||
},
|
||||
// ...
|
||||
)
|
||||
```
|
||||
|
||||
How you wire the ViewModel and state inside each entry depends on your project's architecture. Navigation is driven by ViewModel effects — the route layer translates semantic effects to back-stack operations.
|
||||
|
||||
### Koin module aggregation (CMP)
|
||||
|
||||
```kotlin
|
||||
// Feature module
|
||||
val featureModule = module {
|
||||
navigation<HomeNavKey> { HomeScreen(viewModel = koinViewModel()) }
|
||||
navigation<ProfileNavKey> { ProfileScreen(viewModel = koinViewModel()) }
|
||||
}
|
||||
|
||||
// App module
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryProvider = koinEntryProvider(),
|
||||
)
|
||||
```
|
||||
@@ -1,229 +0,0 @@
|
||||
# Navigation 3
|
||||
|
||||
Navigation 3 for Compose and CMP: you own the back stack as state, the library renders it. Verify artifact maturity before production use.
|
||||
|
||||
For shared navigation concepts (MVI rules, anti-patterns, version decision guide), see [navigation.md](navigation.md).
|
||||
For DI wiring (Hilt/Koin + Nav 3), see [navigation-3-di.md](navigation-3-di.md).
|
||||
For migrating from Nav 2, see [navigation-migration.md](navigation-migration.md).
|
||||
|
||||
References:
|
||||
- [Android Nav 3 docs](https://developer.android.com/guide/navigation/navigation-3)
|
||||
- [Nav 3 state management](https://developer.android.com/guide/navigation/navigation-3/save-state)
|
||||
- [nav3-recipes repo](https://github.com/android/nav3-recipes)
|
||||
- [CMP Nav 3 recipes](https://github.com/terrakok/nav3-recipes)
|
||||
|
||||
## Core Architecture
|
||||
|
||||
Nav 3 has four building blocks:
|
||||
|
||||
1. **Keys** — `@Serializable` types identifying destinations
|
||||
2. **Back stack** — a `SnapshotStateList` you own and mutate directly
|
||||
3. **NavEntry** — wraps a key with composable content and optional metadata
|
||||
4. **NavDisplay** — observes back stack, resolves keys via entry provider, picks a Scene, renders
|
||||
|
||||
```text
|
||||
User interaction
|
||||
-> backStack.add(key) / backStack.removeLastOrNull()
|
||||
-> NavDisplay observes change
|
||||
-> entryProvider resolves key -> NavEntry
|
||||
-> SceneStrategy picks layout
|
||||
-> Scene renders content
|
||||
```
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `NavKey` | Marker interface for serializable destination keys |
|
||||
| `NavEntry` | Key + composable content + metadata map |
|
||||
| `NavDisplay` | Observes back stack, manages scenes and animations |
|
||||
| `Scene` / `SceneStrategy` | Decides layout (single pane, list-detail, dialog) |
|
||||
| `NavEntryDecorator` | Cross-cutting concern (ViewModel scoping, saveable state) |
|
||||
|
||||
## Route Definition
|
||||
|
||||
Define routes as `@Serializable` data classes/objects. Group with sealed interfaces for type safety:
|
||||
|
||||
```kotlin
|
||||
@Serializable sealed interface AppRoute : NavKey
|
||||
@Serializable data object Home : AppRoute
|
||||
@Serializable data class Details(val id: String) : AppRoute
|
||||
@Serializable data object Settings : AppRoute
|
||||
```
|
||||
|
||||
For platform-specific types in route arguments, provide a custom `KSerializer`. In CMP, prefer `String` paths or `expect/actual` wrappers.
|
||||
|
||||
## Back Stack Creation and Persistence
|
||||
|
||||
```kotlin
|
||||
// Recommended — persists across config changes and process death (keys must be @Serializable + NavKey)
|
||||
val backStack = rememberNavBackStack(Home)
|
||||
|
||||
// Simple — no persistence, prototyping only
|
||||
val backStack = remember { mutableStateListOf<Any>(Home) }
|
||||
```
|
||||
|
||||
### CMP: Polymorphic serialization for non-JVM
|
||||
|
||||
Non-JVM CMP targets need `SavedStateConfiguration` plus a `SerializersModule` with polymorphic `NavKey` subclasses (e.g. `subclassesOfSealed<AppRoute>()`).
|
||||
|
||||
Details: [Nav 3 state management](https://developer.android.com/guide/navigation/navigation-3/save-state).
|
||||
|
||||
## NavDisplay Configuration
|
||||
|
||||
```kotlin
|
||||
NavDisplay(
|
||||
backStack = backStack,
|
||||
onBack = { backStack.removeLastOrNull() },
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(),
|
||||
rememberViewModelStoreNavEntryDecorator(),
|
||||
),
|
||||
sceneStrategy = listDetailStrategy,
|
||||
transitionSpec = { slideInHorizontally(initialOffsetX = { it }) togetherWith slideOutHorizontally(targetOffsetX = { -it }) },
|
||||
popTransitionSpec = { slideInHorizontally(initialOffsetX = { -it }) togetherWith slideOutHorizontally(targetOffsetX = { it }) },
|
||||
entryProvider = entryProvider {
|
||||
entry<Home> {
|
||||
HomeScreen(onNavigateToDetails = { id -> backStack.add(Details(id)) })
|
||||
}
|
||||
entry<Details>(metadata = mapOf("pane" to "detail")) { key ->
|
||||
DetailScreen(id = key.id, onNavigateBack = { backStack.removeLastOrNull() })
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Each `entry<Key>` receives the typed key. Pass `metadata` to control scene placement and per-entry animations. For ViewModel/state wiring inside entries, see [navigation.md](navigation.md) and [navigation-3-di.md](navigation-3-di.md).
|
||||
|
||||
## Top-Level Tabs and Dashboard Navigation
|
||||
|
||||
```kotlin
|
||||
data class TopLevelNavItem(val selectedIcon: ImageVector, val unselectedIcon: ImageVector, val label: String)
|
||||
|
||||
val TOP_LEVEL_ITEMS = mapOf(
|
||||
Home to TopLevelNavItem(Icons.Filled.Home, Icons.Outlined.Home, "Home"),
|
||||
Search to TopLevelNavItem(Icons.Filled.Search, Icons.Outlined.Search, "Search"),
|
||||
Profile to TopLevelNavItem(Icons.Filled.Person, Icons.Outlined.Person, "Profile"),
|
||||
)
|
||||
|
||||
@Stable
|
||||
class NavigationState(val backStack: SnapshotStateList<NavKey>, val topLevelKeys: Set<NavKey>) {
|
||||
val currentKey: NavKey get() = backStack.last()
|
||||
val currentTopLevelKey: NavKey? get() = backStack.lastOrNull { it in topLevelKeys }
|
||||
}
|
||||
|
||||
class Navigator(private val state: NavigationState) {
|
||||
fun navigate(key: NavKey) {
|
||||
if (key in state.topLevelKeys) {
|
||||
while (state.backStack.size > 1) state.backStack.removeLast()
|
||||
if (state.backStack.lastOrNull() != key) state.backStack[0] = key
|
||||
} else { state.backStack.add(key) }
|
||||
}
|
||||
fun goBack() { state.backStack.removeLastOrNull() }
|
||||
}
|
||||
```
|
||||
|
||||
Use `NavigationSuiteScaffold` (or custom scaffold) with `NavDisplay` inside.
|
||||
|
||||
## ViewModel Scoping
|
||||
|
||||
Always include both entry decorators:
|
||||
|
||||
```kotlin
|
||||
entryDecorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator(), // preserves rememberSaveable while on stack
|
||||
rememberViewModelStoreNavEntryDecorator(), // per-entry ViewModelStoreOwner
|
||||
)
|
||||
```
|
||||
|
||||
VMs created when entry added, cleared when popped. For DI-specific injection patterns, see [navigation-3-di.md](navigation-3-di.md).
|
||||
|
||||
## Scenes and Adaptive Layouts
|
||||
|
||||
### DialogSceneStrategy
|
||||
|
||||
```kotlin
|
||||
entry<ConfirmDialog>(metadata = DialogSceneStrategy.dialog()) { key ->
|
||||
AlertDialog(onDismissRequest = { backStack.removeLastOrNull() }, /* ... */)
|
||||
}
|
||||
```
|
||||
|
||||
### BottomSheetSceneStrategy
|
||||
|
||||
```kotlin
|
||||
entry<FilterSheet>(metadata = BottomSheetSceneStrategy.bottomSheet()) { key ->
|
||||
FilterContent(onApply = { backStack.removeLastOrNull() })
|
||||
}
|
||||
```
|
||||
|
||||
### Material 3 Adaptive list-detail
|
||||
|
||||
```kotlin
|
||||
val listDetailStrategy = rememberListDetailSceneStrategy<NavKey>()
|
||||
|
||||
NavDisplay(
|
||||
sceneStrategy = listDetailStrategy,
|
||||
entryProvider = entryProvider {
|
||||
entry<ConversationList>(metadata = ListDetailSceneStrategy.listPane(
|
||||
detailPlaceholder = { Text("Select a conversation") }
|
||||
)) { ConversationListScreen(onSelect = { backStack.add(ConversationDetail(it)) }) }
|
||||
|
||||
entry<ConversationDetail>(metadata = ListDetailSceneStrategy.detailPane()) { key ->
|
||||
ConversationDetailScreen(key.id)
|
||||
}
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Automatically adapts: side-by-side on wide screens, single pane on narrow.
|
||||
|
||||
### Chaining strategies
|
||||
|
||||
```kotlin
|
||||
val strategy = dialogStrategy then bottomSheetStrategy then listDetailStrategy
|
||||
// First match wins. SinglePaneSceneStrategy is always implicit fallback.
|
||||
```
|
||||
|
||||
## Animations
|
||||
|
||||
### Global transitions on NavDisplay
|
||||
|
||||
Set `transitionSpec`, `popTransitionSpec`, and `predictivePopTransitionSpec` on `NavDisplay` (see configuration example above).
|
||||
|
||||
### Per-entry overrides via metadata
|
||||
|
||||
```kotlin
|
||||
entry<ModalRoute>(
|
||||
metadata = NavDisplay.transitionSpec {
|
||||
slideInVertically(initialOffsetY = { it }) togetherWith ExitTransition.KeepUntilTransitionsFinished
|
||||
} + NavDisplay.popTransitionSpec {
|
||||
EnterTransition.None togetherWith slideOutVertically(targetOffsetY = { it })
|
||||
}
|
||||
) { ModalScreen() }
|
||||
```
|
||||
|
||||
## Back Stack Manipulation Patterns
|
||||
|
||||
```kotlin
|
||||
backStack.add(Details("123")) // forward
|
||||
backStack.removeLastOrNull() // back
|
||||
backStack.removeAll { it is Details }; backStack.add(Details(newId)) // replace duplicate Details
|
||||
backStack.clear(); backStack.addAll(listOf(Home, Details(deepLinkId))) // synthetic stack (e.g. deep link)
|
||||
while (backStack.size > 1) backStack.removeLast(); backStack[0] = targetKey // tabs: pop to root, swap root key
|
||||
```
|
||||
|
||||
## Deep Links
|
||||
|
||||
Nav 3 does not parse deep links — you own this. Pattern: parse URI → extract args into `NavKey` → build synthetic back stack → set before first composition.
|
||||
|
||||
```kotlin
|
||||
// Android Activity or CMP entry point
|
||||
val backStack = rememberNavBackStack(Home)
|
||||
|
||||
LaunchedEffect(deepLinkId) {
|
||||
if (deepLinkId != null) {
|
||||
backStack.clear()
|
||||
backStack.addAll(listOf(Home, Details(deepLinkId)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Registration lives in platform entry points: `AndroidManifest.xml` intent filters, App Delegate/SceneDelegate on iOS, URL handlers on Desktop. Back stack construction logic can live in shared `commonMain`.
|
||||
@@ -1,120 +0,0 @@
|
||||
# Migrating from Nav 2 to Nav 3
|
||||
|
||||
Nav 2 → Nav 3 migration based on [official docs](https://developer.android.com/guide/navigation/migrate-to-nav3). Nav 2 is **not deprecated** — migration is optional.
|
||||
|
||||
For Nav 3 full reference, see [navigation-3.md](navigation-3.md).
|
||||
For Nav 2 full reference, see [navigation-2.md](navigation-2.md).
|
||||
For shared concepts and decision guide, see [navigation.md](navigation.md).
|
||||
|
||||
## Key Conceptual Shifts
|
||||
|
||||
| Nav 2 | Nav 3 |
|
||||
|---|---|
|
||||
| `NavController` owns the back stack | You own the back stack (`SnapshotStateList`) |
|
||||
| `NavHost` renders composable destinations | `NavDisplay` observes the back stack and renders entries |
|
||||
| Routes are strings or `@Serializable` types | Keys are `@Serializable` types implementing `NavKey` |
|
||||
| Imperative navigation (`navController.navigate()`) | List manipulation (`backStack.add()`, `backStack.removeLastOrNull()`) |
|
||||
| `NavGraph` groups destinations | No separate graph — entries are resolved by the `entryProvider` |
|
||||
| Deep links parsed by Navigation library | Deep links parsed by your code — you construct the back stack |
|
||||
| Graph-scoped ViewModels via `getBackStackEntry()` | Entry-scoped ViewModels via `rememberViewModelStoreNavEntryDecorator()` |
|
||||
| `currentBackStackEntryAsState()` for selected tab | Direct back stack inspection (`backStack.last()`) |
|
||||
| `saveState`/`restoreState` for tab persistence | Persistent per-tab stacks or root swap pattern |
|
||||
|
||||
## Migration Steps
|
||||
|
||||
### 1. Replace route types with NavKey
|
||||
|
||||
```kotlin
|
||||
// Nav 2
|
||||
@Serializable data object Home
|
||||
@Serializable data class Detail(val id: String)
|
||||
|
||||
// Nav 3
|
||||
@Serializable data object Home : NavKey
|
||||
@Serializable data class Detail(val id: String) : NavKey
|
||||
```
|
||||
|
||||
### 2. Replace NavController with a SnapshotStateList back stack
|
||||
|
||||
```kotlin
|
||||
// Nav 2
|
||||
val navController = rememberNavController()
|
||||
navController.navigate(Detail(id))
|
||||
|
||||
// Nav 3
|
||||
val backStack = rememberNavBackStack(Home)
|
||||
backStack.add(Detail(id))
|
||||
```
|
||||
|
||||
### 3. Replace NavHost with NavDisplay
|
||||
|
||||
Replace `NavHost` + `composable<T>` with `NavDisplay` + `entryProvider` + `entry<T>`. Each `composable` block becomes an `entry` block; `navController.navigate()` becomes `backStack.add()`. For full `NavDisplay` API, decorators, and DI wiring, see [navigation-3.md](navigation-3.md) and [navigation-3-di.md](navigation-3-di.md).
|
||||
|
||||
### 4. Replace graph-scoped ViewModels with entry decorators
|
||||
|
||||
Nav 3 scopes ViewModels to entries automatically via `rememberViewModelStoreNavEntryDecorator()`. For shared state across entries, lift state to a parent composable or use a shared ViewModel at the Activity/App scope.
|
||||
|
||||
**Nav 2 graph-scoped pattern:**
|
||||
|
||||
```kotlin
|
||||
val parentEntry = remember(entry) { navController.getBackStackEntry("checkout") }
|
||||
val sharedViewModel: CheckoutViewModel = hiltViewModel(parentEntry)
|
||||
```
|
||||
|
||||
**Nav 3 equivalent — lift to parent or share via DI:**
|
||||
|
||||
```kotlin
|
||||
// Option 1: shared ViewModel at a higher scope
|
||||
val sharedViewModel: CheckoutViewModel = viewModel() // Activity-scoped
|
||||
|
||||
// Option 2: state hoisting in a parent composable
|
||||
// The parent composable holds shared state, passes it to child entries
|
||||
```
|
||||
|
||||
### 5. Replace deep link integration
|
||||
|
||||
Nav 3 does not parse deep links — parse URIs in your platform entry point and construct the back stack manually:
|
||||
|
||||
```kotlin
|
||||
// Nav 2
|
||||
composable<Detail>(
|
||||
deepLinks = listOf(navDeepLink<Detail>(basePath = "https://example.com/detail"))
|
||||
) { /* ... */ }
|
||||
|
||||
// Nav 3
|
||||
LaunchedEffect(deepLinkId) {
|
||||
if (deepLinkId != null) {
|
||||
backStack.clear()
|
||||
backStack.addAll(listOf(Home, Detail(deepLinkId)))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6. Replace tab navigation
|
||||
|
||||
```kotlin
|
||||
// Nav 2 — NavigationBar + currentBackStackEntryAsState + saveState/restoreState
|
||||
navController.navigate(tab.route) {
|
||||
popUpTo(startDest) { saveState = true }
|
||||
launchSingleTop = true
|
||||
restoreState = true
|
||||
}
|
||||
|
||||
// Nav 3 — direct back stack manipulation
|
||||
while (backStack.size > 1) backStack.removeLast()
|
||||
backStack[0] = targetTopLevelKey
|
||||
```
|
||||
|
||||
## Incremental Migration
|
||||
|
||||
You do not have to migrate everything at once. The official docs recommend:
|
||||
|
||||
1. **Start with leaf screens** that have simple navigation — they are the easiest to convert since they have few navigation dependencies
|
||||
2. **Move shared/graph-scoped ViewModels last** — these require the most restructuring (entry decorators replace graph scoping)
|
||||
3. **Keep Nav 2 running alongside Nav 3** during transition if needed — they can coexist in the same app
|
||||
4. **Convert navigation effects** — update ViewModel effect handlers from `navController.navigate()` calls to `backStack.add()` calls one screen at a time
|
||||
5. **Test each migrated screen** independently before moving to the next
|
||||
|
||||
### Coexistence strategy
|
||||
|
||||
During migration, Nav 2 and Nav 3 can coexist in the same app. Use Nav 3 for new feature modules while keeping Nav 2 for existing screens. Bridge between them at the Activity level — a Nav 2 destination can launch an Activity/Fragment that hosts Nav 3, or vice versa.
|
||||
@@ -1,91 +0,0 @@
|
||||
# Navigation
|
||||
|
||||
Shared navigation concepts for Nav 2 and Nav 3. Load first, then see version-specific references.
|
||||
|
||||
References:
|
||||
- [Nav 3 official docs](https://developer.android.com/guide/navigation/navigation-3)
|
||||
- [Nav 2 official docs](https://developer.android.com/guide/navigation/get-started)
|
||||
- [Kotlin CMP Nav 3 docs](https://kotlinlang.org/docs/multiplatform/compose-navigation-3.html)
|
||||
|
||||
## Nav 2 vs Nav 3 Decision Guide
|
||||
|
||||
| Criterion | Nav 3 (NavDisplay) | Nav 2 (NavHost / NavController) |
|
||||
|---|---|---|
|
||||
| Back stack ownership | You own it (`SnapshotStateList`) | Library owns it (`NavController`) |
|
||||
| Navigation model | List manipulation — `add()`, `removeLastOrNull()` | Imperative — `navigate()`, `popBackStack()` |
|
||||
| MVI alignment | Natural — back stack is state you mutate | Requires bridging — controller calls in effect handlers |
|
||||
| Deep link parsing | You parse URIs, construct back stack manually | Built-in `NavDeepLink` parsing |
|
||||
| Scenes / adaptive layouts | First-class: dialog, bottom sheet, list-detail | Manual: separate composable overlays |
|
||||
| CMP support | Full (Android, iOS, Desktop, Web) | Android-only (JetBrains forks exist but differ) |
|
||||
| Maturity | Newer — verify artifact stability for production | Stable, battle-tested |
|
||||
| Fragment interop | None | Full Fragment/Activity integration |
|
||||
|
||||
**When to use Nav 3:**
|
||||
- New Compose projects following MVI architecture
|
||||
- Compose Multiplatform projects targeting multiple platforms
|
||||
- Projects wanting direct back stack control as state
|
||||
- Projects needing adaptive layout scenes (list-detail, dialog, bottom sheet)
|
||||
|
||||
**When to use Nav 2:**
|
||||
- Existing codebases already built on `NavHost`/`NavController`
|
||||
- Projects requiring built-in deep link parsing via `NavDeepLink`
|
||||
- Hybrid Compose + Fragment apps where Nav 2 provides Fragment integration
|
||||
- Teams that prefer the declarative `NavGraph` DSL
|
||||
|
||||
## Navigation in MVI
|
||||
|
||||
The architectural rule: **ViewModels emit semantic effects; the route layer handles navigation.** This rule applies identically to both Nav 2 and Nav 3.
|
||||
|
||||
```kotlin
|
||||
sealed interface ItemEffect {
|
||||
data object NavigateBack : ItemEffect
|
||||
data class OpenDetails(val id: String) : ItemEffect
|
||||
}
|
||||
|
||||
// Nav 3 route layer — manipulates back stack
|
||||
CollectEffect(viewModel.effect) { effect ->
|
||||
when (effect) {
|
||||
is ItemEffect.NavigateBack -> backStack.removeLastOrNull()
|
||||
is ItemEffect.OpenDetails -> backStack.add(Details(effect.id))
|
||||
}
|
||||
}
|
||||
|
||||
// Nav 2 route layer — calls NavController
|
||||
CollectEffect(viewModel.effect) { effect ->
|
||||
when (effect) {
|
||||
is ItemEffect.NavigateBack -> navController.navigateUp()
|
||||
is ItemEffect.OpenDetails -> navController.navigate(Detail(effect.id))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
- Never call navigation during composition — always in `LaunchedEffect` or event handler callbacks
|
||||
- Never pass the back stack (Nav 3) or `NavController` (Nav 2) to the ViewModel or leaf composables
|
||||
- ViewModel emits semantic effects (`NavigateBack`, `OpenDetails(id)`)
|
||||
- Route/navigation layer translates effects to navigation calls
|
||||
- Keep navigation logic at the route boundary, not in screens or leaves
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Applies to | Why it hurts | Better replacement |
|
||||
|---|---|---|---|
|
||||
| Navigating during composition | Both | Triggers on every recomposition, causes infinite loops | Navigate in `LaunchedEffect` or event handler callbacks |
|
||||
| Passing NavController/back stack to ViewModel | Both | Violates MVI boundary, navigation becomes business logic | ViewModel emits semantic effects; route handles navigation |
|
||||
| String-based routes without type safety | Both | No compile-time checking, argument mismatch at runtime | `@Serializable` data classes/objects |
|
||||
| Missing `onBack` handler | Nav 3 | System back gesture does nothing | Always provide `onBack = { backStack.removeLastOrNull() }` |
|
||||
| Globally-scoped ViewModel for per-screen data | Both | Data leaks across screens, not cleared on pop | Entry-scoped VMs (Nav 3 decorators) or destination-scoped VMs (Nav 2) |
|
||||
| Recreating back stacks on tab switch | Both | Loses user navigation history within tabs | Persistent per-tab stacks (Nav 3) or `saveState`/`restoreState` (Nav 2) |
|
||||
| Missing entry decorators | Nav 3 | ViewModels leak, saveable state lost | Always include both `rememberSaveableStateHolderNavEntryDecorator` and `rememberViewModelStoreNavEntryDecorator` |
|
||||
| Using Nav 2 in new MVI codebases | Nav 3 preferred | Nav 3's user-owned back stack aligns better with MVI state ownership | Prefer Nav 3 `NavDisplay` for new MVI-first projects; Nav 2 remains valid for existing codebases |
|
||||
|
||||
## Version-Specific References
|
||||
|
||||
Load the file that matches your task:
|
||||
|
||||
- **Nav 3 routes, tabs, scenes, deep links, or back stack patterns** → [navigation-3.md](navigation-3.md)
|
||||
- **Nav 2 NavHost, tabs, deep links, nested graphs, or animations** → [navigation-2.md](navigation-2.md)
|
||||
- **Wiring Hilt or Koin with Nav 3** → [navigation-3-di.md](navigation-3-di.md)
|
||||
- **Wiring Hilt or Koin with Nav 2** → [navigation-2-di.md](navigation-2-di.md)
|
||||
- **Migrating from Nav 2 to Nav 3** → [navigation-migration.md](navigation-migration.md)
|
||||
@@ -1,237 +0,0 @@
|
||||
# Network Architecture Decisions
|
||||
|
||||
Optional patterns for projects that outgrow the simple approach in [networking-ktor.md](networking-ktor.md). Use these when the project needs richer error classification, centralized request handling, or production instrumentation. For auth see [networking-ktor-auth.md](networking-ktor-auth.md). For testing see [networking-ktor-testing.md](networking-ktor-testing.md).
|
||||
|
||||
## Error Handling Strategy
|
||||
|
||||
Choose one approach and use it consistently across the project.
|
||||
|
||||
### Decision: `Result<T>` vs custom sealed class
|
||||
|
||||
| Criterion | `Result<T>` (Kotlin stdlib) | Custom `ApiResult<T>` |
|
||||
|---|---|---|
|
||||
| Operators | Built-in: `map`, `fold`, `getOrNull`, `onSuccess`, `onFailure` | Define your own |
|
||||
| Error info | `Throwable` only — inspect exception type at use site | Sealed subclasses with structured data per error kind |
|
||||
| UI branching | `when (e) { is IOException -> ... }` | `when (error) { is ApiResult.Unauthorized -> ... }` |
|
||||
| Maintenance | Zero — stdlib | Team maintains the sealed class |
|
||||
| Best for | Most apps, prototypes, APIs with few error-type branches | Apps needing per-error-type UI flows (login redirect, retry prompt, offline message) |
|
||||
|
||||
`Result<T>` is the simpler default. A custom sealed class is justified when the UI needs to branch on many distinct error types and inspecting exception classes becomes unwieldy.
|
||||
|
||||
### Option A — Kotlin `Result<T>`
|
||||
|
||||
```kotlin
|
||||
suspend inline fun <reified T> HttpClient.safeRequest(
|
||||
block: HttpRequestBuilder.() -> Unit,
|
||||
): Result<T> = runCatching { request { block() }.body<T>() }
|
||||
|
||||
// Repository usage
|
||||
override suspend fun getItems(): Result<List<Item>> {
|
||||
return client.safeRequest<ItemListDto> { url("items") }
|
||||
.map { it.items.toDomain() }
|
||||
}
|
||||
|
||||
// ViewModel consumption
|
||||
viewModelScope.launch {
|
||||
repository.getItems()
|
||||
.onSuccess { items -> _state.update { it.copy(items = items) } }
|
||||
.onFailure { error ->
|
||||
when (error) {
|
||||
is ClientRequestException -> handleHttpError(error.response.status.value)
|
||||
is IOException -> _state.update { it.copy(error = "No connection") }
|
||||
else -> _state.update { it.copy(error = "Something went wrong") }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Option B — Custom `ApiResult<T>`
|
||||
|
||||
```kotlin
|
||||
sealed class ApiResult<out T> {
|
||||
data class Success<T>(val data: T) : ApiResult<T>()
|
||||
|
||||
sealed class Failure : ApiResult<Nothing>() {
|
||||
data class HttpError(val code: Int, val message: String?, val serverMessage: String? = null) : Failure()
|
||||
data class NetworkError(val message: String? = null) : Failure()
|
||||
data class Timeout(val message: String? = null) : Failure()
|
||||
data class Unauthorized(val serverMessage: String? = null) : Failure()
|
||||
data class SerializationError(val message: String? = null) : Failure()
|
||||
data class Unknown(val throwable: Throwable) : Failure()
|
||||
}
|
||||
}
|
||||
|
||||
inline fun <T, R> ApiResult<T>.map(transform: (T) -> R): ApiResult<R> = when (this) {
|
||||
is ApiResult.Success -> ApiResult.Success(transform(data))
|
||||
is ApiResult.Failure -> this
|
||||
}
|
||||
|
||||
inline fun <T, R> ApiResult<T>.fold(
|
||||
onSuccess: (T) -> R,
|
||||
onFailure: (ApiResult.Failure) -> R,
|
||||
): R = when (this) {
|
||||
is ApiResult.Success -> onSuccess(data)
|
||||
is ApiResult.Failure -> onFailure(this)
|
||||
}
|
||||
|
||||
fun <T> ApiResult<T>.getOrNull(): T? = (this as? ApiResult.Success)?.data
|
||||
```
|
||||
|
||||
## Safe Request Wrapper
|
||||
|
||||
A `safeRequest` extension centralizes error handling so repositories stay focused on data mapping. This is one valid project-level pattern — not required for every project.
|
||||
|
||||
Pair with `expectSuccess = false` so the wrapper inspects status codes instead of catching Ktor's response exceptions:
|
||||
|
||||
```kotlin
|
||||
suspend inline fun <reified T> HttpClient.safeRequest(
|
||||
block: HttpRequestBuilder.() -> Unit,
|
||||
): ApiResult<T> {
|
||||
return try {
|
||||
val response = request { block() }
|
||||
when (response.status.value) {
|
||||
in 200..299 -> ApiResult.Success(response.body<T>())
|
||||
else -> classifyStatus(response.status.value, tryParseError(response))
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
classifyException(e)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For 204 No Content responses, use `Unit` as the type parameter: `safeRequest<Unit> { ... }`.
|
||||
|
||||
### Server error message extraction
|
||||
|
||||
Parse backend error envelopes safely — never fail if the error body is malformed:
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class ErrorDto(
|
||||
val message: String? = null,
|
||||
val error: String? = null,
|
||||
val detail: String? = null,
|
||||
) {
|
||||
val displayMessage: String? get() = message ?: error ?: detail
|
||||
}
|
||||
|
||||
suspend fun tryParseError(response: HttpResponse): String? = runCatching {
|
||||
response.body<ErrorDto>().displayMessage
|
||||
}.getOrNull()
|
||||
```
|
||||
|
||||
## Exception Classification
|
||||
|
||||
Map Ktor exceptions to error types. Used inside `safeRequest` with `ApiResult`, or at the ViewModel level with `Result<T>`.
|
||||
|
||||
```kotlin
|
||||
fun classifyException(e: Exception): ApiResult.Failure = when (e) {
|
||||
is HttpRequestTimeoutException,
|
||||
is ConnectTimeoutException,
|
||||
is SocketTimeoutException,
|
||||
-> ApiResult.Failure.Timeout("Request timed out")
|
||||
|
||||
is IOException,
|
||||
is UnresolvedAddressException,
|
||||
-> ApiResult.Failure.NetworkError("No internet connection")
|
||||
|
||||
is SerializationException,
|
||||
is JsonConvertException,
|
||||
is MissingFieldException,
|
||||
-> ApiResult.Failure.SerializationError("Invalid response format")
|
||||
|
||||
is ClientRequestException -> when (e.response.status.value) {
|
||||
401 -> ApiResult.Failure.Unauthorized()
|
||||
else -> ApiResult.Failure.HttpError(e.response.status.value, "Request failed")
|
||||
}
|
||||
|
||||
is ServerResponseException -> ApiResult.Failure.HttpError(
|
||||
e.response.status.value, "Server error",
|
||||
)
|
||||
|
||||
else -> ApiResult.Failure.Unknown(e)
|
||||
}
|
||||
|
||||
fun classifyStatus(code: Int, serverMessage: String? = null): ApiResult.Failure = when (code) {
|
||||
401 -> ApiResult.Failure.Unauthorized(serverMessage)
|
||||
403 -> ApiResult.Failure.HttpError(code, "Access denied", serverMessage)
|
||||
404 -> ApiResult.Failure.HttpError(code, "Not found", serverMessage)
|
||||
429 -> ApiResult.Failure.HttpError(code, "Too many requests", serverMessage)
|
||||
in 400..599 -> ApiResult.Failure.HttpError(code, if (code < 500) "Request failed" else "Server error", serverMessage)
|
||||
else -> ApiResult.Failure.HttpError(code, "Unexpected error", serverMessage)
|
||||
}
|
||||
```
|
||||
|
||||
`CancellationException` must always be re-thrown — never swallow it. It breaks structured concurrency.
|
||||
|
||||
## Plugin Composition
|
||||
|
||||
### What goes where
|
||||
|
||||
| Concern | Where | Why |
|
||||
|---|---|---|
|
||||
| Base URL, content type, static headers | `defaultRequest {}` | Runs per-request, reads live state |
|
||||
| JSON parsing | `ContentNegotiation` | Core plugin |
|
||||
| Timeouts | `HttpTimeout` | Default for every project |
|
||||
| Logging | `Logging` | Debug aid — sanitize `Authorization` in production |
|
||||
| Token load and refresh | `Auth` plugin | Built-in retry cycle — see [networking-ktor-auth.md](networking-ktor-auth.md) |
|
||||
| Retry on server errors | `HttpRequestRetry` | Add when the API has transient failures worth retrying |
|
||||
| Compression | `ContentEncoding` | Add for bandwidth-sensitive APIs |
|
||||
|
||||
### Plugin install order
|
||||
|
||||
Install order matters — plugins execute in installation order for requests, reverse order for responses.
|
||||
|
||||
```
|
||||
ContentNegotiation → Auth → HttpRequestRetry → HttpTimeout → ContentEncoding
|
||||
```
|
||||
|
||||
Install `HttpRequestRetry` before `HttpTimeout` so retries work on timeout errors. `Auth` handles 401s independently from `HttpRequestRetry` — keep these concerns separate.
|
||||
|
||||
## Custom Client Plugins
|
||||
|
||||
*Advanced — use when built-in plugins don't cover the need.*
|
||||
|
||||
Build reusable interceptors with `createClientPlugin` for analytics, header injection, or response logging:
|
||||
|
||||
```kotlin
|
||||
val ApiKeyPlugin = createClientPlugin("ApiKeyPlugin", ::ApiKeyConfig) {
|
||||
val apiKey = pluginConfig.apiKey
|
||||
|
||||
onRequest { request, _ ->
|
||||
request.headers.append("X-Api-Key", apiKey)
|
||||
}
|
||||
}
|
||||
|
||||
class ApiKeyConfig {
|
||||
var apiKey: String = ""
|
||||
}
|
||||
|
||||
val client = HttpClient(engine) {
|
||||
install(ApiKeyPlugin) {
|
||||
apiKey = "my-secret-key"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For global response observation (analytics, session expiry), use `onResponse` in a similar plugin without changing error handling.
|
||||
|
||||
## Debug vs Production Logging
|
||||
|
||||
| Concern | Debug | Production |
|
||||
|---|---|---|
|
||||
| Ktor `Logging` plugin | `LogLevel.BODY` | `LogLevel.HEADERS` or not installed |
|
||||
| `sanitizeHeader` | Optional | Required for `Authorization` |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it hurts | Better approach |
|
||||
|---|---|---|
|
||||
| `HttpClient` per request | Connection pool waste, resource leaks | Shared singleton via DI |
|
||||
| Swallowing `CancellationException` | Breaks structured concurrency, coroutine never cancels | Re-throw explicitly |
|
||||
| Logging request bodies in production | Leaks sensitive data (tokens, PII) | `LogLevel.HEADERS` or off; `sanitizeHeader` for auth |
|
||||
| Mixing `expectSuccess = true` with manual status inspection | `ClientRequestException` thrown before you inspect status | Pick one: `expectSuccess = true` + catch exceptions, or `false` + check `response.status` |
|
||||
| Random plugin install order | Retries fire before timeout, auth conflicts with retry | Follow documented composition order |
|
||||
| Forced specific result wrapper | Doesn't adapt to team conventions or project scale | Present `Result`/`ApiResult` as a project decision |
|
||||
@@ -1,204 +0,0 @@
|
||||
# Networking — Auth, WebSockets & SSE
|
||||
|
||||
Bearer token auth, WebSocket messaging, and Server-Sent Events for Ktor client. For core HttpClient setup see [networking-ktor.md](networking-ktor.md). For testing see [networking-ktor-testing.md](networking-ktor-testing.md).
|
||||
|
||||
References:
|
||||
- [Ktor bearer auth](https://ktor.io/docs/client-bearer-auth.html)
|
||||
- [Ktor WebSockets](https://ktor.io/docs/client-websockets.html)
|
||||
- [Ktor SSE](https://ktor.io/docs/client-server-sent-events.html)
|
||||
|
||||
## Bearer Token Auth
|
||||
|
||||
Use Ktor's `Auth` plugin with `bearer` for token management. The plugin handles loading cached tokens, attaching them to requests, and refreshing on 401 automatically.
|
||||
|
||||
### Default approach — `markAsRefreshTokenRequest()`
|
||||
|
||||
The Ktor-documented pattern uses `markAsRefreshTokenRequest()` inside `refreshTokens` so the refresh request itself is not intercepted by the auth plugin. This avoids circular auth loops without needing a separate client.
|
||||
|
||||
```kotlin
|
||||
fun createAuthenticatedClient(
|
||||
engine: HttpClientEngine,
|
||||
baseUrl: String,
|
||||
tokenStorage: TokenStorage,
|
||||
onSessionExpired: () -> Unit,
|
||||
): HttpClient {
|
||||
return HttpClient(engine) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json { ignoreUnknownKeys = true })
|
||||
}
|
||||
|
||||
defaultRequest { url(baseUrl) }
|
||||
|
||||
install(Auth) {
|
||||
bearer {
|
||||
loadTokens {
|
||||
val tokens = tokenStorage.getTokens()
|
||||
BearerTokens(tokens.accessToken, tokens.refreshToken)
|
||||
}
|
||||
|
||||
refreshTokens {
|
||||
val refreshToken = oldTokens?.refreshToken
|
||||
?: return@refreshTokens null
|
||||
|
||||
try {
|
||||
markAsRefreshTokenRequest()
|
||||
val response = client.post("auth/refresh") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(RefreshRequest(refreshToken))
|
||||
}.body<TokenResponse>()
|
||||
|
||||
tokenStorage.saveTokens(response.accessToken, response.refreshToken)
|
||||
BearerTokens(response.accessToken, response.refreshToken)
|
||||
} catch (e: Exception) {
|
||||
onSessionExpired()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
sendWithoutRequest { request ->
|
||||
request.url.pathSegments.none { it in listOf("login", "register") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Key points:**
|
||||
- `markAsRefreshTokenRequest()` — prevents the refresh call from being intercepted by the `Auth` plugin, avoiding infinite loops.
|
||||
- `oldTokens` — provided by Ktor's `RefreshTokensParams` receiver, gives access to the expired tokens.
|
||||
- `sendWithoutRequest` — controls which endpoints skip authentication entirely (login, register, public endpoints).
|
||||
- Return `null` from `refreshTokens` to signal that refresh failed — Ktor will not retry the original request.
|
||||
|
||||
### TokenStorage interface
|
||||
|
||||
Implement with DataStore, encrypted SharedPreferences, or Keychain depending on platform. The interface uses app-owned types — convert to `BearerTokens` only at the plugin boundary.
|
||||
|
||||
```kotlin
|
||||
interface TokenStorage {
|
||||
suspend fun getTokens(): AuthTokens
|
||||
suspend fun saveTokens(accessToken: String, refreshToken: String)
|
||||
suspend fun clearTokens()
|
||||
}
|
||||
|
||||
data class AuthTokens(val accessToken: String, val refreshToken: String)
|
||||
```
|
||||
|
||||
## Advanced: Isolated Refresh Client
|
||||
|
||||
Some teams prefer a dedicated `HttpClient` for the refresh call — one with no `Auth` plugin installed — to guarantee the refresh request cannot trigger another auth cycle. This is a valid alternative when the team wants explicit separation, but `markAsRefreshTokenRequest()` achieves the same goal with less ceremony.
|
||||
|
||||
```kotlin
|
||||
private suspend fun refreshBearerToken(
|
||||
baseUrl: String,
|
||||
tokenStorage: TokenStorage,
|
||||
onSessionExpired: () -> Unit,
|
||||
): BearerTokens? {
|
||||
val tokens = tokenStorage.getTokens()
|
||||
val refreshToken = tokens.refreshToken.ifBlank { null } ?: return null
|
||||
return try {
|
||||
HttpClient {
|
||||
install(ContentNegotiation) { json() }
|
||||
}.use { refreshClient ->
|
||||
val response = refreshClient.post(baseUrl + "auth/refresh") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(RefreshRequest(refreshToken))
|
||||
}.body<TokenResponse>()
|
||||
tokenStorage.saveTokens(response.accessToken, response.refreshToken)
|
||||
BearerTokens(response.accessToken, response.refreshToken)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
onSessionExpired()
|
||||
null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
If using this pattern, call it from inside `refreshTokens` instead of using `client` directly. Close the refresh client after use (`.use {}` handles this).
|
||||
|
||||
## WebSocket Support
|
||||
|
||||
### Dependencies
|
||||
|
||||
Add `ktor-client-websockets` to your version catalog and `commonMain` dependencies.
|
||||
|
||||
### Connection and messaging
|
||||
|
||||
```kotlin
|
||||
val client = HttpClient(engine) {
|
||||
install(WebSockets) {
|
||||
pingIntervalMillis = 30_000
|
||||
}
|
||||
}
|
||||
|
||||
client.webSocket("wss://api.example.com/ws") {
|
||||
send(Frame.Text(Json.encodeToString(SubscribeMessage("items"))))
|
||||
|
||||
for (frame in incoming) {
|
||||
when (frame) {
|
||||
is Frame.Text -> {
|
||||
val message = Json.decodeFromString<ServerMessage>(frame.readText())
|
||||
// handle message
|
||||
}
|
||||
is Frame.Close -> break
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Session reference for external control
|
||||
|
||||
```kotlin
|
||||
val session = client.webSocketSession("wss://api.example.com/ws")
|
||||
session.send(Frame.Text("hello"))
|
||||
val response = session.incoming.receive() as Frame.Text
|
||||
session.close()
|
||||
```
|
||||
|
||||
### Serialization converter
|
||||
|
||||
Type-safe WebSocket messaging using kotlinx.serialization:
|
||||
|
||||
```kotlin
|
||||
install(WebSockets) {
|
||||
contentConverter = KotlinxWebsocketSerializationConverter(Json)
|
||||
}
|
||||
|
||||
client.webSocket("wss://api.example.com/ws") {
|
||||
sendSerialized(SubscribeMessage("items"))
|
||||
val message = receiveDeserialized<ServerMessage>()
|
||||
}
|
||||
```
|
||||
|
||||
## Server-Sent Events (SSE)
|
||||
|
||||
SSE provides server-push updates over HTTP. Unlike WebSockets, SSE is unidirectional (server to client) and works over standard HTTP. SSE support is built into `ktor-client-core` — no extra dependency needed.
|
||||
|
||||
### Basic usage
|
||||
|
||||
```kotlin
|
||||
val client = HttpClient(engine) {
|
||||
install(SSE)
|
||||
}
|
||||
|
||||
client.sse("https://api.example.com/events") {
|
||||
incoming.collect { event ->
|
||||
println("Event: ${event.event}")
|
||||
println("Data: ${event.data}")
|
||||
println("ID: ${event.id}")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### When to use SSE vs WebSocket
|
||||
|
||||
| Criterion | SSE | WebSocket |
|
||||
|---|---|---|
|
||||
| Direction | Server -> Client only | Bidirectional |
|
||||
| Protocol | HTTP (standard) | WebSocket (protocol upgrade) |
|
||||
| Auto-reconnect | Built-in | Manual |
|
||||
| Binary data | No (text only) | Yes |
|
||||
| Use case | Live feeds, notifications, progress, streaming AI | Chat, gaming, real-time collaboration |
|
||||
|
||||
Prefer SSE for server-push scenarios. Use WebSockets when the client also needs to send frequent messages.
|
||||
@@ -1,153 +0,0 @@
|
||||
# Networking — Testing & DI
|
||||
|
||||
MockEngine testing patterns and Koin/Hilt DI integration for Ktor client. For core HttpClient setup see [networking-ktor.md](networking-ktor.md). For error handling patterns see [networking-ktor-architecture.md](networking-ktor-architecture.md).
|
||||
|
||||
References:
|
||||
- [Ktor testing](https://ktor.io/docs/client-testing.html)
|
||||
- [Ktor MockEngine](https://api.ktor.io/ktor-client-mock/io.ktor.client.engine.mock/-mock-engine/index.html)
|
||||
|
||||
## Testing with MockEngine
|
||||
|
||||
### Setup
|
||||
|
||||
```kotlin
|
||||
// commonTest
|
||||
testImplementation("io.ktor:ktor-client-mock:$ktor_version")
|
||||
```
|
||||
|
||||
### Testing API calls
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getItem returns mapped domain model`() = runTest {
|
||||
val mockEngine = MockEngine { request ->
|
||||
assertEquals("/items/123", request.url.encodedPath)
|
||||
respond(
|
||||
content = """{"id":"123","name":"Test","status":"active","created_at":1700000000}""",
|
||||
status = HttpStatusCode.OK,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val client = createHttpClient(mockEngine, "https://api.example.com/")
|
||||
val repo = ItemRepositoryImpl(ItemApi(client))
|
||||
val result = repo.getItem("123")
|
||||
assertEquals("Test", result.name)
|
||||
}
|
||||
```
|
||||
|
||||
### Testing error handling
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `getItem throws on 404`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
|
||||
}
|
||||
val client = HttpClient(mockEngine) {
|
||||
expectSuccess = true
|
||||
install(ContentNegotiation) { json() }
|
||||
}
|
||||
val api = ItemApi(client)
|
||||
assertFailsWith<ClientRequestException> { api.getItem("999") }
|
||||
}
|
||||
```
|
||||
|
||||
If using a `safeRequest` wrapper (see [networking-ktor-architecture.md](networking-ktor-architecture.md)), test the wrapper's return type instead:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `safeRequest returns failure on 404`() = runTest {
|
||||
val mockEngine = MockEngine {
|
||||
respond(content = """{"error":"not found"}""", status = HttpStatusCode.NotFound)
|
||||
}
|
||||
val client = HttpClient(mockEngine) {
|
||||
expectSuccess = false
|
||||
install(ContentNegotiation) { json() }
|
||||
}
|
||||
val result = client.safeRequest<ItemDto> { url("items/999") }
|
||||
assertTrue(result.isFailure) // or check sealed class variant
|
||||
}
|
||||
```
|
||||
|
||||
### Request assertions
|
||||
|
||||
Verify request method, headers, body, and query parameters:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `createItem sends correct request`() = runTest {
|
||||
val mockEngine = MockEngine { request ->
|
||||
assertEquals(HttpMethod.Post, request.method)
|
||||
assertEquals("application/json", request.body.contentType?.toString())
|
||||
|
||||
val body = (request.body as TextContent).text
|
||||
assertTrue(body.contains("\"name\":\"Widget\""))
|
||||
|
||||
respond(
|
||||
content = """{"id":"1","name":"Widget","status":"active","created_at":1700000000}""",
|
||||
status = HttpStatusCode.Created,
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
}
|
||||
|
||||
val client = createHttpClient(mockEngine, "https://api.example.com/")
|
||||
val api = ItemApi(client)
|
||||
val result = api.createItem(CreateItemRequest(name = "Widget"))
|
||||
assertEquals("Widget", result.name)
|
||||
}
|
||||
```
|
||||
|
||||
### Multiple responses
|
||||
|
||||
MockEngine can return different responses based on path:
|
||||
|
||||
```kotlin
|
||||
val mockEngine = MockEngine { request ->
|
||||
when (request.url.encodedPath) {
|
||||
"/items" -> respond(
|
||||
content = """{"items":[],"total":0}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
"/items/1" -> respond(
|
||||
content = """{"id":"1","name":"Test","status":"active","created_at":1700000000}""",
|
||||
headers = headersOf(HttpHeaders.ContentType, "application/json"),
|
||||
)
|
||||
else -> respondError(HttpStatusCode.NotFound)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Engine injection for testability
|
||||
|
||||
Accept `HttpClientEngine` as a constructor parameter so you can inject `MockEngine` in tests:
|
||||
|
||||
```kotlin
|
||||
// Production: ItemApi(createHttpClient(OkHttp.create(), baseUrl))
|
||||
// Test: ItemApi(createHttpClient(MockEngine { ... }, baseUrl))
|
||||
```
|
||||
|
||||
Share the same `createHttpClient` factory in production and tests to keep plugin configuration consistent.
|
||||
|
||||
## DI Integration
|
||||
|
||||
Provide `HttpClient` and `HttpClientEngine` as singletons. Use `expect/actual` platform modules for engine selection (OkHttp on Android, Darwin on iOS):
|
||||
|
||||
```kotlin
|
||||
// Koin: single { createHttpClient(engine = get(), baseUrl = "https://api.example.com/") }
|
||||
// Hilt: @Provides @Singleton fun provideHttpClient(): HttpClient = createHttpClient(...)
|
||||
```
|
||||
|
||||
For full Koin module patterns (including platform engine modules), see [koin.md](koin.md). For Hilt module patterns, see [hilt.md](hilt.md).
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it hurts | Better replacement |
|
||||
|---|---|---|
|
||||
| DTOs used directly in UI state | UI coupled to API contract, breaks on API changes | Map to domain models at repository boundary |
|
||||
| Network calls in composables | Violates UDF, untestable, reruns on recomposition | Call from ViewModel, expose via StateFlow |
|
||||
| No timeout configuration | Requests hang indefinitely on bad networks | Set `connectTimeoutMillis`, `requestTimeoutMillis`, `socketTimeoutMillis` |
|
||||
| Hardcoded base URLs | Can't switch environments (dev/staging/prod) | Inject base URL via config or DI |
|
||||
| Parsing/mapping in the API service | Mixes concerns, harder to test | API service returns DTOs; repository maps to domain |
|
||||
| Creating a new `HttpClient` per test | Tests miss plugin-config mismatches | Use the same `createHttpClient` factory with `MockEngine` |
|
||||
| No compression | Wastes bandwidth on text-heavy APIs | `install(ContentEncoding) { gzip() }` |
|
||||
@@ -1,270 +0,0 @@
|
||||
# Networking with Ktor Client
|
||||
|
||||
Default Ktor client setup for Compose Multiplatform and Android projects. Advanced topics in separate files:
|
||||
|
||||
- [Architecture decisions](networking-ktor-architecture.md) — result wrappers, error classification, plugin composition *(optional)*
|
||||
- [Auth, WebSockets & SSE](networking-ktor-auth.md) — bearer tokens, realtime *(use when needed)*
|
||||
- [Testing & DI](networking-ktor-testing.md) — MockEngine, Koin/Hilt wiring
|
||||
|
||||
References:
|
||||
- [Ktor client overview](https://ktor.io/docs/client.html)
|
||||
- [Ktor client plugins](https://ktor.io/docs/client-plugins.html)
|
||||
- [Ktor content negotiation](https://ktor.io/docs/client-serialization.html)
|
||||
|
||||
## Dependencies and Platform Engines
|
||||
|
||||
### Version catalog
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
ktor = "<latest>" # verify: https://ktor.io/docs/releases.html or Maven Central
|
||||
|
||||
[libraries]
|
||||
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
||||
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
||||
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
||||
```
|
||||
|
||||
As needed: `ktor-client-auth`, `ktor-client-websockets`, `ktor-client-resources`, `ktor-client-encoding` (same `version.ref = "ktor"` pattern).
|
||||
|
||||
### build.gradle.kts
|
||||
|
||||
```kotlin
|
||||
commonMain.dependencies {
|
||||
implementation(libs.ktor.client.core)
|
||||
implementation(libs.ktor.client.content.negotiation)
|
||||
implementation(libs.ktor.serialization.kotlinx.json)
|
||||
implementation(libs.ktor.client.logging)
|
||||
}
|
||||
|
||||
androidMain.dependencies {
|
||||
implementation(libs.ktor.client.okhttp)
|
||||
}
|
||||
|
||||
iosMain.dependencies {
|
||||
implementation(libs.ktor.client.darwin)
|
||||
}
|
||||
|
||||
jvmMain.dependencies {
|
||||
implementation(libs.ktor.client.cio)
|
||||
}
|
||||
|
||||
commonTest.dependencies {
|
||||
implementation(libs.ktor.client.mock)
|
||||
}
|
||||
```
|
||||
|
||||
### Platform engine selection
|
||||
|
||||
| Platform | Engine | Dependency |
|
||||
|---|---|---|
|
||||
| Android | OkHttp | `ktor-client-okhttp` |
|
||||
| iOS | Darwin (NSURLSession) | `ktor-client-darwin` |
|
||||
| JVM/Desktop | CIO | `ktor-client-cio` |
|
||||
| All (testing) | MockEngine | `ktor-client-mock` |
|
||||
|
||||
For CMP, select the engine per source set. For Android-only, use OkHttp directly.
|
||||
|
||||
## HttpClient Configuration
|
||||
|
||||
Create a single, reusable `HttpClient` instance. Never create one per request.
|
||||
|
||||
```kotlin
|
||||
fun createHttpClient(engine: HttpClientEngine, baseUrl: String): HttpClient {
|
||||
return HttpClient(engine) {
|
||||
install(ContentNegotiation) {
|
||||
json(Json {
|
||||
ignoreUnknownKeys = true // ignore unknown JSON fields
|
||||
coerceInputValues = true // null → defaults for non-null props
|
||||
encodeDefaults = true // include defaults when serializing
|
||||
})
|
||||
}
|
||||
|
||||
defaultRequest {
|
||||
url(baseUrl)
|
||||
headers.append("Accept", "application/json")
|
||||
}
|
||||
|
||||
install(HttpTimeout) {
|
||||
connectTimeoutMillis = 15_000
|
||||
requestTimeoutMillis = 30_000
|
||||
socketTimeoutMillis = 15_000
|
||||
}
|
||||
|
||||
install(Logging) {
|
||||
logger = Logger.DEFAULT
|
||||
level = LogLevel.HEADERS
|
||||
sanitizeHeader { it == "Authorization" }
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is the minimal production-ready client. Add plugins incrementally when the project needs them — auth, retry, compression, and content encoding are covered in the sub-references.
|
||||
|
||||
Set `isLenient = true` only for non-standard APIs; it accepts malformed JSON and can hide data issues in production.
|
||||
|
||||
### `expectSuccess` — choose based on error strategy
|
||||
|
||||
| Setting | Behavior | Use when |
|
||||
|---|---|---|
|
||||
| `true` (Ktor default) | Throws `ClientRequestException` / `ServerResponseException` on non-2xx | Using `try/catch` or `runCatching` for error handling |
|
||||
| `false` | Returns the response regardless of status | Inspecting `response.status` manually in a custom wrapper |
|
||||
|
||||
Both are valid. Pick one approach and apply it consistently. See [networking-ktor-architecture.md](networking-ktor-architecture.md) for wrapper patterns that pair with `expectSuccess = false`.
|
||||
|
||||
## DTO Models and Serialization
|
||||
|
||||
```kotlin
|
||||
@Serializable
|
||||
data class ItemListDto(
|
||||
val items: List<ItemDto>,
|
||||
val total: Int,
|
||||
@SerialName("next_page") val nextPage: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ItemDto(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val status: StatusDto = StatusDto.ACTIVE,
|
||||
@SerialName("created_at") val createdAt: Long,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class StatusDto {
|
||||
@SerialName("active") ACTIVE,
|
||||
@SerialName("archived") ARCHIVED,
|
||||
}
|
||||
```
|
||||
|
||||
Always `@Serializable` on DTOs, `@SerialName` when JSON keys differ, default values for optional fields. DTOs mirror the API contract — no business logic.
|
||||
|
||||
## DTO-to-Domain Mappers
|
||||
|
||||
Map at the repository boundary. Domain models have no serialization annotations.
|
||||
|
||||
```kotlin
|
||||
data class Item(val id: String, val name: String, val status: ItemStatus, val createdAt: Long)
|
||||
enum class ItemStatus { ACTIVE, ARCHIVED }
|
||||
|
||||
fun ItemDto.toDomain() = Item(
|
||||
id = id,
|
||||
name = name,
|
||||
status = ItemStatus.valueOf(status.name),
|
||||
createdAt = createdAt,
|
||||
)
|
||||
|
||||
fun List<ItemDto>.toDomain() = map { it.toDomain() }
|
||||
```
|
||||
|
||||
## API Service Layer
|
||||
|
||||
Wrap `HttpClient` in a service class with typed methods:
|
||||
|
||||
```kotlin
|
||||
class ItemApi(private val client: HttpClient) {
|
||||
|
||||
suspend fun getItems(page: Int = 1, limit: Int = 20): ItemListDto {
|
||||
return client.get("items") {
|
||||
parameter("page", page)
|
||||
parameter("limit", limit)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun getItem(id: String): ItemDto = client.get("items/$id").body()
|
||||
|
||||
suspend fun createItem(request: CreateItemRequest): ItemDto {
|
||||
return client.post("items") {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(request)
|
||||
}.body()
|
||||
}
|
||||
|
||||
suspend fun deleteItem(id: String) { client.delete("items/$id") }
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class CreateItemRequest(val name: String)
|
||||
```
|
||||
|
||||
## Repository Pattern
|
||||
|
||||
The repository maps DTOs to domain models and handles errors. The error-handling approach is a project decision — see [networking-ktor-architecture.md](networking-ktor-architecture.md) for `Result<T>` vs custom sealed class options.
|
||||
|
||||
### Simple approach — exceptions bubble up
|
||||
|
||||
```kotlin
|
||||
interface ItemRepository {
|
||||
suspend fun getItems(): List<Item>
|
||||
suspend fun getItem(id: String): Item
|
||||
}
|
||||
|
||||
class ItemRepositoryImpl(private val api: ItemApi) : ItemRepository {
|
||||
|
||||
override suspend fun getItems(): List<Item> {
|
||||
return api.getItems().items.toDomain()
|
||||
}
|
||||
|
||||
override suspend fun getItem(id: String): Item {
|
||||
return api.getItem(id).toDomain()
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The ViewModel catches exceptions and updates state. This works well for simpler apps. For `Result` / richer error classification, see [networking-ktor-architecture.md](networking-ktor-architecture.md).
|
||||
|
||||
### Offline-first pattern
|
||||
|
||||
Local DB as the single source of truth. Repository syncs remote data into local storage. UI observes the local `Flow`.
|
||||
|
||||
```kotlin
|
||||
class OfflineFirstItemRepository(
|
||||
private val api: ItemApi,
|
||||
private val dao: ItemDao,
|
||||
) : ItemRepository {
|
||||
|
||||
val items: Flow<List<Item>> = dao.observeAll().map { it.map { e -> e.toDomain() } }
|
||||
|
||||
suspend fun refresh() {
|
||||
val remote = api.getItems().items
|
||||
dao.replaceAll(remote.map { it.toEntity() })
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Type-Safe Resources (Optional)
|
||||
|
||||
The Ktor Resources plugin maps `@Resource`-annotated data classes to HTTP paths for compile-time URL safety. Add `ktor-client-resources` to the catalog and `implementation(libs.ktor.client.resources)`; `install(Resources)` alongside `ContentNegotiation`. Reference: [Ktor type-safe requests](https://ktor.io/docs/client-resources.html).
|
||||
|
||||
```kotlin
|
||||
import io.ktor.resources.*
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
@Resource("/articles")
|
||||
class Articles {
|
||||
@Serializable
|
||||
@Resource("{id}")
|
||||
class ById(val parent: Articles = Articles(), val id: Int)
|
||||
|
||||
@Serializable
|
||||
@Resource("search")
|
||||
class Search(val parent: Articles = Articles(), val query: String, val page: Int = 1)
|
||||
}
|
||||
|
||||
// Nested paths and query params resolve from the resource tree, e.g. /articles/42, /articles/search?query=compose&page=1
|
||||
val articles: List<ArticleDto> = client.get(Articles()).body()
|
||||
val article: ArticleDto = client.get(Articles.ById(id = 42)).body()
|
||||
val results: ArticleListDto = client.get(Articles.Search(query = "compose")).body()
|
||||
val created: ArticleDto = client.post(Articles()) {
|
||||
contentType(ContentType.Application.Json)
|
||||
setBody(CreateArticleRequest(title = "New Article"))
|
||||
}.body()
|
||||
client.delete(Articles.ById(id = 42))
|
||||
```
|
||||
@@ -1,150 +0,0 @@
|
||||
# Paging 3 — MVI Integration & Testing
|
||||
|
||||
MVI dual-flow pattern for paging, testing strategies, and anti-patterns. This file builds on the core Paging setup in [paging.md](paging.md).
|
||||
|
||||
References:
|
||||
- [Paging testing](https://developer.android.com/topic/libraries/architecture/paging/test)
|
||||
|
||||
## MVI Integration
|
||||
|
||||
PagingData must be a **separate Flow** from the MVI ViewModel state. The ViewModel handles non-paging concerns (filters, selection mode, errors). PagingData flows independently.
|
||||
|
||||
```kotlin
|
||||
class ItemListViewModel(
|
||||
private val repository: ItemRepository,
|
||||
) : ViewModel() {
|
||||
|
||||
// MVI state — non-paging concerns
|
||||
private val _state = MutableStateFlow(ItemListState())
|
||||
val state: StateFlow<ItemListState> = _state.asStateFlow()
|
||||
|
||||
// PagingData — separate Flow, reacts to filter changes
|
||||
private val _statusFilter = MutableStateFlow(StatusFilter.ALL)
|
||||
|
||||
val items: Flow<PagingData<ItemUi>> = _statusFilter
|
||||
.distinctUntilChanged()
|
||||
.flatMapLatest { status ->
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = 20),
|
||||
pagingSourceFactory = { repository.itemPagingSource(status) },
|
||||
).flow.map { pagingData -> pagingData.map { it.toUi() } }
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
fun onEvent(event: ItemListEvent) {
|
||||
when (event) {
|
||||
is ItemListEvent.FilterChanged -> {
|
||||
_statusFilter.value = event.filter
|
||||
_state.update { it.copy(selectedFilter = event.filter) }
|
||||
}
|
||||
is ItemListEvent.ItemClicked -> {
|
||||
// emit navigation effect
|
||||
}
|
||||
is ItemListEvent.SelectionToggled -> {
|
||||
_state.update { it.copy(selectedIds = it.selectedIds.toggle(event.id)) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Route collects both flows
|
||||
|
||||
The route composable collects both the MVI state and PagingData flow, then passes them to the stateless screen composable. Use a DI-agnostic ViewModel parameter.
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ItemListRoute(viewModel: ItemListViewModel) {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
val pagingItems = viewModel.items.collectAsLazyPagingItems()
|
||||
|
||||
ItemListScreen(
|
||||
state = state,
|
||||
pagingItems = pagingItems,
|
||||
onEvent = viewModel::onEvent,
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
The screen composable is dumb — it receives `LazyPagingItems` and state as props, emits events as callbacks.
|
||||
|
||||
## Testing
|
||||
|
||||
### PagingSource unit test
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `load returns page of items`() = runTest {
|
||||
val mockApi = MockItemApi(items = listOf(item1, item2))
|
||||
val pagingSource = ItemPagingSource(api = mockApi, query = "")
|
||||
|
||||
val result = pagingSource.load(
|
||||
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
|
||||
)
|
||||
|
||||
assertTrue(result is PagingSource.LoadResult.Page)
|
||||
val page = result as PagingSource.LoadResult.Page
|
||||
assertEquals(2, page.data.size)
|
||||
assertEquals(null, page.prevKey)
|
||||
assertEquals(2, page.nextKey)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `load returns error on network failure`() = runTest {
|
||||
val mockApi = MockItemApi(error = IOException("Network error"))
|
||||
val pagingSource = ItemPagingSource(api = mockApi, query = "")
|
||||
|
||||
val result = pagingSource.load(
|
||||
PagingSource.LoadParams.Refresh(key = null, loadSize = 20, placeholdersEnabled = false)
|
||||
)
|
||||
|
||||
assertTrue(result is PagingSource.LoadResult.Error)
|
||||
}
|
||||
```
|
||||
|
||||
### Testing with asSnapshot
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `items flow loads first two pages`() = runTest {
|
||||
val viewModel = ItemListViewModel(FakeRepository())
|
||||
|
||||
val items = viewModel.items.asSnapshot {
|
||||
scrollTo(index = 30)
|
||||
}
|
||||
|
||||
assertTrue(items.size >= 30)
|
||||
assertEquals("item_1", items.first().id)
|
||||
}
|
||||
```
|
||||
|
||||
### Testing transformations
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `paging data maps dto to ui model`() = runTest {
|
||||
val dtos = listOf(ItemDto(id = "1", title = "Test", amount = 100.0))
|
||||
val pagingSource = dtos.asPagingSourceFactory().invoke()
|
||||
|
||||
val pager = TestPager(PagingConfig(pageSize = 10), pagingSource)
|
||||
val result = pager.refresh() as PagingSource.LoadResult.Page
|
||||
|
||||
assertEquals(1, result.data.size)
|
||||
assertEquals("1", result.data.first().id)
|
||||
}
|
||||
```
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it hurts | Fix |
|
||||
|---|---|---|
|
||||
| `PagingData` inside `UiState` StateFlow | Any non-paging state change re-emits the wrapping StateFlow, creating a new flow for `collectAsLazyPagingItems()` and resetting scroll position ([official codelab](https://github.com/android/codelab-android-paging) uses separate flows) | Expose PagingData as **separate** `Flow` |
|
||||
| New `Pager` per recomposition | Duplicate network requests, lost pagination state | Store `Flow` as `val` in ViewModel |
|
||||
| Reusing `PagingSource` instance | Crash: "PagingSource was re-used" | Always create new instance in `pagingSourceFactory` |
|
||||
| Missing `cachedIn(viewModelScope)` | Data lost on config change, duplicate loads | Always call `cachedIn` |
|
||||
| Missing list keys | Scroll jumps, state corruption on updates | `itemKey { it.id }` with stable domain IDs |
|
||||
| `combine` on `PagingData` flows | "Collecting from multiple PagingData concurrently" error | Use `flatMapLatest` for parameter changes |
|
||||
| Calling `refresh()` in composable body | Infinite refresh loop on every recomposition | Call from event handler or `LaunchedEffect` |
|
||||
| No `LoadState` handling | Broken UX: no loading indicator, no error recovery | Handle `refresh`, `append`, `prepend` states |
|
||||
| Transformations after `cachedIn` | Transformations lost on cache hit | Apply `.map { }` / `.filter { }` **before** `cachedIn` |
|
||||
| Catching generic `Exception` in PagingSource | Hides bugs, swallows unexpected errors | Catch `IOException`, `HttpException` specifically |
|
||||
@@ -1,130 +0,0 @@
|
||||
# Paging 3 — Offline-First with RemoteMediator
|
||||
|
||||
Room as the single source of truth, network as the refresh trigger. This file builds on the core Paging setup in [paging.md](paging.md).
|
||||
|
||||
References:
|
||||
- [Network + database paging](https://developer.android.com/topic/libraries/architecture/paging/v3-network-db)
|
||||
|
||||
## RemoteMediator.initialize
|
||||
|
||||
Override `initialize()` to control whether RemoteMediator triggers a remote refresh on first load. This determines if cached data is shown immediately or if a network request fires first.
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
override suspend fun initialize(): InitializeAction {
|
||||
val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
|
||||
val lastUpdated = db.remoteKeyDao().getLastUpdated("items") ?: 0L
|
||||
|
||||
return if (System.currentTimeMillis() - lastUpdated < cacheTimeout) {
|
||||
InitializeAction.SKIP_INITIAL_REFRESH
|
||||
} else {
|
||||
InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Return value | Behavior |
|
||||
|---|---|
|
||||
| `LAUNCH_INITIAL_REFRESH` | Triggers `REFRESH` load immediately — fetches fresh data from network before showing cached data. **Default** if `initialize()` is not overridden. |
|
||||
| `SKIP_INITIAL_REFRESH` | Shows cached Room data immediately, only fetches from network on user-triggered refresh or append. Use when cache is still fresh. |
|
||||
|
||||
## RemoteMediator Implementation
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
class ItemRemoteMediator(
|
||||
private val api: ItemApi,
|
||||
private val db: AppDatabase,
|
||||
) : RemoteMediator<Int, ItemEntity>() {
|
||||
|
||||
override suspend fun initialize(): InitializeAction {
|
||||
val lastUpdated = db.remoteKeyDao().getLastUpdated("items") ?: 0L
|
||||
val cacheTimeout = TimeUnit.MILLISECONDS.convert(1, TimeUnit.HOURS)
|
||||
return if (System.currentTimeMillis() - lastUpdated < cacheTimeout) {
|
||||
InitializeAction.SKIP_INITIAL_REFRESH
|
||||
} else {
|
||||
InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
loadType: LoadType,
|
||||
state: PagingState<Int, ItemEntity>,
|
||||
): MediatorResult {
|
||||
val page = when (loadType) {
|
||||
LoadType.REFRESH -> 1
|
||||
LoadType.PREPEND -> return MediatorResult.Success(endOfPaginationReached = true)
|
||||
LoadType.APPEND -> {
|
||||
val remoteKey = db.remoteKeyDao().getRemoteKey("items")
|
||||
remoteKey?.nextPage ?: return MediatorResult.Success(endOfPaginationReached = true)
|
||||
}
|
||||
}
|
||||
|
||||
return try {
|
||||
val response = api.getItems(page = page, limit = state.config.pageSize)
|
||||
|
||||
db.withTransaction {
|
||||
if (loadType == LoadType.REFRESH) {
|
||||
db.itemDao().clearAll()
|
||||
db.remoteKeyDao().delete("items")
|
||||
}
|
||||
db.itemDao().insertAll(response.items.map { it.toEntity() })
|
||||
db.remoteKeyDao().insert(
|
||||
RemoteKey(
|
||||
id = "items",
|
||||
nextPage = if (response.items.isEmpty()) null else page + 1,
|
||||
lastUpdated = System.currentTimeMillis(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
MediatorResult.Success(endOfPaginationReached = response.items.isEmpty())
|
||||
} catch (e: IOException) {
|
||||
MediatorResult.Error(e)
|
||||
} catch (e: HttpException) {
|
||||
MediatorResult.Error(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Pager Wiring
|
||||
|
||||
```kotlin
|
||||
@OptIn(ExperimentalPagingApi::class)
|
||||
val items: Flow<PagingData<ItemEntity>> = Pager(
|
||||
config = PagingConfig(pageSize = 20),
|
||||
remoteMediator = ItemRemoteMediator(api, db),
|
||||
pagingSourceFactory = { db.itemDao().pagingSource() },
|
||||
).flow.cachedIn(viewModelScope)
|
||||
```
|
||||
|
||||
The `PagingSource` reads from Room. The `RemoteMediator` fetches from network and writes to Room. The UI observes the Room-backed `PagingSource`.
|
||||
|
||||
**LoadState with RemoteMediator:** use `loadState.source.refresh` (not `loadState.refresh`) in UI code. The convenience `loadState.refresh` may report network completion before Room finishes writing, causing the loading indicator to disappear too early. See [official guidance](https://developer.android.com/topic/libraries/architecture/paging/v3-compose).
|
||||
|
||||
## Remote Keys
|
||||
|
||||
```kotlin
|
||||
@Entity(tableName = "remote_keys")
|
||||
data class RemoteKey(
|
||||
@PrimaryKey val id: String,
|
||||
val nextPage: Int?,
|
||||
val lastUpdated: Long = System.currentTimeMillis(),
|
||||
)
|
||||
|
||||
@Dao
|
||||
interface RemoteKeyDao {
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(key: RemoteKey)
|
||||
|
||||
@Query("SELECT * FROM remote_keys WHERE id = :id")
|
||||
suspend fun getRemoteKey(id: String): RemoteKey?
|
||||
|
||||
@Query("SELECT lastUpdated FROM remote_keys WHERE id = :id")
|
||||
suspend fun getLastUpdated(id: String): Long?
|
||||
|
||||
@Query("DELETE FROM remote_keys WHERE id = :id")
|
||||
suspend fun delete(id: String)
|
||||
}
|
||||
```
|
||||
@@ -1,219 +0,0 @@
|
||||
# Paging 3
|
||||
|
||||
Paging 3 setup, PagingSource, transformations, and LazyColumn integration.
|
||||
|
||||
References:
|
||||
- [Paging 3 with Compose](https://developer.android.com/topic/libraries/architecture/paging/v3-compose)
|
||||
- [Load and display paged data](https://developer.android.com/topic/libraries/architecture/paging/v3-paged-data)
|
||||
- [LoadState management](https://developer.android.com/topic/libraries/architecture/paging/load-state)
|
||||
|
||||
## Critical Performance Rules
|
||||
|
||||
1. **PagingData must be a separate Flow, NEVER inside UiState** — wrapping in `data class UiState(val pagingData: PagingData<T>)` causes scroll-to-top on any state change. Use two separate properties: `state: StateFlow<UiState>` + `pagingDataFlow: Flow<PagingData>`. See [anti-patterns](paging-mvi-testing.md#anti-patterns)
|
||||
2. **Never create a new Pager per recomposition** — store the Flow as a `val` in ViewModel
|
||||
3. **Always `cachedIn(viewModelScope)`** — prevents data loss on config change
|
||||
4. **Always provide stable keys** — `itemKey { it.id }` prevents scroll jumps
|
||||
5. **Use `flatMapLatest` for parameter changes** — not `combine` on PagingData flows
|
||||
|
||||
## Dependencies
|
||||
|
||||
```kotlin
|
||||
// Android / commonMain
|
||||
implementation("androidx.paging:paging-compose:3.3.6")
|
||||
implementation("androidx.paging:paging-common:3.3.6")
|
||||
testImplementation("androidx.paging:paging-testing:3.3.6")
|
||||
```
|
||||
|
||||
KMP support (since 3.3.0-alpha02): `paging-common` and `paging-compose` work in `commonMain` (Android, JVM, iOS). `paging-runtime` is Android-only (RecyclerView adapters, not needed in Compose). Verify Web/WASM support for your version.
|
||||
|
||||
## Core Data Flow
|
||||
|
||||
```text
|
||||
PagingSource -> Pager(config, factory) -> Flow<PagingData<T>>
|
||||
-> .cachedIn(viewModelScope) -> collectAsLazyPagingItems() -> LazyColumn/Grid/Pager
|
||||
```
|
||||
|
||||
| Component | Role |
|
||||
|---|---|
|
||||
| `PagingSource<Key, Value>` | Loads pages from a single source |
|
||||
| `RemoteMediator` | Coordinates network + local DB ([paging-offline.md](paging-offline.md)) |
|
||||
| `Pager` | Creates `Flow<PagingData>` from config + source |
|
||||
| `PagingConfig` | Page size, prefetch, placeholders |
|
||||
| `LazyPagingItems<T>` | Compose wrapper for consuming PagingData |
|
||||
|
||||
## PagingSource Implementation
|
||||
|
||||
```kotlin
|
||||
class ItemPagingSource(
|
||||
private val api: ItemApi,
|
||||
private val query: String,
|
||||
) : PagingSource<Int, ItemDto>() {
|
||||
|
||||
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, ItemDto> {
|
||||
val page = params.key ?: 1
|
||||
return try {
|
||||
val response = api.getItems(page = page, limit = params.loadSize, query = query)
|
||||
LoadResult.Page(
|
||||
data = response.items,
|
||||
prevKey = if (page == 1) null else page - 1,
|
||||
nextKey = if (response.items.isEmpty()) null else page + 1,
|
||||
)
|
||||
} catch (e: IOException) { LoadResult.Error(e) }
|
||||
catch (e: HttpException) { LoadResult.Error(e) }
|
||||
}
|
||||
|
||||
override fun getRefreshKey(state: PagingState<Int, ItemDto>): Int? =
|
||||
state.anchorPosition?.let { pos ->
|
||||
state.closestPageToPosition(pos)?.let { it.prevKey?.plus(1) ?: it.nextKey?.minus(1) }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:** factory must return a **new instance** every call. Catch specific exceptions. Return `null` for `prevKey`/`nextKey` to signal end. For cursor-based APIs, use `String` key type with `nextCursor`.
|
||||
|
||||
## Pager and ViewModel Setup
|
||||
|
||||
```kotlin
|
||||
class ItemListViewModel(private val repository: ItemRepository) : ViewModel() {
|
||||
private val _uiState = MutableStateFlow(ItemListState())
|
||||
val uiState: StateFlow<ItemListState> = _uiState.asStateFlow()
|
||||
|
||||
// PagingData as SEPARATE Flow — never put inside UiState
|
||||
val items: Flow<PagingData<ItemUi>> = Pager(
|
||||
config = PagingConfig(pageSize = 20, prefetchDistance = 5, enablePlaceholders = false, initialLoadSize = 40),
|
||||
pagingSourceFactory = { repository.itemPagingSource() },
|
||||
).flow
|
||||
.map { pagingData -> pagingData.map { it.toUi() } }
|
||||
.cachedIn(viewModelScope)
|
||||
}
|
||||
|
||||
data class ItemListState(val selectedFilter: FilterType = FilterType.ALL, val selectedIds: Set<String> = emptySet())
|
||||
```
|
||||
|
||||
| PagingConfig param | Purpose |
|
||||
|---|---|
|
||||
| `pageSize` | Items per page (required) |
|
||||
| `prefetchDistance` | Distance from edge to trigger next load |
|
||||
| `enablePlaceholders` | Show null placeholders for unloaded items |
|
||||
| `initialLoadSize` | Items on first request |
|
||||
|
||||
## PagingSource Invalidation
|
||||
|
||||
Call `PagingSource.invalidate()` after mutations. The factory returns a new instance; Paging reloads from `getRefreshKey`.
|
||||
|
||||
```kotlin
|
||||
class ItemRepository(private val api: ItemApi) {
|
||||
private var currentPagingSource: ItemPagingSource? = null
|
||||
|
||||
fun itemPagingSource(query: String = ""): PagingSource<Int, ItemDto> =
|
||||
ItemPagingSource(api, query).also { currentPagingSource = it }
|
||||
|
||||
fun invalidate() { currentPagingSource?.invalidate() }
|
||||
}
|
||||
```
|
||||
|
||||
## Filter and Search with Dynamic Parameters
|
||||
|
||||
Use `flatMapLatest` to create a new Pager when parameters change. Combine multiple filter flows, then `flatMapLatest`:
|
||||
|
||||
```kotlin
|
||||
class ItemListViewModel(private val repository: ItemRepository) : ViewModel() {
|
||||
private val _query = MutableStateFlow("")
|
||||
private val _statusFilter = MutableStateFlow(StatusFilter.ALL)
|
||||
|
||||
fun onQueryChanged(query: String) { _query.value = query }
|
||||
fun onStatusChanged(status: StatusFilter) { _statusFilter.value = status }
|
||||
|
||||
val items: Flow<PagingData<ItemUi>> = combine(
|
||||
_query.debounce(300).distinctUntilChanged(),
|
||||
_statusFilter.distinctUntilChanged(),
|
||||
) { query, status -> query to status }
|
||||
.flatMapLatest { (query, status) ->
|
||||
Pager(
|
||||
config = PagingConfig(pageSize = 20),
|
||||
pagingSourceFactory = { repository.itemPagingSource(query = query, status = status) },
|
||||
).flow.map { pagingData -> pagingData.map { it.toUi() } }
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:** `distinctUntilChanged()` before `flatMapLatest` avoids redundant Pager creation. `debounce` on text prevents excessive calls. `cachedIn` must come **after** `flatMapLatest`, not inside it. For single-filter, omit `combine` and use the single flow directly.
|
||||
|
||||
## Compose UI with LazyPagingItems
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ItemListScreen(uiState: ItemListState, pagingItems: LazyPagingItems<ItemUi>, onEvent: (ItemListEvent) -> Unit) {
|
||||
LazyColumn {
|
||||
items(
|
||||
count = pagingItems.itemCount,
|
||||
key = pagingItems.itemKey { it.id },
|
||||
contentType = pagingItems.itemContentType { "item" },
|
||||
) { index ->
|
||||
pagingItems[index]?.let { item ->
|
||||
ItemRow(item = item, isSelected = uiState.selectedIds.contains(item.id),
|
||||
onClick = { onEvent(ItemListEvent.ItemClicked(item.id)) })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
| Operation | What it does |
|
||||
|---|---|
|
||||
| `pagingItems[index]` | Access item **and** trigger load |
|
||||
| `pagingItems.peek(index)` | Access **without** triggering load |
|
||||
| `pagingItems.retry()` | Retry last failed load |
|
||||
| `pagingItems.refresh()` | Reload all data (never call from composable body) |
|
||||
| `pagingItems.itemKey { }` | Stable keys |
|
||||
| `pagingItems.itemContentType { }` | Content type for layout reuse |
|
||||
|
||||
Works with **all** lazy layouts (`LazyColumn`, `LazyVerticalGrid`, `HorizontalPager`). Prefer `items` with `itemKey`/`itemContentType` over `itemsIndexed` — indices shift during prepend.
|
||||
|
||||
## LoadState Handling
|
||||
|
||||
| State | refresh | append/prepend |
|
||||
|---|---|---|
|
||||
| `Loading` | Initial load or pull-to-refresh | Loading next/previous page |
|
||||
| `Error(throwable)` | Initial load failed | Page load failed |
|
||||
| `NotLoading(endReached)` | Idle | No more pages / idle |
|
||||
|
||||
**Pattern:** branch on `pagingItems.loadState.refresh` — full-screen loading/error/empty only when `itemCount == 0`; with items, use top `LinearProgressIndicator` for refresh and append-row loading/error + `retry()`.
|
||||
|
||||
**RemoteMediator note:** check `loadState.source.refresh` instead of `loadState.refresh` — the convenience property may report complete before Room finishes writing.
|
||||
|
||||
## PagingData Transformations
|
||||
|
||||
Apply on the outer `Flow` **before** `cachedIn`. Transformations after `cachedIn` are lost on cache hit.
|
||||
|
||||
```kotlin
|
||||
val items: Flow<PagingData<ListItem>> = Pager(config, pagingSourceFactory)
|
||||
.flow
|
||||
.map { pagingData ->
|
||||
pagingData
|
||||
.map { dto -> ListItem.ContentItem(dto.toUi()) }
|
||||
.filter { it.item.status != ItemStatus.DELETED }
|
||||
.insertSeparators { before, after ->
|
||||
when {
|
||||
before == null -> ListItem.DateHeader("Today")
|
||||
after == null -> null
|
||||
before.dateGroup != after.dateGroup -> ListItem.DateHeader(after.dateGroup)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
.cachedIn(viewModelScope)
|
||||
|
||||
sealed interface ListItem {
|
||||
data class ContentItem(val item: ItemUi) : ListItem
|
||||
data class DateHeader(val label: String) : ListItem
|
||||
}
|
||||
```
|
||||
|
||||
When using `insertSeparators`, provide unique keys per type (`"item_${id}"`, `"header_${label}"`) and distinct `contentType` values.
|
||||
|
||||
## Related References
|
||||
|
||||
- **Offline-first paging with Room and RemoteMediator** → [paging-offline.md](paging-offline.md)
|
||||
- **MVI dual-flow pattern, testing, and anti-patterns** → [paging-mvi-testing.md](paging-mvi-testing.md)
|
||||
@@ -1,166 +0,0 @@
|
||||
# Performance & Recomposition
|
||||
|
||||
## Three Phases and Primitive Specializations
|
||||
|
||||
Compose executes Composition, Layout, and Drawing phases per frame. State reads in later phases skip earlier phases — moving reads from Composition to Layout/Drawing eliminates recomposition for those reads. Use `Modifier.offset { }` (lambda) instead of `Modifier.offset()`. Use `mutableIntStateOf()`/`mutableFloatStateOf()` instead of `mutableStateOf<Int>()` to avoid boxing. See [compose-essentials.md](compose-essentials.md) for full explanation and code examples.
|
||||
|
||||
## Performance Mistakes and Fixes
|
||||
|
||||
| # | Issue | Fix |
|
||||
|---|---|---|
|
||||
| 1 | Unstable parameters (`MutableList`, lambdas in state models, anonymous objects) | Immutable data classes + immutable collections |
|
||||
| 2 | Broad state observation — parent reads whole state, ripples through tree | Collect once at route, slice aggressively for leaves |
|
||||
| 3 | Large state passed everywhere — many nodes observe unused fields | Pass only what each child renders |
|
||||
| 4 | Callback recreation in hot paths (large lazy lists, nested rows) | `remember(key, callback)` for repeated rows |
|
||||
| 5 | Expensive calculations during composition (parse, sort, filter, format) | Move upstream to ViewModel/domain |
|
||||
| 6 | `remember` misuse — caching business state, hiding architecture issues | Use only for local UI state, expensive local objects, hot callback adaptation |
|
||||
| 7 | `derivedStateOf` misuse — wrapping cheap expressions | Use only when derived from rapidly changing Compose state with coarse output |
|
||||
| 8 | `rememberSaveable` misuse — entire screen state, large graphs | Use only for tiny UI-local values surviving recreation |
|
||||
| 9 | State reads too high in tree (`LazyListState`, animation, keyboard state) | Read close to use |
|
||||
| 10 | List recomposition — missing keys, unstable items, inline filters/sorts | Stable keys, immutable models, pre-computed data |
|
||||
| 11 | Reducer emits excessive updates — same state, rebuilds on every keystroke | Guard identical transitions, emit only on semantic change |
|
||||
| 12 | Ephemeral visual state in global screen state (shimmer alpha, pulse phase) | Keep visual-only state local |
|
||||
| 13 | Equality pitfalls — lambdas in data classes, random IDs, mutable collections | No lambdas/mutables in data classes, stable IDs |
|
||||
| 14 | Abusing `@Immutable`/`@Stable` to silence compiler | Use only to describe truth — `@Immutable` for truly immutable, `@Stable` rare in app code |
|
||||
| 15 | Raw text input in MVI causing stutter (25+ fields) | `TextFieldState`/`BasicTextField2`, group fields into nested data classes, isolate read scopes |
|
||||
| 16 | State reads in Composition phase for layout/draw values | Lambda modifiers: `Modifier.offset { IntOffset(scrollOffset, 0) }` |
|
||||
|
||||
## API Decision Table
|
||||
|
||||
| API | Use it for | Do not use it for |
|
||||
|---|---|---|
|
||||
| `remember` | local objects/state across recompositions | business state, repo results, derived domain data |
|
||||
| `rememberSaveable` | small UI-local state needing restoration | whole screen state, large graphs, domain objects |
|
||||
| `derivedStateOf` | reducing downstream updates from fast-changing Compose state | cheap string concatenation, reducer-owned derivations |
|
||||
| `key` | preserving identity in dynamic children/lists | hiding bad state models |
|
||||
| `LaunchedEffect` | collecting UI effects, startup event, one-shot route work | screen business logic in leaves |
|
||||
| `DisposableEffect` | register/unregister listeners with cleanup | long-running business jobs |
|
||||
| `produceState` | bridging external async/callback source to local Compose state | replacing a real ViewModel |
|
||||
| `snapshotFlow` | turning Compose state reads into `Flow` operators | normal state rendering |
|
||||
| `collectAsState` | collect `StateFlow` into Compose | collecting everywhere in the tree |
|
||||
| lifecycle-aware collection | Lifecycle host integration (multiplatform since lifecycle 2.8+) | common leaf components |
|
||||
| stable callbacks | hot repeated UI paths | every single callback everywhere |
|
||||
|
||||
## Code Examples
|
||||
|
||||
### BAD: calculating derived results in a composable
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun CalculatorResult(state: CalculatorState) {
|
||||
val area = state.input.areaText.toDoubleOrNull() ?: 0.0
|
||||
val materialRate = state.input.materialRateText.toDoubleOrNull() ?: 0.0
|
||||
val subtotal = (area * materialRate)
|
||||
Text("Subtotal: $subtotal")
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: derive upstream, narrow reads
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun CalculatorScreen(state: CalculatorState, onEvent: (CalculatorEvent) -> Unit) {
|
||||
Header(title = "Estimator")
|
||||
CalculatorForm(
|
||||
input = state.input,
|
||||
validation = state.validation,
|
||||
enabled = !state.isRefreshingQuote,
|
||||
onAreaChanged = { onEvent(CalculatorEvent.FieldChanged(FormField.Area, it)) },
|
||||
)
|
||||
ResultCard(derived = state.derived, isRefreshing = state.isRefreshingQuote)
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CalculatorResult(derived: CalculatorDerived?) {
|
||||
Text(text = derived?.subtotal?.toString() ?: "—")
|
||||
}
|
||||
```
|
||||
|
||||
### BAD: unstable list items
|
||||
|
||||
```kotlin
|
||||
data class HistoryRowState(
|
||||
val id: String, val title: String,
|
||||
val tags: MutableList<String>, // unstable
|
||||
val onClick: () -> Unit, // lambda in data class
|
||||
)
|
||||
```
|
||||
|
||||
### GOOD: immutable models, stable keys, callback stability
|
||||
|
||||
```kotlin
|
||||
@Immutable
|
||||
data class HistoryRowUi(val id: String, val title: String, val subtitle: String)
|
||||
|
||||
@Composable
|
||||
fun HistoryList(items: ImmutableList<HistoryRowUi>, onOpen: (String) -> Unit) {
|
||||
LazyColumn {
|
||||
items(items = items, key = { it.id }) { item ->
|
||||
val onClick = remember(item.id, onOpen) { { onOpen(item.id) } }
|
||||
ListItem(
|
||||
headlineContent = { Text(item.title) },
|
||||
supportingContent = { Text(item.subtitle) },
|
||||
modifier = Modifier.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: correct `derivedStateOf`
|
||||
|
||||
```kotlin
|
||||
val listState = rememberLazyListState()
|
||||
val showScrollToTop by remember { derivedStateOf { listState.firstVisibleItemIndex > 2 } }
|
||||
```
|
||||
|
||||
### BAD: unnecessary `derivedStateOf`
|
||||
|
||||
```kotlin
|
||||
val text by remember { derivedStateOf { if (canSubmit) "Submit" else "Fix errors" } }
|
||||
// Just write: Text(if (canSubmit) "Submit" else "Fix errors")
|
||||
```
|
||||
|
||||
### GOOD: guard identical transitions
|
||||
|
||||
```kotlin
|
||||
private fun onAreaEdited(raw: String) {
|
||||
val old = _state.value
|
||||
if (old.input.areaText == raw) return
|
||||
_state.value = old.copy(input = old.input.copy(areaText = raw))
|
||||
}
|
||||
```
|
||||
|
||||
## Compiler and Build Optimizations
|
||||
|
||||
- **Strong Skipping Mode** — enable via compiler flags; allows composables with unstable parameters to skip based on instance equality (`===`)
|
||||
- **Stability config** — use `stability_config.conf` to mark external classes as stable: `com.example.network.dto.*`, `kotlinx.datetime.Instant`
|
||||
- **Compose Compiler Metrics** — audit `restartable`/`skippable` characteristics regularly
|
||||
|
||||
## Baseline Profiles (Android)
|
||||
|
||||
Pre-compile hot code paths via Jetpack Macrobenchmark to reduce startup time and jank:
|
||||
|
||||
```kotlin
|
||||
@RunWith(AndroidBenchmarkRunner::class)
|
||||
class StartupBenchmark {
|
||||
@get:Rule val benchmarkRule = MacrobenchmarkRule()
|
||||
|
||||
@Test
|
||||
fun startup() = benchmarkRule.measureRepeated(
|
||||
packageName = "com.example.app",
|
||||
metrics = listOf(StartupTimingMetric()),
|
||||
iterations = 10,
|
||||
setupBlock = { pressHome(); startActivityAndWait() }
|
||||
) { /* interact with app */ }
|
||||
}
|
||||
```
|
||||
|
||||
Target <16.67ms per frame for 60fps. Use `FrameTimingMetric()` for scroll/interaction benchmarks.
|
||||
|
||||
### R8/ProGuard Rules for Compose (Android only)
|
||||
|
||||
```proguard
|
||||
-keep @androidx.compose.runtime.Stable class **
|
||||
-keep @androidx.compose.runtime.Immutable class **
|
||||
```
|
||||
66
.codex/skills/compose-skill/references/platform-native-ui.md
Normal file
66
.codex/skills/compose-skill/references/platform-native-ui.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# Platform-native UI
|
||||
|
||||
Use when Android, Windows, and Linux should intentionally render or behave differently.
|
||||
|
||||
## Native-first rule
|
||||
|
||||
Share domain behavior and semantic state. Duplicate presentation when sharing would make a platform feel foreign.
|
||||
|
||||
| Platform | Default visual language | Typical native differences |
|
||||
|---|---|---|
|
||||
| Android | Material + Material icons | bottom navigation, sheets, system pickers, back behavior, touch density |
|
||||
| Windows | Fluent icons + desktop conventions | sidebar/command placement, context menus, keyboard shortcuts, window chrome |
|
||||
| Linux | Lucide + desktop conventions | desktop menus, filesystem flows, window integration |
|
||||
|
||||
Acceptable duplication:
|
||||
|
||||
```text
|
||||
commonMain: SavedDeviceState + named actions + semantic models
|
||||
androidMain: AndroidSavedDeviceScreen
|
||||
jvmMain: WindowsSavedDeviceScreen / LinuxSavedDeviceScreen
|
||||
```
|
||||
|
||||
Unacceptable duplication:
|
||||
|
||||
```text
|
||||
androidMain/jvmMain each reimplement pairing, transfer validation,
|
||||
source cleanup, lifecycle mapping, or CoreGateway orchestration
|
||||
```
|
||||
|
||||
## Choosing the seam
|
||||
|
||||
Use platform-specific implementation when at least one differs materially:
|
||||
|
||||
- Interaction convention or navigation placement.
|
||||
- System picker, menu, dialog, notification, or window integration.
|
||||
- Keyboard/mouse versus touch behavior.
|
||||
- Icon family or system-provided symbol.
|
||||
- Accessibility semantics required by the host platform.
|
||||
- Layout density and information hierarchy.
|
||||
|
||||
Keep a shared composable when only spacing or a token changes and the interaction model remains native on every platform.
|
||||
|
||||
## Icons
|
||||
|
||||
- Add a semantic `AppIcon` case, not a feature-local drawable choice.
|
||||
- Supply Material, Fluent, and Lucide resources.
|
||||
- Render with `PlatformIcon` so `LocalUiPlatform` selects the family.
|
||||
- Use a native system icon through a platform implementation when it communicates better than the bundled family.
|
||||
- Localize content descriptions for actions. Decorative icons use `null`.
|
||||
- Test `resourceFor`/family selection and important semantics.
|
||||
|
||||
## Adaptive layout
|
||||
|
||||
- Use existing `WindowClass`, platform helpers, and app shell before inventing breakpoints.
|
||||
- Phone flow may be full-screen or sheet-based.
|
||||
- Desktop may use persistent navigation, side panels, dialogs, context menus, and denser information.
|
||||
- Do not merely enlarge phone controls on desktop.
|
||||
- Do not compress desktop controls into touch-hostile phone layouts.
|
||||
|
||||
## Review questions
|
||||
|
||||
- Does this screen look and behave expectedly on each platform?
|
||||
- Did sharing code force a non-native interaction?
|
||||
- Is duplicated code presentation-only?
|
||||
- Are domain rules still local to one shared module?
|
||||
- Does every actionable icon have the correct family and semantics?
|
||||
@@ -1,206 +0,0 @@
|
||||
# Compose Multiplatform Resources
|
||||
|
||||
## Android R vs CMP Res
|
||||
|
||||
Android uses `R` — a generated class with integer IDs. Compose Multiplatform uses `Res` — a generated class with typed accessors. The API surface is intentionally similar, but the types and import paths differ.
|
||||
|
||||
| Concern | Android (Jetpack Compose) | Compose Multiplatform |
|
||||
|---|---|---|
|
||||
| Generated class | `R` (integer resource IDs) | `Res` (typed resource objects) |
|
||||
| String access | `stringResource(R.string.app_name)` | `stringResource(Res.string.app_name)` |
|
||||
| Drawable access | `painterResource(R.drawable.icon)` | `painterResource(Res.drawable.icon)` |
|
||||
| Plural access | `pluralStringResource(R.plurals.items, count)` | `pluralStringResource(Res.plurals.items, count)` |
|
||||
| Font access | `FontFamily(Font(R.font.inter))` | `FontFamily(Font(Res.font.inter))` |
|
||||
| String array | `stringArrayResource(R.array.items)` | `stringArrayResource(Res.array.items)` |
|
||||
| Resource directory | `res/` (under each source set) | `composeResources/` (under each source set) |
|
||||
| Import path | `import com.example.app.R` | `import project.module.generated.resources.Res` |
|
||||
| Suspend access | N/A | `getString(Res.string.app_name)` |
|
||||
| Raw file access | `context.assets.open("file.bin")` | `Res.readBytes("files/file.bin")` |
|
||||
| Platform URI | `ContentResolver` / asset URI | `Res.getUri("files/video.mp4")` |
|
||||
|
||||
**Import convention:** `{group}.{module}.generated.resources.Res`. Individual accessors imported separately:
|
||||
|
||||
```kotlin
|
||||
import project.composeapp.generated.resources.Res
|
||||
import project.composeapp.generated.resources.app_name
|
||||
import project.composeapp.generated.resources.my_image
|
||||
```
|
||||
|
||||
## Directory Structure
|
||||
|
||||
Place resources under `composeResources/` in the owning source set. `commonMain` for shared, platform source sets for platform-specific.
|
||||
|
||||
```text
|
||||
commonMain/composeResources/
|
||||
├── drawable/ PNG, JPG, BMP, WebP, Android XML vectors, SVG (all except Android)
|
||||
│ ├── drawable-dark/ dark theme variants
|
||||
│ └── drawable-xxhdpi/ density-specific variants
|
||||
├── font/ TTF, OTF
|
||||
├── values/ strings.xml (strings, string-arrays, plurals) — base locale
|
||||
│ ├── values-es/ Spanish
|
||||
│ ├── values-fr/ French
|
||||
│ └── values-ja/ Japanese
|
||||
└── files/ raw files, any sub-hierarchy
|
||||
└── myDir/data.json
|
||||
```
|
||||
|
||||
Qualifiers use hyphens and can combine: `drawable-en-rUS-mdpi-dark`. Fallback: unqualified resource.
|
||||
|
||||
## Gradle Setup
|
||||
|
||||
```kotlin
|
||||
kotlin {
|
||||
sourceSets {
|
||||
commonMain.dependencies {
|
||||
implementation(compose.components.resources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
compose.resources {
|
||||
publicResClass = true // default: internal; needed for library modules
|
||||
packageOfResClass = "com.example.app.resources" // default: {group}.{module}.generated.resources
|
||||
generateResClass = auto // auto | always
|
||||
}
|
||||
```
|
||||
|
||||
For `androidLibrary` targets (AGP 8.8.0+), enable explicitly: `kotlin { androidLibrary { androidResources.enable = true } }`.
|
||||
|
||||
Build the project to generate/regenerate the `Res` class and typed accessors.
|
||||
|
||||
## Drawables and Images
|
||||
|
||||
Store in `composeResources/drawable/`. Use `painterResource` as the primary API — returns `Painter` for both raster and vector. Works synchronously except web (empty on first composition, then loads).
|
||||
|
||||
```kotlin
|
||||
Image(painter = painterResource(Res.drawable.my_image), contentDescription = null)
|
||||
val bitmap: ImageBitmap = imageResource(Res.drawable.photo) // raster only
|
||||
val vector: ImageVector = vectorResource(Res.drawable.ic_arrow) // XML vector only
|
||||
```
|
||||
|
||||
## Icons
|
||||
|
||||
Use Material Symbols XML icons from [Google Fonts Icons](https://fonts.google.com/icons). Download the Android XML variant, place in `composeResources/drawable/`, set `android:fillColor` to `#000000`, remove `android:tint`.
|
||||
|
||||
```kotlin
|
||||
Image(
|
||||
painter = painterResource(Res.drawable.ic_settings),
|
||||
contentDescription = "Settings",
|
||||
modifier = Modifier.size(24.dp),
|
||||
colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface),
|
||||
)
|
||||
```
|
||||
|
||||
## Strings, Templates, Arrays, and Plurals
|
||||
|
||||
Store in `composeResources/values/strings.xml`. Each element generates a typed accessor on `Res`.
|
||||
|
||||
| Type | XML | Composable API | Suspend API |
|
||||
|---|---|---|---|
|
||||
| String | `<string name="k">text</string>` | `stringResource(Res.string.k)` | `getString(Res.string.k)` |
|
||||
| Template | `<string name="k">Hello, %1$s!</string>` | `stringResource(Res.string.k, name)` | `getString(Res.string.k, name)` |
|
||||
| String array | `<string-array name="k"><item>A</item></string-array>` | `stringArrayResource(Res.array.k)` | `getStringArray(Res.array.k)` |
|
||||
| Plurals | `<plurals name="k"><item quantity="one">%1$d item</item><item quantity="other">%1$d items</item></plurals>` | `pluralStringResource(Res.plurals.k, count, count)` | `getPluralString(Res.plurals.k, count, count)` |
|
||||
|
||||
Canonical example:
|
||||
|
||||
```xml
|
||||
<resources>
|
||||
<string name="app_name">My App</string>
|
||||
<string name="welcome">Hello, %1$s! You have %2$d new messages.</string>
|
||||
<string-array name="categories">
|
||||
<item>Electronics</item>
|
||||
<item>Clothing</item>
|
||||
</string-array>
|
||||
<plurals name="items_count">
|
||||
<item quantity="one">%1$d item</item>
|
||||
<item quantity="other">%1$d items</item>
|
||||
</plurals>
|
||||
</resources>
|
||||
```
|
||||
|
||||
Special characters: `\n`, `\t`, `\uXXXX`. Unlike Android, no need to escape `@` or `?`. For plurals, the first `count` selects the form; additional args are format arguments. No functional difference between `$s` and `$d`. Supported quantities: `zero`, `one`, `two`, `few`, `many`, `other` — not all apply to every language.
|
||||
|
||||
## Fonts
|
||||
|
||||
Store `.ttf`/`.otf` in `composeResources/font/`. `Font()` is a **composable** in CMP (unlike Android), so dependent `TextStyle`/`Typography` construction must also be composable:
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun AppTypography(): Typography {
|
||||
val fontFamily = FontFamily(
|
||||
Font(Res.font.Inter_Regular, FontWeight.Normal),
|
||||
Font(Res.font.Inter_Bold, FontWeight.Bold),
|
||||
)
|
||||
return MaterialTheme.typography.copy(
|
||||
bodyLarge = MaterialTheme.typography.bodyLarge.copy(fontFamily = fontFamily),
|
||||
titleLarge = MaterialTheme.typography.titleLarge.copy(fontFamily = fontFamily, fontWeight = FontWeight.Bold),
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
## Raw Files and URIs
|
||||
|
||||
Place arbitrary files in `composeResources/files/` with any sub-hierarchy.
|
||||
|
||||
```kotlin
|
||||
// Read bytes (suspend)
|
||||
val bytes = Res.readBytes("files/data.json")
|
||||
|
||||
// Convert to images
|
||||
val bitmap: ImageBitmap = bytes.decodeToImageBitmap()
|
||||
val vector: ImageVector = bytes.decodeToImageVector(LocalDensity.current)
|
||||
val painter: Painter = bytes.decodeToSvgPainter(LocalDensity.current) // all platforms except Android
|
||||
|
||||
// Get platform URI for external APIs (WebView, media players)
|
||||
val uri: String = Res.getUri("files/intro.mp4")
|
||||
```
|
||||
|
||||
Since CMP 1.7.0, multiplatform resources are packed into Android assets — enabling `@Preview` and `WebView`/media access via URI.
|
||||
|
||||
## Qualifiers Reference
|
||||
|
||||
| Qualifier | Format | Example |
|
||||
|---|---|---|
|
||||
| Language / region | ISO 639-1/2; optional `r` + ISO 3166-1-alpha-2 | `values-es/`, `values-fra/`, `values-es-rMX/` |
|
||||
| Theme | `light` or `dark` | `drawable-dark/` |
|
||||
| Density | `ldpi`/`mdpi`/`hdpi`/`xhdpi`/`xxhdpi`/`xxxhdpi` | `drawable-xxhdpi/` |
|
||||
|
||||
`stringResource()` automatically selects the correct locale at runtime — no code changes needed.
|
||||
|
||||
## Remote Images
|
||||
|
||||
For loading images from URLs, use a dedicated library — multiplatform resources are for bundled assets only. See [image-loading.md](image-loading.md).
|
||||
|
||||
## MVI Integration
|
||||
|
||||
**Rule: semantic keys in state, resource resolution in UI.** ViewModels use enums/semantic values — never resolved strings or resource IDs. UI maps semantic keys to `stringResource()`/`painterResource()` at render time.
|
||||
|
||||
```kotlin
|
||||
enum class ErrorKey { NetworkError, InvalidInput, Unauthorized }
|
||||
data class ProfileState(val userName: String = "", val error: ErrorKey? = null)
|
||||
|
||||
state.error?.let { key ->
|
||||
Text(stringResource(when (key) {
|
||||
ErrorKey.NetworkError -> Res.string.error_network
|
||||
ErrorKey.InvalidInput -> Res.string.error_invalid_input
|
||||
ErrorKey.Unauthorized -> Res.string.error_unauthorized
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
For full MVI ViewModel collection pattern, see [architecture.md](architecture.md).
|
||||
|
||||
## Rules
|
||||
|
||||
- Use `composeResources/` for all shared strings, images, fonts, and raw files
|
||||
- Use typed accessors (`Res.string.name`) for compile-time safety
|
||||
- Use qualifiers for localization (`values-es/`), theme (`drawable-dark/`), density (`drawable-xxhdpi/`)
|
||||
- Keep resource resolution in composables — call `stringResource()`/`painterResource()` at render time
|
||||
- Use suspend variants (`getString()`, `getPluralString()`) for non-composable contexts
|
||||
- Set `publicResClass = true` when sharing resources from a library module
|
||||
- Use semantic keys/enums in state; map to resources in UI
|
||||
- Never resolve strings or load resources inside reducers or ViewModels
|
||||
- Never use Android `R.string`/`R.drawable` in `commonMain` — use `Res`
|
||||
- Never place platform-only assets (Android adaptive icons, iOS asset catalogs) in `composeResources/`
|
||||
- Rebuild after adding new resources — the `Res` class needs regeneration
|
||||
@@ -1,256 +0,0 @@
|
||||
# Room Database
|
||||
|
||||
SQLite persistence via Room (KMP-ready since 2.7.0) for Compose Multiplatform and Android projects.
|
||||
|
||||
References:
|
||||
- [Save data in a local database using Room](https://developer.android.com/training/data-storage/room)
|
||||
- [Set up Room Database for KMP](https://developer.android.com/kotlin/multiplatform/room)
|
||||
- [SQLite performance best practices](https://developer.android.com/topic/performance/sqlite-performance-best-practices)
|
||||
|
||||
## Setup
|
||||
|
||||
> **Always search online for the latest stable versions** of `androidx.room`, `androidx.sqlite`, and `com.google.devtools.ksp` before adding dependencies.
|
||||
|
||||
### Dependencies (version catalog)
|
||||
|
||||
```toml
|
||||
[versions]
|
||||
room = "<latest>" # search: "androidx.room latest version"
|
||||
sqlite = "<latest>" # search: "androidx.sqlite latest version"
|
||||
ksp = "<latest>" # must match your Kotlin version
|
||||
|
||||
[libraries]
|
||||
androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
|
||||
androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" }
|
||||
androidx-sqlite-bundled = { module = "androidx.sqlite:sqlite-bundled", version.ref = "sqlite" }
|
||||
|
||||
[plugins]
|
||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
||||
androidx-room = { id = "androidx.room", version.ref = "room" }
|
||||
```
|
||||
|
||||
### KMP Gradle
|
||||
|
||||
```kotlin
|
||||
plugins {
|
||||
alias(libs.plugins.ksp)
|
||||
alias(libs.plugins.androidx.room)
|
||||
}
|
||||
kotlin {
|
||||
sourceSets.commonMain.dependencies {
|
||||
implementation(libs.androidx.room.runtime)
|
||||
implementation(libs.androidx.sqlite.bundled)
|
||||
}
|
||||
}
|
||||
dependencies {
|
||||
add("kspAndroid", libs.androidx.room.compiler)
|
||||
add("kspIosArm64", libs.androidx.room.compiler)
|
||||
// ... add for every target
|
||||
}
|
||||
room { schemaDirectory("$projectDir/schemas") }
|
||||
```
|
||||
|
||||
**Android-only:** use `ksp(libs.androidx.room.compiler)` directly.
|
||||
|
||||
### Database definition
|
||||
|
||||
```kotlin
|
||||
@Database(entities = [ProjectEntity::class, TaskEntity::class], version = 1)
|
||||
@ConstructedBy(AppDatabaseConstructor::class)
|
||||
abstract class AppDatabase : RoomDatabase() {
|
||||
abstract fun projectDao(): ProjectDao
|
||||
abstract fun taskDao(): TaskDao
|
||||
}
|
||||
|
||||
@Suppress("KotlinNoActualForExpect")
|
||||
expect object AppDatabaseConstructor : RoomDatabaseConstructor<AppDatabase> {
|
||||
override fun initialize(): AppDatabase
|
||||
}
|
||||
```
|
||||
|
||||
Room generates `actual` implementations per platform. **Android-only:** skip `@ConstructedBy`, use `Room.databaseBuilder(context, AppDatabase::class.java, "app.db")`.
|
||||
|
||||
### Database instantiation
|
||||
|
||||
```kotlin
|
||||
fun getRoomDatabase(builder: RoomDatabase.Builder<AppDatabase>): AppDatabase =
|
||||
builder.setDriver(BundledSQLiteDriver()).setQueryCoroutineContext(Dispatchers.IO).build()
|
||||
```
|
||||
|
||||
Each platform provides its own `getDatabaseBuilder`. See [KMP setup guide](https://developer.android.com/kotlin/multiplatform/room).
|
||||
|
||||
## Critical Performance Rules
|
||||
|
||||
| Rule | Why |
|
||||
|------|-----|
|
||||
| Index every column in `WHERE`, `ORDER BY`, `JOIN ON` | Avoids full table scan: O(n) → O(log n) |
|
||||
| Batch writes inside `@Transaction` | Individual inserts each trigger separate disk sync |
|
||||
| Select only needed columns (projection data classes) | Reduces memory and I/O vs `SELECT *` |
|
||||
| `Flow` for reactive reads, `suspend` for writes | Auto-notify on changes; keep main thread free |
|
||||
| Never `allowMainThreadQueries()` in production | Blocks UI, causes ANRs |
|
||||
| Use `BundledSQLiteDriver` for KMP | Consistent SQLite version across platforms |
|
||||
| Provide `RoomDatabase` as DI singleton | Each instance manages its own connection pool |
|
||||
|
||||
## Entity Design
|
||||
|
||||
```kotlin
|
||||
@Entity(
|
||||
tableName = "tasks",
|
||||
indices = [Index("projectId"), Index("projectId", "dueDate")]
|
||||
)
|
||||
data class TaskEntity(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val title: String,
|
||||
val description: String,
|
||||
val projectId: Long,
|
||||
@ColumnInfo(name = "due_date") val dueDate: Long? = null,
|
||||
@ColumnInfo(defaultValue = "0") val isCompleted: Boolean = false,
|
||||
@Ignore val displayOrder: Int = 0
|
||||
)
|
||||
```
|
||||
|
||||
Composite key: `@Entity(primaryKeys = ["taskId", "labelId"])`. Full-text search: `@Fts4(contentEntity = ...)` with `MATCH` queries.
|
||||
|
||||
### Indexes
|
||||
|
||||
| Scenario | Index? | Reason |
|
||||
|----------|--------|--------|
|
||||
| Column in `WHERE`/`ORDER BY`/`JOIN ON` | Yes | Avoids full scan / sort pass |
|
||||
| Foreign key column | Yes | Room warns if missing |
|
||||
| Rarely queried column / tiny table | No | Wastes storage, slows writes |
|
||||
|
||||
Composite index `(a, b)` accelerates queries on `a` alone or both. Column order matters — most selective first.
|
||||
|
||||
## DAO Patterns
|
||||
|
||||
```kotlin
|
||||
@Dao
|
||||
interface TaskDao {
|
||||
@Insert(onConflict = OnConflictStrategy.ABORT) suspend fun insert(task: TaskEntity): Long
|
||||
@Insert suspend fun insertAll(tasks: List<TaskEntity>): List<Long>
|
||||
@Update suspend fun update(task: TaskEntity)
|
||||
@Upsert suspend fun upsert(task: TaskEntity)
|
||||
@Delete suspend fun delete(task: TaskEntity)
|
||||
@Query("DELETE FROM tasks WHERE projectId = :projectId") suspend fun deleteByProject(projectId: Long)
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE projectId = :projectId ORDER BY due_date ASC")
|
||||
fun observeByProject(projectId: Long): Flow<List<TaskEntity>>
|
||||
|
||||
@Query("SELECT * FROM tasks WHERE id = :id") suspend fun getById(id: Long): TaskEntity?
|
||||
}
|
||||
```
|
||||
|
||||
`@Upsert` (Room 2.5+) inserts or updates by primary key. Prefer over `@Insert(onConflict = REPLACE)` which deletes then re-inserts, triggering cascading deletes. Room auto-invalidates `Flow` queries on table changes.
|
||||
|
||||
**KMP:** all DAO functions for non-Android must be `suspend` or return `Flow`.
|
||||
|
||||
### Performance-oriented queries
|
||||
|
||||
```kotlin
|
||||
data class TaskSummary(val id: Long, val title: String, @ColumnInfo(name = "due_date") val dueDate: Long?)
|
||||
|
||||
@Query("SELECT id, title, due_date FROM tasks WHERE projectId = :projectId")
|
||||
fun observeSummaries(projectId: Long): Flow<List<TaskSummary>>
|
||||
|
||||
@Query("SELECT projectId, COUNT(*) AS taskCount, SUM(CASE WHEN isCompleted = 1 THEN 1 ELSE 0 END) AS completedCount FROM tasks GROUP BY projectId")
|
||||
suspend fun getProjectStats(): List<ProjectStats>
|
||||
|
||||
@Transaction
|
||||
suspend fun replaceAllForProject(projectId: Long, tasks: List<TaskEntity>) {
|
||||
deleteByProject(projectId); insertAll(tasks)
|
||||
}
|
||||
```
|
||||
|
||||
Always use `:paramName` bind parameters — never concatenate. Use `LIMIT` for bounded results. For unbounded scrolling, use [Paging](paging.md). For offline-first paging with Room, see [paging-offline.md](paging-offline.md).
|
||||
|
||||
## Relationships
|
||||
|
||||
### One-to-many
|
||||
|
||||
```kotlin
|
||||
data class ProjectWithTasks(
|
||||
@Embedded val project: ProjectEntity,
|
||||
@Relation(parentColumn = "id", entityColumn = "projectId") val tasks: List<TaskEntity>
|
||||
)
|
||||
|
||||
@Transaction @Query("SELECT * FROM projects WHERE id = :id")
|
||||
suspend fun getWithTasks(id: Long): ProjectWithTasks?
|
||||
```
|
||||
|
||||
Always `@Transaction` on relational queries — Room issues multiple queries internally.
|
||||
|
||||
### Many-to-many with Junction
|
||||
|
||||
```kotlin
|
||||
@Entity(
|
||||
tableName = "task_labels", primaryKeys = ["taskId", "labelId"],
|
||||
foreignKeys = [
|
||||
ForeignKey(entity = TaskEntity::class, parentColumns = ["id"], childColumns = ["taskId"], onDelete = ForeignKey.CASCADE),
|
||||
ForeignKey(entity = LabelEntity::class, parentColumns = ["id"], childColumns = ["labelId"], onDelete = ForeignKey.CASCADE)
|
||||
],
|
||||
indices = [Index("labelId")]
|
||||
)
|
||||
data class TaskLabelCrossRef(val taskId: Long, val labelId: Long)
|
||||
|
||||
data class TaskWithLabels(
|
||||
@Embedded val task: TaskEntity,
|
||||
@Relation(parentColumn = "id", entityColumn = "id",
|
||||
associateBy = Junction(TaskLabelCrossRef::class, parentColumn = "taskId", entityColumn = "labelId"))
|
||||
val labels: List<LabelEntity>
|
||||
)
|
||||
```
|
||||
|
||||
## TypeConverters
|
||||
|
||||
```kotlin
|
||||
class Converters {
|
||||
@TypeConverter fun fromInstant(value: Long?): Instant? = value?.let { Instant.fromEpochMilliseconds(it) }
|
||||
@TypeConverter fun toInstant(instant: Instant?): Long? = instant?.toEpochMilliseconds()
|
||||
}
|
||||
```
|
||||
|
||||
**KMP:** use `kotlinx-datetime`. Reserve TypeConverters for simple mappings (timestamps, enums) — prefer normalized tables over JSON blobs.
|
||||
|
||||
## Transactions
|
||||
|
||||
- **KMP:** `database.useWriterConnection { it.immediateTransaction { } }` for writes, `database.useReaderConnection { it.deferredTransaction { } }` for consistent reads
|
||||
- **Android-only:** `database.withTransaction { }` (not available in KMP `commonMain`)
|
||||
- **DAO-level:** `@Transaction` to group multiple queries atomically
|
||||
|
||||
## Migrations
|
||||
|
||||
```kotlin
|
||||
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||
override fun migrate(connection: SQLiteConnection) {
|
||||
connection.execSQL("ALTER TABLE tasks ADD COLUMN priority INTEGER NOT NULL DEFAULT 0")
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`.addMigrations(MIGRATION_1_2)`. **AutoMigration:** `autoMigrations = [AutoMigration(from = 1, to = 2)]` for simple changes. Export schema to VCS; `fallbackToDestructiveMigration()` only in early dev.
|
||||
|
||||
## MVI Integration
|
||||
|
||||
Map entities to domain models at the repository boundary (`TaskEntity.toDomain()` / `Task.toEntity()`). Never pass `@Entity` classes to the UI. Provide `RoomDatabase` and DAOs as DI singletons.
|
||||
|
||||
For the ViewModel collection pattern, see [architecture.md](architecture.md) — Reactive Data Collection.
|
||||
|
||||
## Testing
|
||||
|
||||
- **DAO tests:** `Room.inMemoryDatabaseBuilder<AppDatabase>()` with `BundledSQLiteDriver` + test dispatcher. Test `Flow` with Turbine.
|
||||
- **Migration tests:** `MigrationTestHelper` — create at old version, run `runMigrationsAndValidate`, verify.
|
||||
- **ViewModel tests:** Fake DAO backed by `MutableStateFlow<List<Entity>>`. See [testing.md](testing.md).
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it is harmful | Better replacement |
|
||||
|---|---|---|
|
||||
| `allowMainThreadQueries()` | Blocks UI, ANRs | `suspend` + `Flow` |
|
||||
| `SELECT *` everywhere | Loads unused columns | Projection data classes |
|
||||
| Missing indexes on queried columns | Full table scan | `@Entity(indices = [...])` |
|
||||
| Destructive fallback only | Users lose data | `Migration` or `AutoMigration` |
|
||||
| `@Insert(onConflict = REPLACE)` with FKs | Cascading deletes | `@Upsert` |
|
||||
| Blocking DAO functions on KMP | Crashes non-Android | `suspend` or `Flow` |
|
||||
| No `@Transaction` on relational queries | Inconsistent snapshot | Always `@Transaction` with `@Relation` |
|
||||
| Multiple `RoomDatabase` instances | Breaks invalidation | DI singleton |
|
||||
| Large blobs / nested JSON via TypeConverter | Bloats DB, opaque to SQL | File paths + normalized tables |
|
||||
@@ -1,222 +0,0 @@
|
||||
# Testing Strategy
|
||||
|
||||
## What to Test in commonMain
|
||||
|
||||
### ViewModel Tests with Turbine (highest ROI)
|
||||
|
||||
Test the full event→state→effect cycle through the ViewModel. Use `kotlinx-coroutines-test` with the **Turbine** library:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `save with empty title shows validation error`() = runTest {
|
||||
val viewModel = CreateItemViewModel(FakeItemRepository())
|
||||
|
||||
viewModel.state.test {
|
||||
val initial = awaitItem()
|
||||
assertTrue(initial.errors.isEmpty())
|
||||
|
||||
viewModel.onEvent(CreateItemEvent.OnSaveClick)
|
||||
val afterSave = awaitItem()
|
||||
|
||||
assertEquals("Title is required", afterSave.errors["title"])
|
||||
assertFalse(afterSave.isSaving)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save with valid input transitions through saving to success`() = runTest {
|
||||
val viewModel = CreateItemViewModel(FakeItemRepository())
|
||||
|
||||
viewModel.state.test {
|
||||
awaitItem() // initial
|
||||
|
||||
viewModel.onEvent(CreateItemEvent.OnTitleChanged("New item"))
|
||||
awaitItem()
|
||||
viewModel.onEvent(CreateItemEvent.OnAmountChanged("42.5"))
|
||||
awaitItem()
|
||||
|
||||
viewModel.onEvent(CreateItemEvent.OnSaveClick)
|
||||
val saving = awaitItem()
|
||||
assertTrue(saving.isSaving)
|
||||
|
||||
val done = awaitItem()
|
||||
assertFalse(done.isSaving)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `title changed clears title validation error`() = runTest {
|
||||
val viewModel = CreateItemViewModel(FakeItemRepository())
|
||||
|
||||
viewModel.state.test {
|
||||
awaitItem() // initial
|
||||
|
||||
viewModel.onEvent(CreateItemEvent.OnSaveClick)
|
||||
val withError = awaitItem()
|
||||
assertTrue(withError.errors.containsKey("title"))
|
||||
|
||||
viewModel.onEvent(CreateItemEvent.OnTitleChanged("A"))
|
||||
val cleared = awaitItem()
|
||||
assertFalse(cleared.errors.containsKey("title"))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save emits ShowMessage effect on success`() = runTest {
|
||||
val viewModel = CreateItemViewModel(FakeItemRepository())
|
||||
|
||||
viewModel.onEvent(CreateItemEvent.OnTitleChanged("New item"))
|
||||
viewModel.onEvent(CreateItemEvent.OnAmountChanged("10"))
|
||||
|
||||
viewModel.effect.test {
|
||||
viewModel.onEvent(CreateItemEvent.OnSaveClick)
|
||||
val effect = awaitItem()
|
||||
assertTrue(effect is CreateItemEffect.ShowMessage)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**What to test:**
|
||||
|
||||
- Event→state transitions: field edits, validation triggers, loading states
|
||||
- Event→effect emissions: navigation, snackbar, error messages
|
||||
- Async flows: loading → success, loading → failure, retry
|
||||
- Edge cases: empty input, duplicate detection, concurrent saves
|
||||
- State preservation: old content kept during refresh, error doesn't wipe data
|
||||
|
||||
### Testing State and Effects Separately
|
||||
|
||||
When a single event produces both state changes and effects, test them independently for clarity:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `back click emits NavigateBack effect without changing state`() = runTest {
|
||||
val viewModel = CreateItemViewModel(FakeItemRepository())
|
||||
|
||||
viewModel.effect.test {
|
||||
viewModel.onEvent(CreateItemEvent.OnBackClick)
|
||||
assertEquals(CreateItemEffect.NavigateBack, awaitItem())
|
||||
}
|
||||
|
||||
viewModel.state.test {
|
||||
val state = awaitItem()
|
||||
assertEquals(CreateItemState(), state)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Validation Tests
|
||||
|
||||
Test validation logic as pure functions when extracted into a dedicated validator:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `validator rejects blank title`() {
|
||||
val errors = CreateItemValidator.validate(title = "", amount = "10")
|
||||
assertEquals("Title is required", errors["title"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `validator accepts valid input`() {
|
||||
val errors = CreateItemValidator.validate(title = "Widget", amount = "25.0")
|
||||
assertTrue(errors.isEmpty())
|
||||
}
|
||||
```
|
||||
|
||||
If validation is inline in the ViewModel (acceptable for simple cases), test it through ViewModel events as shown above.
|
||||
|
||||
### Calculation Engine Tests
|
||||
|
||||
Test pure calculation/domain services directly — no ViewModel needed:
|
||||
|
||||
```kotlin
|
||||
@Test
|
||||
fun `calculator computes correct monthly payment`() {
|
||||
val result = LoanCalculator.monthlyPayment(amount = 100000.0, rate = 5.0, years = 30)
|
||||
assertEquals(536.82, result, 0.01)
|
||||
}
|
||||
```
|
||||
|
||||
Test: edge cases, rounding policy, domain invariants, regression fixtures.
|
||||
|
||||
### Fake Repositories for ViewModel Tests
|
||||
|
||||
Use fakes (not mocks) for repositories and services:
|
||||
|
||||
```kotlin
|
||||
class FakeItemRepository : ItemRepository {
|
||||
private val items = mutableListOf<Item>()
|
||||
var shouldThrow: Exception? = null
|
||||
|
||||
override suspend fun create(title: String, amount: Double) {
|
||||
shouldThrow?.let { throw it }
|
||||
items.add(Item(title = title, amount = amount))
|
||||
}
|
||||
|
||||
override suspend fun getAll(): List<Item> = items.toList()
|
||||
}
|
||||
```
|
||||
|
||||
Fakes give you control over success/failure scenarios without mock framework complexity.
|
||||
|
||||
## Compose UI Tests
|
||||
|
||||
Compose Multiplatform common UI testing uses `runComposeUiTest` rather than Android's JUnit `TestRule` model.
|
||||
|
||||
Test:
|
||||
|
||||
- Critical field entry flows
|
||||
- Submit enable/disable behavior
|
||||
- Error visibility
|
||||
- Loading placeholder/content swap
|
||||
- Preserved content during refresh
|
||||
- Accessibility labels on critical controls
|
||||
|
||||
## Platform Tests
|
||||
|
||||
### Android/iOS specific
|
||||
|
||||
Test:
|
||||
|
||||
- Platform shell wiring
|
||||
- Deep-link entry
|
||||
- Navigation host integration
|
||||
- Share sheet / clipboard / haptic bindings
|
||||
- Platform lifecycle edge cases
|
||||
- Keyboard/safe-area regressions
|
||||
|
||||
## Snapshot Testing Caveats
|
||||
|
||||
Per-platform rendering, typography, and layout differ; shared Android/iOS goldens are brittle.
|
||||
|
||||
**Default:** prefer semantic assertions and interaction tests; use per-platform visual goldens only for a few high-value screens.
|
||||
|
||||
## Lean Default Test Matrix
|
||||
|
||||
1. ViewModel event→state→effect tests for every feature (via Turbine)
|
||||
2. Validation/calculation tests for every rule-heavy feature (pure function tests)
|
||||
3. UI tests for high-risk screens
|
||||
4. Platform integration tests only for real platform behavior
|
||||
|
||||
Do not sink weeks into screenshot infrastructure before you have ViewModel test coverage.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
| Anti-pattern | Why it hurts | Better replacement |
|
||||
|---|---|---|
|
||||
| No ViewModel tests, only UI tests | slow feedback, flaky, hard to isolate failures | ViewModel event→state→effect tests with Turbine first |
|
||||
| Testing implementation details (private functions, internal state) | brittle tests that break on refactoring | test through public API: send event, assert state/effect |
|
||||
| Mocking the DI framework | couples tests to DI internals | swap real implementations with fakes via constructor injection |
|
||||
| Screenshot tests before ViewModel coverage | high maintenance, low defect yield | establish ViewModel + validator coverage first, then add screenshots selectively |
|
||||
| Testing derived/computed properties in isolation from ViewModel | duplicates logic, drifts from real behavior | test derived values through ViewModel state assertions |
|
||||
| Sharing mutable test fixtures across tests | hidden coupling, order-dependent failures | fresh state per test, explicit setup in each test function |
|
||||
|
||||
## Domain-Specific Testing
|
||||
|
||||
Some reference files contain their own testing sections with domain-specific patterns:
|
||||
|
||||
| Domain | Reference | What it covers |
|
||||
|---|---|---|
|
||||
| Paging 3 | [paging-mvi-testing.md](paging-mvi-testing.md) | PagingSource unit tests, `asSnapshot`, `TestPager` transformations |
|
||||
| Room Database | [room-database.md](room-database.md) | In-memory DB tests, migration tests, fake DAOs |
|
||||
| Networking | [networking-ktor-testing.md](networking-ktor-testing.md) | MockEngine, API response testing, DI integration |
|
||||
@@ -1,170 +0,0 @@
|
||||
# UI/UX Patterns for Utility Apps
|
||||
|
||||
## Core Principles
|
||||
|
||||
Utility apps are trust products. The UI must feel: stable, immediate, precise, reversible, non-destructive.
|
||||
|
||||
## Loading States
|
||||
|
||||
### Decision Rule
|
||||
|
||||
| Situation | Best default |
|
||||
|---|---|
|
||||
| First load, known result card layout | skeleton |
|
||||
| Small inline refresh of one section | keep content + small inline indicator |
|
||||
| Whole-screen blocking startup with no known structure | spinner, but rare |
|
||||
| Recalculating quote while old result exists | keep old result + "updating" affordance |
|
||||
| Empty but idle state | empty-state hint, not spinner |
|
||||
|
||||
### Default Recommendation
|
||||
|
||||
- **Skeleton**: default for known layout with missing data
|
||||
- **Subtle shimmer over skeleton**: optional polish, not the primary strategy
|
||||
- **Spinner**: only for small unknown-layout operations or blocking tasks with no stable placeholder shape
|
||||
|
||||
### Stable Layout During Loading
|
||||
|
||||
Never wipe content during refresh. Never cause height jumps, flicker, or lost context.
|
||||
|
||||
## Inline Validation
|
||||
|
||||
Default behavior:
|
||||
|
||||
- Validate format/range as user edits for fields where feedback is obvious
|
||||
- Avoid screaming errors on untouched fields
|
||||
- Show errors inline, next to the field they belong to
|
||||
- Do not collapse layout when error appears/disappears
|
||||
- Disable submit when impossible, but also explain why
|
||||
|
||||
### Good inline validation behavior
|
||||
|
||||
- Field keeps its value during error
|
||||
- Error appears under field
|
||||
- Submit remains disabled only when necessary
|
||||
- No modal dialog for every invalid keystroke
|
||||
- No full-form red error wall
|
||||
|
||||
## Disabled States
|
||||
|
||||
Disabled is fine only when:
|
||||
|
||||
- The reason is obvious from nearby context
|
||||
- The screen is still readable
|
||||
- User input is preserved
|
||||
|
||||
Bad disabled state: button disabled with no visible reason, form cleared during loading, entire screen grayed out for a small refresh.
|
||||
|
||||
## Preserving User Input
|
||||
|
||||
Non-negotiable rules:
|
||||
|
||||
- **Never clear edited fields on refresh**
|
||||
- **Never clear last good result while fetching a new one**
|
||||
- **Never wipe the screen because one request failed**
|
||||
|
||||
## Progressive Disclosure
|
||||
|
||||
For dense forms:
|
||||
|
||||
- Hide advanced options by default
|
||||
- Keep main path obvious
|
||||
- Reveal secondary controls progressively
|
||||
- Do not split trivial forms into too many steps
|
||||
|
||||
## Partial Results
|
||||
|
||||
Good pattern:
|
||||
|
||||
- Compute instant local estimate from current draft
|
||||
- Show local estimate immediately
|
||||
- Fetch remote refinement in background
|
||||
- Keep old refined quote until new one arrives
|
||||
- Label refreshed state clearly
|
||||
|
||||
## Perceived Performance
|
||||
|
||||
For form-heavy screens:
|
||||
|
||||
- Apply local field state changes instantly
|
||||
- Recalculate cheap deterministic outputs immediately
|
||||
- Debounce only expensive async work
|
||||
- Keep layout stable
|
||||
- Animate only meaningful content changes
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Error messages must be text, not color only
|
||||
- Loading indicators should not hide context unnecessarily
|
||||
- Support logical keyboard/focus order
|
||||
- Avoid rapid flashing/sweeping shimmer
|
||||
- Keep controls large enough for data-entry reliability
|
||||
|
||||
## Code Examples
|
||||
|
||||
### BAD: disappearing content and layout jumps
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
|
||||
if (isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else if (quote != null) {
|
||||
QuoteContent(quote = quote, refreshing = false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: stable layout with old content preserved
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun QuoteSection(quote: QuoteUi?, isLoading: Boolean) {
|
||||
ResultCardSlot {
|
||||
when {
|
||||
quote != null -> QuoteContent(quote = quote, refreshing = isLoading)
|
||||
isLoading -> QuoteCardSkeleton()
|
||||
else -> QuoteEmptyState()
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: stable placeholder slot
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun ResultCardSlot(content: @Composable BoxScope.() -> Unit) {
|
||||
Box(modifier = Modifier.fillMaxWidth().heightIn(min = 180.dp)) { content() }
|
||||
}
|
||||
```
|
||||
|
||||
### GOOD: skeleton with shimmer
|
||||
|
||||
```kotlin
|
||||
@Composable
|
||||
fun QuoteCardSkeleton(modifier: Modifier = Modifier) {
|
||||
val alpha by rememberInfiniteTransition(label = "skeleton").animateFloat(
|
||||
initialValue = 0.35f,
|
||||
targetValue = 0.60f,
|
||||
animationSpec = infiniteRepeatable(
|
||||
animation = tween(durationMillis = 800),
|
||||
repeatMode = RepeatMode.Reverse,
|
||||
),
|
||||
label = "alpha",
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(min = 180.dp)
|
||||
.clip(RoundedCornerShape(16.dp))
|
||||
.background(MaterialTheme.colorScheme.surfaceVariant.copy(alpha = alpha))
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Box(Modifier.fillMaxWidth(0.4f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
|
||||
Box(Modifier.fillMaxWidth().height(36.dp).clip(RoundedCornerShape(12.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
|
||||
Box(Modifier.fillMaxWidth(0.7f).height(20.dp).clip(RoundedCornerShape(8.dp)).background(MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)))
|
||||
}
|
||||
}
|
||||
```
|
||||
2
.github/workflows/rust-core.yml
vendored
2
.github/workflows/rust-core.yml
vendored
@@ -45,6 +45,8 @@ jobs:
|
||||
run: rustup component add clippy rustfmt
|
||||
- name: Check Rust core
|
||||
run: make check-rust
|
||||
- name: Saved devices production release gate
|
||||
run: make test-rust-saved-devices
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit --locked
|
||||
- name: Audit Rust dependencies
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@@ -20,10 +20,15 @@ node_modules/
|
||||
target/
|
||||
.junie
|
||||
config.override.mk
|
||||
bin/
|
||||
tmp/
|
||||
|
||||
# Local design export scratch
|
||||
output/
|
||||
.scratch/
|
||||
|
||||
# Local ADRs (not tracked — agent/session decisions)
|
||||
docs/adr/
|
||||
.screenshots
|
||||
apple/RELEASE-MACOS.md
|
||||
apple/Generated/*.xcconfig
|
||||
.scratch/
|
||||
|
||||
14
AGENTS.md
14
AGENTS.md
@@ -29,12 +29,18 @@ Domain docs (reference, do not paste into PRs):
|
||||
|
||||
- [`crates/vnidrop/CORE_FLOW.md`](crates/vnidrop/CORE_FLOW.md)
|
||||
- [`crates/vnidrop/tests/README.md`](crates/vnidrop/tests/README.md)
|
||||
- **Saved Devices platform UI:** read
|
||||
[`DEVICE-HISTORY-UI-HANDOFF.md`](DEVICE-HISTORY-UI-HANDOFF.md) before work on
|
||||
`feat/device-history-kmp` or `feat/device-history-apple`; it defines branch
|
||||
ownership, PR bases, product behavior, and completion gates.
|
||||
|
||||
---
|
||||
|
||||
## Absolute rules
|
||||
|
||||
1. Prefer PRs into `master`. Do not merge to `master` locally unless the user asks.
|
||||
1. Prefer PRs into `master`. The Saved Devices platform branches are the
|
||||
documented exception: their PR base is `feat/device-history`. Do not merge to
|
||||
`master` locally unless the user asks.
|
||||
2. Do not `git push`, force-push, or open a PR unless the user asks.
|
||||
3. If `commit.gpgsign` is enabled, create **signed** commits only. If signing fails
|
||||
(empty `ssh-add -l`), stop and tell the user to unlock the key. Never switch to
|
||||
@@ -139,7 +145,7 @@ crates/vnidrop/src/runtime/
|
||||
provider.rs # provider events, per-connection send progress
|
||||
```
|
||||
|
||||
Other core modules: `filesystem.rs`, `repository.rs`, `approval.rs`,
|
||||
Other core modules: `filesystem.rs`, `invitation/`, `approval.rs`,
|
||||
`handshake.rs`, `ticket.rs`, `access_policy.rs`, `event_hub.rs`, `api.rs`.
|
||||
|
||||
### Shared app
|
||||
@@ -199,7 +205,7 @@ For UI and presentation work, **load and follow** the in-repo skill:
|
||||
.codex/skills/compose-skill/SKILL.md
|
||||
```
|
||||
|
||||
- Open at most one `references/*.md` file when the skill’s Quick Routing requires it.
|
||||
- Open at most one `references/*.md` file when the skill links to it for the current task.
|
||||
- Do not invent a second Compose style guide.
|
||||
- VniDrop uses **MVVM-style** ViewModels (`*State` + `StateFlow` + named methods),
|
||||
not a forced MVI `onEvent` base — adapt, do not rewrite.
|
||||
@@ -279,7 +285,7 @@ branch from updated `master`.
|
||||
|
||||
| Task | Start here |
|
||||
|------|------------|
|
||||
| Share / multi-file / folders | `runtime/share.rs`, `filesystem.rs`, platform `FileSystemService.*` |
|
||||
| Share / multi-file / folders | `runtime/share.rs`, `filesystem.rs`, platform `PickedShareSourceAdapter.*` |
|
||||
| Receive / export / sinks | `runtime/receive.rs` |
|
||||
| Cancel / delete / stop share | `runtime/lifecycle.rs`, `facade.rs` |
|
||||
| Per-receiver send progress | `runtime/provider.rs`, `ui/state/AppUiModels.kt` |
|
||||
|
||||
43
CONTEXT.md
Normal file
43
CONTEXT.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# VniDrop
|
||||
|
||||
Local peer-to-peer file transfer. This glossary is the product/core ubiquitous language — not an implementation guide.
|
||||
|
||||
## Transfers
|
||||
|
||||
**Transfer draft**:
|
||||
A temporary, local selection of files or one folder, an editable transfer name, and a destination intent before a transfer is created. A draft may produce an Invitation transfer or a Targeted transfer; it is neither until creation succeeds.
|
||||
_Avoid_: pending transfer, temporary transfer, share draft
|
||||
|
||||
**Invitation transfer**:
|
||||
A share anyone with the ticket can request, subject to approval and access policy. Ordinary multi-recipient send/receive.
|
||||
_Avoid_: contact send, held offer, reusable share offer
|
||||
|
||||
**Targeted transfer**:
|
||||
A transfer bound to one saved-device relationship: immutable sender, receiver, manifest, and content identity; requires explicit approval before content.
|
||||
_Avoid_: contact transfer, private share
|
||||
|
||||
**Saved device**:
|
||||
A remote app identity this installation has mutually consented to remember, with directional grants at a relationship generation.
|
||||
_Avoid_: contact, person, account
|
||||
|
||||
**Device relationship**:
|
||||
The durable pairing state between this installation and a remote endpoint (pending, saved, forgotten/blocked lifecycle).
|
||||
_Avoid_: contact record, friendship
|
||||
|
||||
## Persistence (core)
|
||||
|
||||
**Domain store**:
|
||||
The module that owns schema and queries for one domain (invitation history, targeted transfers, blocked devices, relationship rows, pairing eligibility, secret metadata). Callers use store methods — never a raw SQL pool.
|
||||
_Avoid_: repository-for-everything, DAO, database layer
|
||||
|
||||
**Invitation repository**:
|
||||
The domain store for invitation-transfer history, artifacts, receiver requests, and related events. Module path `invitation`; today’s type name may still be `Repository`.
|
||||
_Avoid_: “the database”, AppDataStores
|
||||
|
||||
**AppDataStores**:
|
||||
The bag of concrete domain stores opened together for one app-data profile (one SQLite pool, every schema applied once).
|
||||
_Avoid_: Repository (for the bag), Persistence (as a type name), DbContext
|
||||
|
||||
**Persistence open**:
|
||||
Creating the profile’s SQLite pool, applying all domain schemas, and returning `AppDataStores`. The only place that may touch pool creation for app data.
|
||||
_Avoid_: Repository::open as the global DB entry (once migrated), sqlite_pool export
|
||||
283
Cargo.lock
generated
283
Cargo.lock
generated
@@ -219,6 +219,18 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532"
|
||||
dependencies = [
|
||||
"event-listener",
|
||||
"event-listener-strategy",
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.5.0"
|
||||
@@ -244,6 +256,17 @@ dependencies = [
|
||||
"tokio",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-recursion"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -421,6 +444,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
@@ -483,6 +515,15 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.65"
|
||||
@@ -864,7 +905,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccc2776f0c61eca1ca32528f85548abd1a4be8fb53d1b21c013e4f18da1e7090"
|
||||
dependencies = [
|
||||
"data-encoding",
|
||||
"syn 1.0.109",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1049,6 +1090,12 @@ version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d"
|
||||
|
||||
[[package]]
|
||||
name = "endi"
|
||||
version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099"
|
||||
|
||||
[[package]]
|
||||
name = "enum-assoc"
|
||||
version = "1.3.0"
|
||||
@@ -1060,6 +1107,27 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef"
|
||||
dependencies = [
|
||||
"enumflags2_derive",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "enumflags2_derive"
|
||||
version = "0.7.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.2"
|
||||
@@ -1930,6 +1998,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -2511,6 +2580,15 @@ version = "2.8.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -2811,6 +2889,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
@@ -2837,6 +2929,15 @@ dependencies = [
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -2863,6 +2964,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -3032,6 +3144,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "ordered-stream"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "papaya"
|
||||
version = "0.2.4"
|
||||
@@ -3866,6 +3988,25 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "5.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"cbc",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"getrandom 0.2.17",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -3974,6 +4115,17 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_repr"
|
||||
version = "0.1.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8d3b1629de253c70a0508c3899572da79ca359fdab27c7920ff00406df418906"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.1"
|
||||
@@ -4485,6 +4637,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn-mid"
|
||||
version = "0.5.4"
|
||||
@@ -4550,7 +4713,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
@@ -4684,6 +4847,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -4977,6 +5141,17 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -5227,14 +5402,19 @@ dependencies = [
|
||||
"data-encoding",
|
||||
"futures",
|
||||
"futures-lite",
|
||||
"getrandom 0.3.4",
|
||||
"iroh",
|
||||
"iroh-blobs",
|
||||
"iroh-relay",
|
||||
"irpc",
|
||||
"irpc-iroh",
|
||||
"jni 0.21.1",
|
||||
"libc",
|
||||
"n0-future",
|
||||
"ndk-context",
|
||||
"num_cpus",
|
||||
"secret-service",
|
||||
"security-framework",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sqlx",
|
||||
@@ -5247,6 +5427,7 @@ dependencies = [
|
||||
"uniffi",
|
||||
"uuid",
|
||||
"walkdir",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5442,7 +5623,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.48.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5916,6 +6097,62 @@ dependencies = [
|
||||
"synstructure",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe18fb60dc696039e738717b76eaea21e7a4489bbb1885020b43c94236d7e98a"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-recursion",
|
||||
"async-trait",
|
||||
"enumflags2",
|
||||
"event-listener",
|
||||
"futures-core",
|
||||
"futures-lite",
|
||||
"hex",
|
||||
"libc",
|
||||
"ordered-stream",
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
"windows-sys 0.61.2",
|
||||
"winnow 1.0.3",
|
||||
"zbus_macros",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe96480bed92df2b442a1a30df364e12d08eed03aeb061f2b8dc6afb2be91119"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"zbus_names",
|
||||
"zvariant",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zbus_names"
|
||||
version = "4.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8bf88b4a3ff53e883001e0e0115b297a9d53c31b9c1edd2bfdd853e3428624e"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.52"
|
||||
@@ -6015,3 +6252,43 @@ name = "zmij"
|
||||
version = "1.0.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bee2a0bcd2a907786a456fff45aaaaf54c9ba5f50b71ae9ec1a4edd200c94911"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"winnow 1.0.3",
|
||||
"zvariant_derive",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.13.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38a708216a18780796770bfe3f4739c7c83a3e8f789b755534bbbc06e4e23e12"
|
||||
dependencies = [
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
"zvariant_utils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_utils"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "90cb9383f9b45290407a1258b202d3f8f01db719eb60b4e4055c6375af4fc7c7"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"serde",
|
||||
"syn 2.0.118",
|
||||
"winnow 1.0.3",
|
||||
]
|
||||
|
||||
561
DESIGN-DEVICE-HISTORY.md
Normal file
561
DESIGN-DEVICE-HISTORY.md
Normal file
@@ -0,0 +1,561 @@
|
||||
# Design — Saved devices and targeted transfers
|
||||
|
||||
Status: **production Rust core capability; platform UI implementation is in progress**.
|
||||
|
||||
The unreleased contact/held-offer/polling prototype has been removed. The
|
||||
implementation on this branch is the versioned saved-device, device-relationship,
|
||||
and targeted-transfer core described below. Its wire protocol and public core
|
||||
surface are production contracts. Platform UI work is coordinated in
|
||||
[`DEVICE-HISTORY-UI-HANDOFF.md`](DEVICE-HISTORY-UI-HANDOFF.md).
|
||||
|
||||
The feature lets two VniDrop installations remember one another after a
|
||||
successful transfer, with explicit consent on both devices. A saved device can
|
||||
then request a new transfer without another invitation, QR scan, or NFC tap.
|
||||
The receiver must still approve every transfer.
|
||||
|
||||
The Rust core, protocol, persistence, credential-storage integration, and
|
||||
platform contracts are complete. KMP and Apple product UI ship from separate
|
||||
branches into `feat/device-history` before the feature targets `master`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Vocabulary and invariants
|
||||
|
||||
### Saved device
|
||||
|
||||
A `SavedDevice` is a remote VniDrop **app-installation identity**. It is not a
|
||||
person, account, address-book contact, or reliably identifiable piece of
|
||||
physical hardware.
|
||||
|
||||
The identity is the remote iroh endpoint identity. A reinstall or unrecoverable
|
||||
endpoint-key loss creates a new identity and requires a new successful transfer
|
||||
and mutual consent. Display names, platform hints, IP addresses, and physical
|
||||
device properties must never merge identities.
|
||||
|
||||
### Device relationship
|
||||
|
||||
A `DeviceRelationship` is a mutually acknowledged relationship between two
|
||||
saved-device identities. It contains two directional grants: one issued in
|
||||
each direction. The relationship is usable only after both grants have been
|
||||
acknowledged.
|
||||
|
||||
### Targeted transfer
|
||||
|
||||
A `TargetedTransfer` is an immutable one-sender, one-receiver transfer. It is a
|
||||
separate domain from the existing invitation-based `Share`, which may serve
|
||||
multiple receivers.
|
||||
|
||||
The following invariants are mandatory:
|
||||
|
||||
- Saving a device requires a fully completed authenticated transfer and
|
||||
explicit consent on both devices.
|
||||
- Remembering a device never authorizes automatic receipt. Every targeted
|
||||
transfer requires explicit receiver approval.
|
||||
- A targeted transfer has exactly one sender identity, one receiver identity,
|
||||
one transfer ID, and one immutable manifest.
|
||||
- Authorization is bound to the selected receiver. A leaked capability or
|
||||
ticket must not authorize any other identity.
|
||||
- Relays may forward end-to-end encrypted traffic according to the active
|
||||
network profile, but VniDrop has no intermediary file store, relationship
|
||||
service, delivery queue, push service, or account system.
|
||||
- Existing invitation-based transfers retain their current behavior and domain
|
||||
model.
|
||||
|
||||
---
|
||||
|
||||
## 2. Goals and non-goals
|
||||
|
||||
### Goals
|
||||
|
||||
- Send to a previously saved device without exchanging another invitation.
|
||||
- Make mutual consent cryptographically enforceable rather than a UI promise.
|
||||
- Keep receiver approval mandatory for each new transfer.
|
||||
- Give forget, revoke, block, cancellation, and deletion immediate local
|
||||
security effect even when the peer is offline.
|
||||
- Persist accepted interrupted transfers so they can resume when both devices
|
||||
are online again.
|
||||
- Protect endpoint identity keys and relationship secrets with platform-backed
|
||||
credential storage.
|
||||
- Provide versioned, typed Rust and UniFFI contracts that every platform can
|
||||
exercise before UI work begins.
|
||||
|
||||
### Non-goals
|
||||
|
||||
- Automatic acceptance or unattended writes to a receiver's device.
|
||||
- Offline store-and-forward, automatic peer polling, background inboxes, or
|
||||
push notifications.
|
||||
- Server-side device discovery, relationship storage, history synchronization,
|
||||
backup, export, or restoration onto another installation.
|
||||
- Presence indicators or a promise that a suspended mobile application is
|
||||
reachable.
|
||||
- Groups or a multi-recipient variant of `TargetedTransfer`.
|
||||
- Associating several saved devices with a person or account.
|
||||
- UI screens, navigation, wording, and presentation architecture in this phase.
|
||||
|
||||
---
|
||||
|
||||
## 3. Network and privacy model
|
||||
|
||||
Saved-device operations use the same configured iroh network profile as
|
||||
ordinary transfers:
|
||||
|
||||
- `Automatic` may use configured/default relays and direct paths.
|
||||
- Custom-relay modes remain restricted to their configured relays and fallback
|
||||
policy.
|
||||
- `LocalOnly` must not silently enable public discovery or a relay.
|
||||
|
||||
An endpoint ID authenticates a peer; it is not, by itself, a routable address.
|
||||
Address discovery and file transport may use a relay. VniDrop and the endpoints
|
||||
still provide end-to-end authentication and encryption, so the relay cannot
|
||||
decrypt content or authorize a recipient. A relay may observe transport
|
||||
metadata such as network addresses, timing, and volume. VniDrop must not claim
|
||||
that relayed traffic is anonymous, metadata-free, or relay-free.
|
||||
|
||||
VniDrop does not upload a transfer for later delivery. The sender and receiver
|
||||
cores must both be reachable while an offer is negotiated. A relay cannot wake
|
||||
a terminated or suspended application. The first release therefore reports a
|
||||
typed unavailable or timeout result when the receiver's core cannot answer.
|
||||
|
||||
Current direct address candidates may be exchanged over an authenticated
|
||||
connection and cached for the connection or a short local lifetime. The app
|
||||
must not accumulate a historical IP-address log.
|
||||
|
||||
---
|
||||
|
||||
## 4. Identity and credential custody
|
||||
|
||||
The endpoint private key and all relationship capability secrets are protected
|
||||
by platform-backed credential storage:
|
||||
|
||||
| Platform | Required protection |
|
||||
|---|---|
|
||||
| Apple | Keychain with a non-synchronizing, device-appropriate accessibility class |
|
||||
| Android | Keystore-backed encryption; only ciphertext may live outside Keystore |
|
||||
| Windows | DPAPI scoped to the current user |
|
||||
| Linux | Secret Service/libsecret |
|
||||
|
||||
There is no plaintext fallback.
|
||||
|
||||
Rust owns identity use, cryptographic operations, relationship state, and
|
||||
authorization. Platforms provide a narrow secure-secret-store adapter. Public
|
||||
bindings exchange opaque handles and typed outcomes, never raw grants, pairing
|
||||
tokens, or private keys.
|
||||
|
||||
If the endpoint identity key is temporarily unavailable, networking is
|
||||
temporarily unavailable because VniDrop cannot authenticate as the same
|
||||
endpoint. If the endpoint key is available but relationship grants are not,
|
||||
ordinary invitation transfers remain available while saved-device operations
|
||||
fail closed. Neither case may generate a replacement identity automatically.
|
||||
|
||||
### 4.1 Legacy endpoint-key migration
|
||||
|
||||
Migration of an existing endpoint key must be recoverable:
|
||||
|
||||
1. Read the legacy key.
|
||||
2. Write it to protected storage.
|
||||
3. Read it back and prove that it derives the same endpoint ID.
|
||||
4. Commit a storage-version marker.
|
||||
5. Only then remove the legacy copy.
|
||||
|
||||
A crash at any step must preserve at least one valid copy and must not change
|
||||
the endpoint identity. Confirmed unrecoverable loss or an explicit identity
|
||||
reset is required before replacement.
|
||||
|
||||
Secrets must not synchronize through platform cloud backup. Restored metadata
|
||||
without its device-bound secrets reconciles to disabled relationships, never a
|
||||
cloned identity.
|
||||
|
||||
---
|
||||
|
||||
## 5. Pairing eligibility
|
||||
|
||||
Only a **fully completed authenticated transfer** creates pairing eligibility.
|
||||
A handshake, partial download, failed export, cancellation, decline, or failed
|
||||
transfer does not qualify. Either the sender or receiver may initiate pairing
|
||||
after a qualifying transfer.
|
||||
|
||||
During the qualifying transfer, the peers establish a cryptographic,
|
||||
single-use pairing eligibility capability bound to:
|
||||
|
||||
- Both endpoint identities.
|
||||
- The qualifying transfer/session.
|
||||
- The saved-device protocol version.
|
||||
- A 24-hour local expiry.
|
||||
|
||||
The capability becomes usable only after the transfer reaches its durable
|
||||
completed state. It is stored locally in encrypted form without filenames or a
|
||||
transfer-history record. It is deleted when consumed, declined, expired,
|
||||
forgotten, blocked, or reset.
|
||||
|
||||
Requests without valid eligibility are silently rejected. This prevents a
|
||||
modified stranger from generating unsolicited pairing prompts.
|
||||
|
||||
---
|
||||
|
||||
## 6. Mutual-consent protocol
|
||||
|
||||
The protocol uses explicit pending states rather than exposing partial contacts
|
||||
as usable saved devices:
|
||||
|
||||
- `PendingOutgoing`
|
||||
- `PendingIncoming`
|
||||
- `Saved`
|
||||
|
||||
The normal exchange is:
|
||||
|
||||
1. Alice locally chooses to remember Bob after a qualifying transfer.
|
||||
2. Alice sends a token-bound pairing request.
|
||||
3. Bob explicitly consents.
|
||||
4. Alice and Bob exchange fresh directional grants.
|
||||
5. Alice acknowledges Bob's grant.
|
||||
6. Both sides activate the relationship as `Saved` only after the mutual
|
||||
exchange is acknowledged.
|
||||
|
||||
Failure before activation remains a bounded pending operation and cannot be
|
||||
used to initiate a transfer. Pending operations expire and are recoverable or
|
||||
cleaned after crashes.
|
||||
|
||||
If both devices initiate simultaneously, the protocol deterministically merges
|
||||
the attempts using the endpoint identities and the transfer-bound eligibility
|
||||
capability. It creates one relationship and one active grant per direction,
|
||||
without duplicate prompts or rows.
|
||||
|
||||
Declining consumes the eligibility for that qualifying transfer. It cannot
|
||||
prompt again. A later completed transfer may establish new eligibility, but
|
||||
another request still requires fresh local initiation.
|
||||
|
||||
---
|
||||
|
||||
## 7. Directional grants
|
||||
|
||||
Each direction has one active, high-entropy capability bound to:
|
||||
|
||||
- Issuer endpoint identity.
|
||||
- Holder endpoint identity.
|
||||
- Relationship generation.
|
||||
- Minimum negotiated protocol generation.
|
||||
|
||||
Proof uses the authenticated iroh channel plus established, domain-separated
|
||||
cryptographic primitives, challenge binding, and replay protection. Display
|
||||
names, addresses, and transfer IDs alone are never authentication. The protocol
|
||||
must have independent, reviewable test vectors.
|
||||
|
||||
Relationships do not expire merely through inactivity. They remain until
|
||||
forget, block, explicit revocation, identity loss, or reset. Long-unseen devices
|
||||
may later be represented as inactive by UI, but inactivity does not silently
|
||||
remove permission.
|
||||
|
||||
Activating a replacement grant first makes the prior relationship generation
|
||||
locally invalid. Exactly one generation is active per direction. Minimal
|
||||
non-secret revocation tombstones are retained for as long as an old generation
|
||||
could otherwise be replayed; tombstones contain no names, filenames, transfer
|
||||
history, or capability material.
|
||||
|
||||
An established relationship records its minimum supported protocol generation
|
||||
and must never silently downgrade below it.
|
||||
|
||||
---
|
||||
|
||||
## 8. Forget, block, and identity replacement
|
||||
|
||||
### Forget
|
||||
|
||||
Forget makes the local relationship and its grants unusable immediately,
|
||||
cancels active or resumable targeted transfers for that relationship, removes
|
||||
relationship secrets and metadata, and sends a signed/bound best-effort remote
|
||||
revocation when possible. Correctness never depends on remote delivery.
|
||||
|
||||
An independently approved invitation transfer already in progress may continue
|
||||
because it belongs to the existing share domain.
|
||||
|
||||
### Block
|
||||
|
||||
Block is identity-wide and immediate. It rejects or cancels current and future
|
||||
traffic from the blocked endpoint across:
|
||||
|
||||
- Pairing and grant operations.
|
||||
- Targeted offers and transfers.
|
||||
- Ordinary invitation handshakes and transfers.
|
||||
- Revocation and probing endpoints, except for indistinguishable rejection
|
||||
needed to avoid exposing block state.
|
||||
|
||||
Blocking deletes active relationship grants but retains the minimal identity
|
||||
deny record and replay tombstones. Unblocking removes only the deny rule. It
|
||||
does not restore grants, relationships, or cancelled transfers. Saving the
|
||||
device again requires another qualifying transfer and fresh mutual consent.
|
||||
|
||||
A peer reinstall produces a new endpoint identity. It is never linked to the
|
||||
old device by name, address, or platform. The old saved entry remains
|
||||
unavailable until forgotten; the new identity follows the complete first-
|
||||
transfer and consent flow.
|
||||
|
||||
---
|
||||
|
||||
## 9. Targeted-transfer model
|
||||
|
||||
`TargetedTransfer` is not an access mode on an ordinary share. It has its own
|
||||
protocol types, repository records, authorization rules, and public APIs.
|
||||
Internal blob storage, import, hashing, streaming, and output-sink machinery may
|
||||
be reused.
|
||||
|
||||
The following fields are immutable after creation:
|
||||
|
||||
- Transfer ID.
|
||||
- Sender endpoint identity.
|
||||
- Receiver endpoint identity.
|
||||
- Manifest identity and content hashes.
|
||||
- File count and total size.
|
||||
|
||||
Sending identical content to several saved devices creates independent
|
||||
targeted transfers. Internal blobs may be deduplicated, but approval, progress,
|
||||
cancellation, retry, authorization, and durable state remain independent.
|
||||
|
||||
The durable state machine is:
|
||||
|
||||
```text
|
||||
Preparing -> Offering -> AwaitingApproval -> Approved -> Connecting
|
||||
-> Transferring -> Completed
|
||||
\-> Interrupted -> Connecting
|
||||
|
||||
Terminal alternatives: Declined, Cancelled, Failed, Deleted
|
||||
```
|
||||
|
||||
Rust centrally validates transitions. Platform code invokes typed operations
|
||||
and consumes snapshots/events; it cannot fabricate states.
|
||||
|
||||
---
|
||||
|
||||
## 10. Offer and approval protocol
|
||||
|
||||
An offer is online-only and bounded:
|
||||
|
||||
1. The sender creates an immutable targeted transfer for one saved-device
|
||||
identity.
|
||||
2. The peers authenticate the saved relationship and negotiate the targeted-
|
||||
transfer protocol version.
|
||||
3. The sender submits a bounded offer containing a stable transfer ID and an
|
||||
authenticated manifest summary, but no reusable ordinary-share ticket.
|
||||
4. The receiver validates all framing, limits, identity bindings, relay-policy
|
||||
compatibility, and manifest claims before surfacing approval.
|
||||
5. The receiver explicitly approves or declines.
|
||||
6. On approval, the sender issues authorization bound to the exact transfer,
|
||||
manifest, and receiver endpoint.
|
||||
7. The receiver pulls the content through the existing safe streaming and
|
||||
output-sink machinery.
|
||||
|
||||
The approved authorization covers the exact manifest, content hashes, sizes,
|
||||
sender, receiver, transfer ID, and protocol generation. Any mismatch or content
|
||||
mutation invalidates the transfer and requires a new transfer ID and approval.
|
||||
A leaked capability must fail when presented by another endpoint.
|
||||
|
||||
Every operation is idempotent. Replaying the same pairing request, offer,
|
||||
approval, acknowledgement, cancellation, or completion returns the existing
|
||||
result and cannot create duplicate prompts, grants, authorizations, or rows.
|
||||
|
||||
Declining rejects only that transfer. It neither forgets nor blocks the sender.
|
||||
|
||||
---
|
||||
|
||||
## 11. Online, interruption, and deletion semantics
|
||||
|
||||
An unapproved offer exists only in a bounded live-session queue. Sender
|
||||
cancellation, decline, timeout, disconnect, or core restart removes it. There
|
||||
is no sender-held offline offer, receiver polling loop, background inbox, or
|
||||
automatic retry that can produce a later prompt.
|
||||
|
||||
After approval, the transfer and its recipient-scoped authorization are
|
||||
durable. Interruption retains verified progress and may resume when both devices
|
||||
are online again. Resuming the same immutable transfer does not request another
|
||||
approval. Changed content or metadata requires a new transfer.
|
||||
|
||||
Cancellation before approval withdraws the offer. Cancellation after approval
|
||||
stops authorization and active streaming synchronously before asynchronous
|
||||
cleanup. It affects only that transfer.
|
||||
|
||||
Deletion must make authorization unusable, stop content service for that
|
||||
transfer, remove resumable state, and clean related secrets. Remote cleanup is
|
||||
best-effort; immediate durable local denial is mandatory.
|
||||
|
||||
Several separately approved targeted transfers may run concurrently between
|
||||
the same devices under existing global stream and resource limits.
|
||||
|
||||
---
|
||||
|
||||
## 12. Local data and consistency
|
||||
|
||||
The private application database may contain only the relationship and transfer
|
||||
metadata needed for the feature, including:
|
||||
|
||||
- Endpoint identity/public identifier.
|
||||
- User-owned local label and untrusted platform/name hints.
|
||||
- Pending/saved/blocked/revoked state and state revision.
|
||||
- Opaque secure-store handles.
|
||||
- Protocol and relationship generation.
|
||||
- Last successful authenticated contact time.
|
||||
- Minimal replay and revocation tombstones.
|
||||
- Durable targeted-transfer state after approval.
|
||||
|
||||
It must not become a transfer-history log. Pairing does not justify retaining
|
||||
filenames, previous IP addresses, or lists of past transfers.
|
||||
|
||||
Credential-store and SQLite updates cannot share a native transaction. Use
|
||||
recoverable staged transitions:
|
||||
|
||||
1. Write secret material under a versioned opaque handle.
|
||||
2. Verify the protected write.
|
||||
3. Commit metadata referencing that handle in a non-active state.
|
||||
4. Finalize activation.
|
||||
|
||||
Startup reconciliation removes orphaned secrets and disables metadata whose
|
||||
required secrets are missing. Revocation becomes locally effective before any
|
||||
network notification. Relationship mutations are serialized per remote
|
||||
endpoint, while unrelated devices proceed concurrently. Database, relationship,
|
||||
and credential-store guards must never be held across network awaits.
|
||||
|
||||
---
|
||||
|
||||
## 13. Core and platform contract
|
||||
|
||||
The Rust core exposes separate typed models and operations for:
|
||||
|
||||
- Pairing eligibility and pending pairing requests.
|
||||
- Listing, renaming, forgetting, blocking, and unblocking saved devices.
|
||||
- Creating and submitting targeted transfers.
|
||||
- Approving, declining, cancelling, resuming, and deleting transfers.
|
||||
- Querying durable state and current capability availability.
|
||||
- Subscribing to typed events carrying stable IDs and monotonic state revisions.
|
||||
|
||||
Bindings must not expose raw secrets or generic state mutation. Events are
|
||||
wake-up notifications, not authoritative storage. They may be delivered at
|
||||
least once; consumers deduplicate by stable ID and revision, then query current
|
||||
state after reconnect or restart.
|
||||
|
||||
### 13.1 Pairing and targeted-transfer event catalog
|
||||
|
||||
Canonical kinds emitted on `CoreEvent` (phase → kind). Treat every event as a
|
||||
wake-up: refresh durable state via list/get APIs. Targeted progress persists
|
||||
monotonic `verified_bytes`; event payloads remain advisory.
|
||||
|
||||
**`pairing`**
|
||||
|
||||
| Kind | Meaning |
|
||||
|---|---|
|
||||
| `eligibility-available` | Pairing eligibility exists for a peer after a completed authenticated invitation transfer. |
|
||||
| `eligibility-removed` | Eligibility expired or was consumed/removed. |
|
||||
| `relationship-changed` | Device-relationship state changed (pending, saved, revoked, blocked). Payload includes peer id and state. |
|
||||
| `relationship-grant-rotated` | Local relationship grant generation advanced for a peer. |
|
||||
| `saved-device-forgotten` | Local forget completed for a saved peer. |
|
||||
| `device-blocked` | Peer was blocked locally. |
|
||||
|
||||
**`targeted_transfer`**
|
||||
|
||||
| Kind | Meaning |
|
||||
|---|---|
|
||||
| `offer-received` | A pre-approval offer is pending local approve/decline. |
|
||||
| `approved` | Local approval completed; authorization is in core custody. |
|
||||
| `offer-declined` | Local decline completed. |
|
||||
| `created`, `offering`, `awaiting-approval` | Sender-side durable setup and offer lifecycle changed. |
|
||||
| `connecting`, `transferring`, `progress`, `interrupted` | Receiver-side pull lifecycle or verified payload progress changed. |
|
||||
| `completed`, `cancelled`, `failed`, `deleted` | A durable targeted-transfer terminal snapshot changed. |
|
||||
|
||||
Lifecycle payloads use `targeted_transfer_id`; consumers refresh the corresponding
|
||||
snapshot after receiving the wake-up. Progress payloads remain advisory and the
|
||||
durable snapshot is authoritative.
|
||||
|
||||
Failures remain typed where callers can act differently, including:
|
||||
|
||||
- Device unavailable or offer timeout.
|
||||
- Protocol incompatibility or forbidden downgrade.
|
||||
- Revoked or blocked relationship.
|
||||
- Relay-policy incompatibility.
|
||||
- Secure storage locked, unavailable, missing, or corrupted.
|
||||
- Approval decline, cancellation, interruption, and invalid transition.
|
||||
|
||||
Production errors and diagnostics must not expose endpoint IDs, direct
|
||||
addresses, tickets, grants, pairing capabilities, filenames, or secret-store
|
||||
payloads.
|
||||
|
||||
---
|
||||
|
||||
## 14. Limits and hostile-peer handling
|
||||
|
||||
A saved relationship proves a remote app identity and permits it to request
|
||||
approval. It does not make remote metadata, filenames, paths, sizes, messages,
|
||||
or content trusted.
|
||||
|
||||
The feature reuses all existing filesystem safety, output-sink, no-overwrite,
|
||||
ticket validation, and resource-limit invariants. Before approval it also
|
||||
enforces:
|
||||
|
||||
- One unresolved offer per sender identity.
|
||||
- A bounded global pending-offer queue.
|
||||
- Strict request, manifest, metadata, file-count, and size limits.
|
||||
- Connection, pairing, offer, approval, and acknowledgement timeouts.
|
||||
- Per-identity cooldown after repeated malformed traffic or declines.
|
||||
- Silent rejection of unauthenticated, ineligible, blocked, or invalid traffic.
|
||||
- A configurable `CoreLimits.max_saved_devices`, defaulting to 256.
|
||||
|
||||
These are control-plane and local-resource protections. They do not impose a
|
||||
quota on accepted transfers, files, bytes, or bandwidth.
|
||||
|
||||
VniDrop cannot protect against a compromised or unlocked endpoint, malicious
|
||||
files the receiver knowingly accepts, operating-system credential compromise,
|
||||
network traffic analysis, or a reinstalled peer appearing under a new identity.
|
||||
|
||||
---
|
||||
|
||||
## 15. Compatibility and release policy
|
||||
|
||||
Saved devices and targeted transfers use explicit, versioned protocol
|
||||
capabilities. A peer without compatible support cannot be paired or receive a
|
||||
targeted transfer and falls back to the existing invitation flow. A targeted
|
||||
transfer must never be reinterpreted as an ordinary share for compatibility.
|
||||
|
||||
The Rust core feature has passed its production release gate. Its wire protocol
|
||||
is versioned from its first merge. KMP and Apple Saved-device UI graduation,
|
||||
including their existing experimental preference gates, is a separate release
|
||||
decision. Future core protocol revisions continue to require:
|
||||
|
||||
- Stable migrations from every released database version.
|
||||
- Compatible Apple, Android, Windows, and Linux credential-store adapters.
|
||||
- Rust and platform contract coverage.
|
||||
- Stable downgrade, revocation, recovery, and lifecycle behavior.
|
||||
- No regression in invitation-based multi-recipient transfers.
|
||||
|
||||
The unreleased `feat/device-history` contact schema, held offers, polling
|
||||
behavior, expiring grants, `Contact` terminology, Apple-only feature UI, and
|
||||
ordinary-share offer authorization were prototype artifacts and have been
|
||||
removed without a compatibility migration. Useful low-level cryptographic,
|
||||
repository, protocol, and test patterns were retained only after they were
|
||||
checked against this design.
|
||||
|
||||
---
|
||||
|
||||
## 16. Verification requirements
|
||||
|
||||
Rust tests must deterministically cover:
|
||||
|
||||
- Mutual consent, decline, simultaneous initiation, timeouts, and lost
|
||||
acknowledgements.
|
||||
- Pairing eligibility after completion and rejection after every non-completed
|
||||
outcome.
|
||||
- Replay, malformed input, spoofed identity, blocking, revocation, grant
|
||||
rotation, and protocol downgrade.
|
||||
- Recipient-bound authorization and rejection of leaked capabilities.
|
||||
- Direct, relay, custom-relay, local-only, and incompatible-profile behavior.
|
||||
- Restart and recovery at every durable state.
|
||||
- Cancellation, deletion, forget, and block during active streaming.
|
||||
- Credential-store failure and crash-point reconciliation.
|
||||
- Concurrent independent targeted transfers.
|
||||
- Existing invitation-based multi-recipient behavior remaining unchanged.
|
||||
|
||||
Each platform secure-storage adapter requires contract coverage for create,
|
||||
read, update, delete, locked/unavailable behavior, migration, device-bound
|
||||
persistence, orphan cleanup, and redaction. Platform harnesses must prove that
|
||||
secrets do not appear in generated bindings, logs, diagnostics, or ordinary
|
||||
database columns.
|
||||
|
||||
The core/platform foundation is complete only when these contracts are
|
||||
implemented, documented, exposed through typed UniFFI APIs, and pass the
|
||||
relevant Rust and platform checks. UI polish is not part of that completion
|
||||
boundary.
|
||||
118
DEVICE-HISTORY-UI-HANDOFF.md
Normal file
118
DEVICE-HISTORY-UI-HANDOFF.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Saved Devices UI handoff
|
||||
|
||||
This document coordinates the platform UI work built on the production Saved
|
||||
Devices and Targeted Transfer core.
|
||||
|
||||
## Branch topology
|
||||
|
||||
| Branch | Ownership | Pull-request base |
|
||||
|---|---|---|
|
||||
| `feat/device-history` | Shared core contract and integration base | `master` only when the complete feature is ready |
|
||||
| `feat/device-history-kmp` | Android, Windows, and Linux Compose UI | `feat/device-history` |
|
||||
| `feat/device-history-apple` | Native iOS and macOS SwiftUI | `feat/device-history` |
|
||||
|
||||
Create both platform branches from the same `feat/device-history` commit. Keep
|
||||
platform work on its matching branch. Open every platform PR against
|
||||
`feat/device-history`, never `master` or the sibling platform branch. When the
|
||||
base advances, merge or rebase `feat/device-history` into the platform branch;
|
||||
do not merge one platform branch into the other.
|
||||
|
||||
## Product contract
|
||||
|
||||
- Saved Device is a top-level product feature, not an experimental setting.
|
||||
- A populated Saved Devices screen has a title-only header. Explanatory copy
|
||||
belongs in the first-use empty state or next to the control that needs it.
|
||||
- The main screen lists saved devices and outstanding consent requests. It does
|
||||
not expose the global Targeted Transfer history.
|
||||
- Selecting a saved device opens a platform-native details surface: bottom
|
||||
sheet on compact mobile layouts and a native inspector, sheet, or dialog on
|
||||
wider layouts. That surface owns Send, label/forget/block actions, and the
|
||||
device's Targeted Transfers with related lifecycle activity, status,
|
||||
progress, and available actions.
|
||||
- Display `localLabel` when present, otherwise the authenticated
|
||||
`remoteDisplayName`. Keep the endpoint ID secondary and diagnostic.
|
||||
- Use each platform's native device iconography and interaction conventions.
|
||||
Equivalent behavior may use separate Apple and Compose implementations.
|
||||
- Label changes are transactional from the UI's perspective: preserve the
|
||||
draft and editor on failure, prevent conflicting dismissal/edit actions while
|
||||
saving, and close only after success.
|
||||
- Invitation Transfer and Targeted Transfer source composition have file,
|
||||
folder, editable-name, replacement, and cleanup parity. Keep the domains
|
||||
distinct after creation.
|
||||
- Every Targeted Transfer still needs receiver approval. Saving a device never
|
||||
grants automatic receipt.
|
||||
- UI and platform code manage pickers and destinations; Rust streams payload
|
||||
bytes. Android folder selection expands SAF trees into file descriptors and
|
||||
relative names, never a directory descriptor.
|
||||
|
||||
Use the exact domain terms in [`CONTEXT.md`](CONTEXT.md) and the security and
|
||||
lifecycle invariants in [`crates/vnidrop/CORE_FLOW.md`](crates/vnidrop/CORE_FLOW.md).
|
||||
The KMP implementation under
|
||||
`shared/src/commonMain/kotlin/com/vnidrop/app/feature/saveddevices/` is a tested
|
||||
behavioral reference, not an Apple visual specification.
|
||||
|
||||
## KMP implementation branch
|
||||
|
||||
Start on `feat/device-history-kmp` and follow
|
||||
[`shared/AGENTS.md`](shared/AGENTS.md) plus
|
||||
[`.codex/skills/compose-skill/SKILL.md`](.codex/skills/compose-skill/SKILL.md).
|
||||
|
||||
The branch owns:
|
||||
|
||||
- `shared/`, `androidApp/`, and `desktopApp/` Saved Devices presentation work;
|
||||
- Material Android and native-feeling Windows/Linux presentations;
|
||||
- per-device details, transfer composition, offers, pairing consent, label
|
||||
editing, and lifecycle actions;
|
||||
- common state-machine tests, JVM Compose interaction tests, and platform
|
||||
adapter tests.
|
||||
|
||||
Before handoff, run `make check-localization`, `make check-shared`, and the
|
||||
relevant Android build. Inspect the actual Android emulator and desktop window;
|
||||
record any host that could not be rendered.
|
||||
|
||||
## Apple implementation branch
|
||||
|
||||
Start on `feat/device-history-apple`. Apple remains native SwiftUI; do not add
|
||||
Apple presentation to `shared/`.
|
||||
|
||||
The branch owns:
|
||||
|
||||
- a top-level Saved Devices destination in the iOS tab bar and macOS sidebar;
|
||||
- an Apple-native Saved Devices model/coordinator and SwiftUI screen;
|
||||
- pairing consent and Targeted Offer presentation outside Experimental
|
||||
Settings;
|
||||
- per-device details and Targeted Transfer lifecycle actions;
|
||||
- iOS/macOS picker and receive-destination integration using the existing
|
||||
platform services;
|
||||
- Swift model tests, UI contract tests, and simulator-rendered visual checks.
|
||||
|
||||
Use the generated production UniFFI surface in
|
||||
`apple/VnidropCore/Sources/VnidropCore/Vnidrop.swift`, including
|
||||
`listSavedDevices`, `listDeviceRelationships`, `listPairingEligibilities`,
|
||||
`listPendingTargetedOffers`, `createTargetedTransfer`,
|
||||
`respondToTargetedOffer`, `listTargetedTransfers`, receive/resume/cancel/delete,
|
||||
label, forget, and block operations. Wrap those calls through the existing
|
||||
Apple `CoreGateway` / `CoreRepository` boundary rather than invoking generated
|
||||
bindings from SwiftUI views.
|
||||
|
||||
Use SF Symbols and native iOS/macOS controls even when that duplicates Compose
|
||||
presentation code. Share behavior and vocabulary across platforms, not widget
|
||||
implementations. Before handoff, run `make check-localization` and
|
||||
`make check-apple`, then inspect the affected iOS and macOS states in real
|
||||
simulator/app hosts.
|
||||
|
||||
## Completion gate
|
||||
|
||||
Each platform PR is ready only when it demonstrates:
|
||||
|
||||
1. mutual consent creates and names a Saved Device correctly;
|
||||
2. an already-saved pair is not prompted to save again;
|
||||
3. files and folders can be composed, changed, and sent to one saved device;
|
||||
4. Targeted Transfers are absent from the main device list and visible in the
|
||||
selected device's details surface;
|
||||
5. receive, resume, cancel, delete, progress, and terminal states survive
|
||||
refresh/restart as defined by the core snapshot;
|
||||
6. label failure preserves the draft and retry path;
|
||||
7. empty, populated, busy, error, long-name, and destructive-confirmation
|
||||
states are rendered and visually inspected;
|
||||
8. ordinary Invitation Transfer flows remain unchanged.
|
||||
23
Makefile
23
Makefile
@@ -10,7 +10,7 @@ include $(ROOT)/make/release.mk
|
||||
|
||||
.PHONY: help doctor setup setup-localization setup-docs setup-diagnostics
|
||||
.PHONY: format test check check-rust audit-rust test-rust test-rust-all
|
||||
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink
|
||||
.PHONY: test-rust-transfer test-rust-approval test-rust-lifecycle test-rust-output-sink test-rust-saved-devices
|
||||
.PHONY: check-shared test-shared test-android-host check-android verify-android-libs build-android run-desktop
|
||||
.PHONY: apple-core apple-version-config apple-app-config apple-project open-apple-project open-apple build-apple-macos build-apple-ios check-apple package-apple-core
|
||||
.PHONY: prepare-release check-version check-release check-localization localization localization-migrate
|
||||
@@ -83,30 +83,35 @@ check-release: ## Validate coordinated release scripts and workflow YAML.
|
||||
|
||||
check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
|
||||
cd $(ROOT) && $(CARGO) fmt --all -- --check
|
||||
cd $(ROOT) && $(CARGO) clippy --workspace --all-targets -- -D warnings
|
||||
cd $(ROOT) && $(CARGO) test --workspace --all-targets
|
||||
cd $(ROOT) && $(CARGO) clippy --workspace --all-targets --features integration-test-store -- -D warnings
|
||||
cd $(ROOT) && $(CARGO) test --workspace --all-targets --features integration-test-store
|
||||
cd $(ROOT) && RUSTDOCFLAGS='-D warnings' $(CARGO) doc --workspace --no-deps
|
||||
|
||||
audit-rust: ## Audit Rust dependencies (requires cargo-audit).
|
||||
cd $(ROOT) && $(CARGO) audit
|
||||
|
||||
test-rust: ## Run the focused Rust core suite.
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store
|
||||
|
||||
test-rust-all: ## Run every Rust workspace test target.
|
||||
cd $(ROOT) && $(CARGO) test --workspace --all-targets
|
||||
cd $(ROOT) && $(CARGO) test --workspace --all-targets --features integration-test-store
|
||||
|
||||
test-rust-transfer: ## Run Rust transfer integration tests.
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --test transfer
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test transfer
|
||||
|
||||
test-rust-approval: ## Run Rust approval integration tests.
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --test approval
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test approval
|
||||
|
||||
test-rust-lifecycle: ## Run Rust lifecycle integration tests.
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --test lifecycle
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test lifecycle
|
||||
|
||||
test-rust-output-sink: ## Run Rust output-sink integration tests.
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --test output_sink
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test output_sink
|
||||
|
||||
test-rust-saved-devices: ## Run the Saved devices production-core release gate.
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --lib
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test saved_device_domain
|
||||
cd $(ROOT) && $(CARGO) test -p vnidrop --features integration-test-store --test transfer --test approval --test lifecycle --test output_sink
|
||||
|
||||
check-shared: ## Test and compile the shared Android/JVM module.
|
||||
cd $(ROOT) && $(GRADLE) :shared:jvmTest :shared:compileKotlinJvm $(GRADLE_FLAGS)
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.vnidrop.app.core.initializeAndroidCoreRuntime
|
||||
import com.vnidrop.app.feature.receive.ExternalInvitationController
|
||||
import com.vnidrop.app.feature.receive.MaxVniDropInvitationBytes
|
||||
import com.vnidrop.app.feature.receive.VniDropInvitationExtension
|
||||
@@ -22,6 +23,7 @@ class MainActivity : ComponentActivity() {
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
enableEdgeToEdge()
|
||||
super.onCreate(savedInstanceState)
|
||||
initializeAndroidCoreRuntime(applicationContext)
|
||||
setContent {
|
||||
App(rememberAndroidAppDependencies(this, externalInvitations))
|
||||
}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// swift-tools-version:5.9
|
||||
import PackageDescription
|
||||
|
||||
// Core/UI Swift sources built as a library so the shared logic can be typechecked
|
||||
// and unit-tested from the command line (macOS). The iOS/macOS app target in the
|
||||
// Xcode project links the same sources plus the app entry point.
|
||||
let package = Package(
|
||||
name: "VniDropApp",
|
||||
defaultLocalization: "en",
|
||||
platforms: [
|
||||
.iOS(.v16),
|
||||
.macOS(.v13),
|
||||
],
|
||||
products: [
|
||||
.library(name: "VniDropApp", targets: ["VniDropApp"]),
|
||||
],
|
||||
dependencies: [
|
||||
.package(path: "VnidropCore"),
|
||||
],
|
||||
targets: [
|
||||
.target(
|
||||
name: "VniDropApp",
|
||||
dependencies: [.product(name: "VnidropCore", package: "VnidropCore")],
|
||||
path: "VniDrop",
|
||||
// The @main entry belongs to the Xcode app target only; excluding it
|
||||
// keeps this library free of a conflicting `_main` symbol for tests.
|
||||
exclude: ["Resources", "App/VniDropApp.swift"],
|
||||
// The Rust core (iroh network stack) links these system libraries. The
|
||||
// Xcode app target must add the same frameworks under "Link Binary With
|
||||
// Libraries" (SystemConfiguration, Security, libresolv).
|
||||
linkerSettings: [
|
||||
.linkedFramework("SystemConfiguration"),
|
||||
.linkedFramework("Security"),
|
||||
.linkedLibrary("resolv"),
|
||||
]
|
||||
),
|
||||
.testTarget(
|
||||
name: "VniDropAppTests",
|
||||
dependencies: ["VniDropApp"],
|
||||
path: "Tests"
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -18,9 +18,8 @@ apple/
|
||||
UI/Theme|Components|Navigation|Feedback|Shell/
|
||||
Platform/ # pickers, QR, NFC, share/export, per-OS file services
|
||||
Resources/ # Localizable.xcstrings, Info.plist, entitlements, assets
|
||||
Tests/ # XCTest (ported progress-derivation assertions)
|
||||
Package.swift # builds VniDrop/ as a library for CLI build/test
|
||||
project.yml # XcodeGen spec for the iOS/macOS app target
|
||||
Tests/ # XCTest bundle (VniDropTests target)
|
||||
project.yml # XcodeGen spec for the iOS/macOS app and test targets
|
||||
```
|
||||
|
||||
## Build & run
|
||||
@@ -72,19 +71,22 @@ opt in with `APPLE_CODE_SIGNING=YES`. For signed builds from Xcode, create the
|
||||
ignored `apple/Local.xcconfig` and override the signing settings there, including
|
||||
the development team.
|
||||
|
||||
## Command-line typecheck & tests
|
||||
## Typecheck & tests
|
||||
|
||||
`Package.swift` builds the same sources as a library (minus the `@main` entry),
|
||||
so the shared logic can be checked and unit-tested without Xcode:
|
||||
The Xcode project is the only build definition: it owns the UI, its package
|
||||
dependencies, and the `VniDropTests` bundle (module `VniDrop`, which is what the
|
||||
tests import). Everything runs through `xcodebuild`:
|
||||
|
||||
```bash
|
||||
cd apple
|
||||
swift build # macOS
|
||||
swift test # runs Tests/ (ported progress-derivation assertions)
|
||||
# iOS typecheck:
|
||||
swift build --triple arm64-apple-ios16.0-simulator --sdk "$(xcrun --sdk iphonesimulator --show-sdk-path)"
|
||||
make check-apple # iOS simulator unit tests
|
||||
make build-apple-macos # unsigned macOS build (typecheck)
|
||||
```
|
||||
|
||||
There is deliberately no SwiftPM manifest for the app. A second build definition
|
||||
would duplicate the target's package dependencies, and the previous one had
|
||||
already drifted out of sync with `project.yml` badly enough that neither
|
||||
`swift build` nor `swift test` worked.
|
||||
|
||||
## Generated / ignored artifacts
|
||||
|
||||
`build-core.sh` produces build outputs that are gitignored (see `apple/.gitignore`):
|
||||
@@ -106,8 +108,7 @@ Rust crate itself is never changed.
|
||||
## System frameworks
|
||||
|
||||
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
|
||||
`libresolv`. These are declared in both `Package.swift` (for CLI build/test) and
|
||||
`project.yml` (for the app target).
|
||||
`libresolv`. These are declared in `project.yml` for the app target.
|
||||
|
||||
## Parity & scope
|
||||
|
||||
|
||||
@@ -92,8 +92,16 @@ final class FakeFileSystemService: FileSystemService {
|
||||
func defaultReceiveFolder() -> ReceiveFolder { folder }
|
||||
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
|
||||
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
|
||||
await repository.shareSources([], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy)
|
||||
private(set) var shareDestinations: [ShareDestination] = []
|
||||
|
||||
func sharePickedFiles(repository: CoreGateway, files: [PickedShareFile], transferName: String, senderName: String, destination: ShareDestination) async -> Result<Share, Error> {
|
||||
shareDestinations.append(destination)
|
||||
guard case .invitation(let accessPolicy) = destination else {
|
||||
return .failure(TestError.unimplemented)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
[], transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -70,7 +70,7 @@ final class ProgressDerivationTests: XCTestCase {
|
||||
|
||||
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
|
||||
CoreEventModel(
|
||||
id: UUID().uuidString, timestamp: 0, scope: "transfer", transferId: 1,
|
||||
id: UUID().uuidString, revision: 1, timestamp: 0, scope: "transfer", transferId: 1,
|
||||
direction: "send", phase: phase, kind: kind, dataJson: json
|
||||
)
|
||||
}
|
||||
|
||||
174
apple/Tests/SavedDeviceCoreContractTests.swift
Normal file
174
apple/Tests/SavedDeviceCoreContractTests.swift
Normal file
@@ -0,0 +1,174 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
@preconcurrency import VnidropCore
|
||||
@testable import VniDrop
|
||||
|
||||
/// Headless Apple harness for the saved-device core/platform contract (ticket 14).
|
||||
///
|
||||
/// Exercises protected identity restart, event revision recovery, and binding
|
||||
/// hygiene against the generated UniFFI surface. The full two-node public-API
|
||||
/// lifecycle (eligibility → unblock) lives in
|
||||
/// `crates/vnidrop/src/tests/platform_contract_apple.rs` so it can run without
|
||||
/// the iOS simulator.
|
||||
final class SavedDeviceCoreContractTests: XCTestCase {
|
||||
private final class RecordingSink: CoreEventSink, @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var events: [CoreEvent] = []
|
||||
|
||||
func onEvent(event: CoreEvent) {
|
||||
lock.lock()
|
||||
events.append(event)
|
||||
lock.unlock()
|
||||
}
|
||||
|
||||
func snapshot() -> [CoreEvent] {
|
||||
lock.lock()
|
||||
defer { lock.unlock() }
|
||||
return events
|
||||
}
|
||||
}
|
||||
|
||||
func testProtectedKeychainIdentitySurvivesStandardConstructorRestart() throws {
|
||||
let directory = try FileManager.default.url(
|
||||
for: .itemReplacementDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: FileManager.default.temporaryDirectory,
|
||||
create: true
|
||||
).appendingPathComponent("vnidrop-apple-contract-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let sink = RecordingSink()
|
||||
let first = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||
appDataDir: directory.path,
|
||||
eventSink: sink,
|
||||
limits: defaultCoreLimits(),
|
||||
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
|
||||
)
|
||||
let endpointId = first.status().endpointId
|
||||
XCTAssertFalse(endpointId.isEmpty)
|
||||
XCTAssertFalse(
|
||||
FileManager.default.fileExists(atPath: directory.appendingPathComponent("iroh.secret").path),
|
||||
"protected identity must not fall back to plaintext"
|
||||
)
|
||||
first.shutdown()
|
||||
|
||||
let restarted = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||
appDataDir: directory.path,
|
||||
eventSink: RecordingSink(),
|
||||
limits: defaultCoreLimits(),
|
||||
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
|
||||
)
|
||||
defer { restarted.shutdown() }
|
||||
XCTAssertEqual(restarted.status().endpointId, endpointId)
|
||||
}
|
||||
|
||||
func testEventRevisionRecoveryUsesStableIdsThenListApis() throws {
|
||||
let directory = try FileManager.default.url(
|
||||
for: .itemReplacementDirectory,
|
||||
in: .userDomainMask,
|
||||
appropriateFor: FileManager.default.temporaryDirectory,
|
||||
create: true
|
||||
).appendingPathComponent("vnidrop-apple-events-\(UUID().uuidString)", isDirectory: true)
|
||||
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
||||
defer { try? FileManager.default.removeItem(at: directory) }
|
||||
|
||||
let sink = RecordingSink()
|
||||
let core = try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||
appDataDir: directory.path,
|
||||
eventSink: sink,
|
||||
limits: defaultCoreLimits(),
|
||||
networkConfig: CoreNetworkConfig(mode: .automatic, relayUrls: [])
|
||||
)
|
||||
defer { core.shutdown() }
|
||||
|
||||
// Force at least one durable event through the public surface.
|
||||
XCTAssertThrowsError(
|
||||
try core.receive(ticket: "not-a-ticket", outputDir: directory.path, receiverName: nil)
|
||||
)
|
||||
|
||||
let listed = try core.listEvents(transferId: nil)
|
||||
XCTAssertFalse(listed.isEmpty)
|
||||
|
||||
var seenIds = Set<String>()
|
||||
var revisions = Set<UInt64>()
|
||||
for event in listed {
|
||||
XCTAssertTrue(seenIds.insert(event.id).inserted, "event ids must be stable/unique")
|
||||
XCTAssertGreaterThanOrEqual(event.revision, 1)
|
||||
XCTAssertTrue(revisions.insert(event.revision).inserted, "revisions must be distinct")
|
||||
}
|
||||
|
||||
// Simulate duplicate delivery after a listener restart, then trust list APIs.
|
||||
let duplicates = listed
|
||||
var recoveredIds = Set<String>()
|
||||
var maxRevision: UInt64 = 0
|
||||
for event in listed + duplicates {
|
||||
if recoveredIds.insert(event.id).inserted {
|
||||
maxRevision = max(maxRevision, event.revision)
|
||||
}
|
||||
}
|
||||
XCTAssertEqual(recoveredIds.count, listed.count)
|
||||
XCTAssertGreaterThanOrEqual(maxRevision, 1)
|
||||
|
||||
XCTAssertEqual(try core.listSavedDevices().count, 0)
|
||||
XCTAssertEqual(try core.listDeviceRelationships().count, 0)
|
||||
XCTAssertEqual(try core.listBlockedDevices().count, 0)
|
||||
}
|
||||
|
||||
func testGeneratedBindingsOmitRawSecretsAndGenericMutation() throws {
|
||||
let candidates = [
|
||||
Bundle(for: SavedDeviceCoreContractTests.self).bundleURL
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Vnidrop.swift"),
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("VnidropCore/Sources/VnidropCore/Vnidrop.swift"),
|
||||
]
|
||||
|
||||
guard let bindingsURL = candidates.first(where: { FileManager.default.fileExists(atPath: $0.path) })
|
||||
else {
|
||||
// Source package path may be absent in a clean CI checkout before
|
||||
// build-core; linking the typed APIs below still proves the
|
||||
// regenerated surface is what the harness compiles against.
|
||||
let _: (
|
||||
(String, CoreEventSink, CoreLimits, CoreNetworkConfig) throws -> VnidropCore
|
||||
) = VnidropCore.initializeWithLimitsAndNetworkConfig
|
||||
let capabilities: SavedDeviceCapabilities = savedDeviceCapabilities()
|
||||
XCTAssertGreaterThanOrEqual(capabilities.domainContractVersion, 1)
|
||||
XCTAssertNotNil(defaultCoreLimits().maxSavedDevices)
|
||||
return
|
||||
}
|
||||
|
||||
let source = try String(contentsOf: bindingsURL, encoding: .utf8)
|
||||
let forbidden = [
|
||||
"SecretMaterial",
|
||||
"SecretHandle",
|
||||
"SecureSecretStore",
|
||||
"executeSql",
|
||||
"executeSQL",
|
||||
"mutateState",
|
||||
"applyRawState",
|
||||
"rawSecret",
|
||||
"grantSecret",
|
||||
"pairingCapabilityBytes",
|
||||
"func setState(",
|
||||
"func mutate(",
|
||||
]
|
||||
for needle in forbidden {
|
||||
XCTAssertFalse(
|
||||
source.contains(needle),
|
||||
"generated bindings must not expose \(needle)"
|
||||
)
|
||||
}
|
||||
XCTAssertFalse(source.contains("initializeWithExperimentalSavedDevices"))
|
||||
XCTAssertFalse(source.contains("ExperimentalSavedDeviceCapabilities"))
|
||||
XCTAssertFalse(source.contains("experimentalSavedDeviceCapabilities"))
|
||||
XCTAssertTrue(source.contains("initializeWithLimitsAndNetworkConfig"))
|
||||
XCTAssertTrue(source.contains("public struct SavedDeviceCapabilities"))
|
||||
XCTAssertTrue(source.contains("public func savedDeviceCapabilities()"))
|
||||
XCTAssertTrue(source.contains("setSavedDeviceLabel"))
|
||||
XCTAssertTrue(source.contains("listSavedDevices"))
|
||||
XCTAssertTrue(source.contains("revision"))
|
||||
}
|
||||
}
|
||||
@@ -167,7 +167,8 @@ struct RootView: View {
|
||||
switch destination {
|
||||
case .send: SendScreen(model: sendModel, windowClass: windowClass)
|
||||
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
|
||||
case .settings: SettingsScreen(model: settingsModel, windowClass: windowClass)
|
||||
case .settings:
|
||||
SettingsScreen(model: settingsModel, windowClass: windowClass)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,3 +284,10 @@ import UIKit
|
||||
#else
|
||||
import AppKit
|
||||
#endif
|
||||
|
||||
/// Hosts the device-history consent prompts, alongside `ApprovalLayer`.
|
||||
///
|
||||
/// Separate from the approval layer because the two never compete: an approval
|
||||
/// belongs to a transfer this device is sending, and these belong to a device
|
||||
/// asking to reach it. Both are suppressed while the other is up so the user is
|
||||
/// never answering two modals at once.
|
||||
|
||||
@@ -145,7 +145,7 @@ final class AppPreferencesRepository: ObservableObject {
|
||||
static let themeMode = "theme_mode"
|
||||
static let diagnosticsInstallId = "diagnostics_install_id"
|
||||
static let relayConfiguration = "relay_configuration"
|
||||
}
|
||||
}
|
||||
|
||||
init(defaults: UserDefaults = .standard, fallback: AppPreferencesDefaults) {
|
||||
self.defaults = defaults
|
||||
|
||||
@@ -12,6 +12,7 @@ struct CoreStatus: Equatable, Sendable {
|
||||
|
||||
struct CoreEventModel: Equatable, Identifiable, Sendable {
|
||||
let id: String
|
||||
let revision: UInt64
|
||||
let timestamp: Int64
|
||||
let scope: String
|
||||
let transferId: UInt64?
|
||||
@@ -72,6 +73,11 @@ enum ShareAccessPolicy: Equatable, Sendable {
|
||||
case anyoneWithTransfer
|
||||
}
|
||||
|
||||
/// Where a picked selection is going.
|
||||
enum ShareDestination: Equatable, Sendable {
|
||||
case invitation(accessPolicy: ShareAccessPolicy)
|
||||
}
|
||||
|
||||
enum TransferDirection: Equatable, Sendable {
|
||||
case send
|
||||
case receive
|
||||
|
||||
@@ -53,9 +53,10 @@ struct NativeCoreBindingFactory: CoreBindingFactory {
|
||||
case .localOnly:
|
||||
nativeConfiguration = CoreNetworkConfig(mode: .localOnly, relayUrls: [])
|
||||
}
|
||||
return try VnidropCore.initializeWithNetworkConfig(
|
||||
return try VnidropCore.initializeWithLimitsAndNetworkConfig(
|
||||
appDataDir: appDataDir,
|
||||
eventSink: eventSink,
|
||||
limits: defaultCoreLimits(),
|
||||
networkConfig: nativeConfiguration
|
||||
)
|
||||
}
|
||||
@@ -292,7 +293,6 @@ final class CoreRepository: ObservableObject, CoreGateway {
|
||||
if let snapshot { self.applySnapshot(snapshot) }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Event sink handling (ported from CoreRepository.sink)
|
||||
|
||||
private func handle(event: CoreEvent) {
|
||||
@@ -396,7 +396,7 @@ private func withSecurityScopedAccess<T>(pathOrUrl: String, _ body: () throws ->
|
||||
private extension CoreEvent {
|
||||
func toModel() -> CoreEventModel {
|
||||
CoreEventModel(
|
||||
id: id, timestamp: timestamp, scope: scope, transferId: transferId,
|
||||
id: id, revision: revision, timestamp: timestamp, scope: scope, transferId: transferId,
|
||||
direction: direction, phase: phase, kind: kind, dataJson: dataJson
|
||||
)
|
||||
}
|
||||
@@ -519,3 +519,7 @@ private extension ReceiverRequest {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -33,12 +33,15 @@ protocol FileSystemService {
|
||||
func revealReceiveFolder(_ folder: ReceiveFolder) async -> Result<Void, Error>
|
||||
/// Releases only app-owned picker copies; never deletes original user sources.
|
||||
func discardPickedFiles(_ files: [PickedShareFile]) async
|
||||
/// Imports a picked selection, either as an invitation or straight to a
|
||||
/// remembered device. One entry point so the platform's security-scoped
|
||||
/// access handling covers both.
|
||||
func sharePickedFiles(
|
||||
repository: CoreGateway,
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
destination: ShareDestination
|
||||
) async -> Result<Share, Error>
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,9 @@ enum ReceiveMethod {
|
||||
case invitationFile
|
||||
case qrCode
|
||||
case nfc
|
||||
/// Pushed by a remembered device and already accepted by the user, so no
|
||||
/// invitation was acquired by hand.
|
||||
case offer
|
||||
}
|
||||
|
||||
enum ReceiveHistoryDeleteTarget: Equatable {
|
||||
@@ -154,6 +157,40 @@ final class ReceiveModel: ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
/// Receive a transfer the user has already accepted in the offer prompt.
|
||||
///
|
||||
/// The consent happened in that prompt, so this does not ask again: it
|
||||
/// inspects the ticket and starts, falling back to the ordinary review sheet
|
||||
/// only when the destination is not usable and the user has to fix it.
|
||||
func receiveOffered(ticket: String) {
|
||||
let trimmed = ticket.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return messages.error(.resource(L10n.Error.invitationEmpty)) }
|
||||
state.ticket = trimmed
|
||||
state.method = .offer
|
||||
state.inspection = nil
|
||||
state.isInspecting = true
|
||||
Task {
|
||||
switch await repository.inspectTicket(trimmed) {
|
||||
case .success(let inspection):
|
||||
state.inspection = inspection
|
||||
state.isInspecting = false
|
||||
if state.canReceive(coreInitialized: coreState.isInitialized) {
|
||||
receive()
|
||||
} else {
|
||||
// Usually a missing or unwritable destination: show the review
|
||||
// sheet so the user can point it somewhere valid.
|
||||
state.isAcquisitionOpen = true
|
||||
}
|
||||
case .failure(let error):
|
||||
state.ticket = ""
|
||||
state.method = nil
|
||||
state.inspection = nil
|
||||
state.isInspecting = false
|
||||
messages.error(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func receive() {
|
||||
let current = state
|
||||
guard let folder = current.receiveFolder else { return }
|
||||
|
||||
@@ -351,7 +351,7 @@ final class SendModel: ObservableObject {
|
||||
files: current.selectedFiles,
|
||||
transferName: current.transferName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
senderName: current.senderName.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
accessPolicy: current.accessPolicy
|
||||
destination: .invitation(accessPolicy: current.accessPolicy)
|
||||
)
|
||||
switch result {
|
||||
case .success(let share):
|
||||
|
||||
@@ -174,6 +174,7 @@ final class SettingsModel: ObservableObject {
|
||||
loadDeviceInfo()
|
||||
}
|
||||
|
||||
|
||||
func selectSection(_ section: SettingsSection) {
|
||||
state.selectedSection = section
|
||||
if section == .about || section == .bugReport {
|
||||
|
||||
@@ -80,6 +80,11 @@ struct SettingsScreen: View {
|
||||
|
||||
@ViewBuilder
|
||||
private func sectionForm(_ section: SettingsSection) -> some View {
|
||||
settingsSectionForm(section)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func settingsSectionForm(_ section: SettingsSection) -> some View {
|
||||
let content = Form {
|
||||
SettingsSectionContent(model: model, section: section)
|
||||
}
|
||||
|
||||
@@ -42,9 +42,10 @@ private struct MacDeviceInfoProvider: DeviceInfoProvider {
|
||||
var size = 0
|
||||
sysctlbyname("hw.model", nil, &size, nil, 0)
|
||||
guard size > 0 else { return nil }
|
||||
var model = [CChar](repeating: 0, count: size)
|
||||
var model = [UInt8](repeating: 0, count: size)
|
||||
sysctlbyname("hw.model", &model, &size, nil, 0)
|
||||
return String(cString: model)
|
||||
// sysctl reports a NUL-terminated C string; drop the terminator(s).
|
||||
return String(decoding: model.prefix(while: { $0 != 0 }), as: UTF8.self)
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
@@ -59,12 +59,15 @@ struct IosFileSystemService: FileSystemService {
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
destination: ShareDestination
|
||||
) async -> Result<Share, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
}
|
||||
let sources = files.map { $0.toIosShareSource() }
|
||||
guard case .invitation(let accessPolicy) = destination else {
|
||||
return .failure(InvitationError.unsupportedOperation)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
|
||||
@@ -39,7 +39,7 @@ struct MacFileSystemService: FileSystemService {
|
||||
files: [PickedShareFile],
|
||||
transferName: String,
|
||||
senderName: String,
|
||||
accessPolicy: ShareAccessPolicy
|
||||
destination: ShareDestination
|
||||
) async -> Result<Share, Error> {
|
||||
guard !files.isEmpty else {
|
||||
return .failure(InvitationError.shareEmpty)
|
||||
@@ -63,6 +63,9 @@ struct MacFileSystemService: FileSystemService {
|
||||
let sources = files.map {
|
||||
ShareSource(kind: .path, value: $0.value, displayName: $0.displayName, isDirectory: $0.isDirectory)
|
||||
}
|
||||
guard case .invitation(let accessPolicy) = destination else {
|
||||
return .failure(InvitationError.unsupportedOperation)
|
||||
}
|
||||
return await repository.shareSources(
|
||||
sources, transferName: transferName, senderName: senderName, accessPolicy: accessPolicy
|
||||
)
|
||||
|
||||
@@ -70,6 +70,9 @@ struct SendPickers: ViewModifier {
|
||||
}
|
||||
}
|
||||
|
||||
/// File picker for "send to this device", reusing the share picker's selection
|
||||
/// handling so security-scoped bookmarks are captured the same way.
|
||||
|
||||
enum PickerSupport {
|
||||
static func receiveFolder(from url: URL) -> ReceiveFolder {
|
||||
#if os(iOS)
|
||||
@@ -129,4 +132,5 @@ extension View {
|
||||
func sendPickers(model: SendModel) -> some View {
|
||||
modifier(SendPickers(model: model))
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@ import VnidropCore
|
||||
|
||||
/// Maps technical failures to stable, user-facing catalog keys. Ported from
|
||||
/// `ui/feedback/UserFacingError.kt`. Never exposes raw `reason=` blobs.
|
||||
/// How an offered transfer ended without being accepted.
|
||||
enum OfferRefusal {
|
||||
case declined
|
||||
case noAnswer
|
||||
}
|
||||
|
||||
extension Error {
|
||||
func toUiText() -> UiText {
|
||||
if let invitation = self as? InvitationError {
|
||||
@@ -24,6 +30,10 @@ extension Error {
|
||||
return .resource(L10n.Error.storageFull)
|
||||
case .Network:
|
||||
return .resource(L10n.Error.network)
|
||||
case .DeviceUnavailable, .RelayPolicyIncompatible:
|
||||
return .resource(L10n.Error.network)
|
||||
case .OfferTimeout, .ProtocolIncompatible:
|
||||
return .resource(L10n.Error.transfer)
|
||||
case .Transfer(let reason):
|
||||
return transferUiText(reason)
|
||||
case .Repository:
|
||||
@@ -32,6 +42,12 @@ extension Error {
|
||||
return .resource(L10n.Error.generic)
|
||||
case .InvalidInput:
|
||||
return .resource(L10n.Error.invalidInput)
|
||||
case .InvalidTransition:
|
||||
return .resource(L10n.Error.invalidInput)
|
||||
case .SecureStorageLocked, .SecureStorageUnavailable:
|
||||
return .resource(L10n.Error.startingUp)
|
||||
case .SecureStorageMissing, .SecureStorageCorrupted:
|
||||
return .resource(L10n.Error.generic)
|
||||
case .Initialization(let reason):
|
||||
return initializationUiText(reason)
|
||||
case .Internal(let reason):
|
||||
@@ -57,14 +73,31 @@ extension Error {
|
||||
|| haystack.contains("user canceled")
|
||||
}
|
||||
|
||||
/// The other device answered, and the answer was no.
|
||||
///
|
||||
/// Not a failure of this device: the offer was delivered and a person
|
||||
/// declined it, so it is reported as information rather than an error.
|
||||
var offerRefusal: OfferRefusal? {
|
||||
let haystack = technicalDetail.lowercased()
|
||||
if haystack.contains("receiver-declined") || haystack.contains("declined-recently") {
|
||||
return .declined
|
||||
}
|
||||
if haystack.contains("no-response") { return .noAnswer }
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Prefers a `VnidropError` reason; else the localized description.
|
||||
var technicalDetail: String {
|
||||
if let vni = self as? VnidropError {
|
||||
switch vni {
|
||||
case .Initialization(let r), .Ticket(let r), .Filesystem(let r), .FilesystemPermission(let r),
|
||||
.DestinationExists(let r), .StorageFull(let r), .Network(let r),
|
||||
.DeviceUnavailable(let r), .OfferTimeout(let r),
|
||||
.RelayPolicyIncompatible(let r), .ProtocolIncompatible(let r),
|
||||
.Transfer(let r), .Permission(let r), .Repository(let r), .Cancelled(let r),
|
||||
.InvalidInput(let r), .Internal(let r):
|
||||
.InvalidInput(let r), .InvalidTransition(let r), .SecureStorageLocked(let r),
|
||||
.SecureStorageMissing(let r), .SecureStorageCorrupted(let r),
|
||||
.SecureStorageUnavailable(let r), .Internal(let r):
|
||||
return r
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,7 +30,6 @@ CONFIG="Release-Direct"
|
||||
APP_NAME="VniDrop"
|
||||
VERSION_RESOLVER="$REPO_ROOT/packaging/version/resolve-version.sh"
|
||||
VERSION_CONFIG_GENERATOR="$REPO_ROOT/packaging/version/generate-apple-xcconfig.sh"
|
||||
APP_CONFIG_GENERATOR="$SCRIPT_DIR/generate-appconfig.sh"
|
||||
|
||||
VERSION="$("$VERSION_RESOLVER" product)"
|
||||
export VNIDROP_BUILD_TIME_UTC="${VNIDROP_BUILD_TIME_UTC:-$(date -u +%Y%m%d%H%M%S)}"
|
||||
@@ -62,8 +61,6 @@ echo "==> Building Rust core (release)"
|
||||
CARGO_PROFILE_RELEASE_LTO=false "$SCRIPT_DIR/build-core.sh" release
|
||||
echo "==> Regenerating Xcode project"
|
||||
"$VERSION_CONFIG_GENERATOR" all
|
||||
# AppConfig.swift is gitignored codegen — a clean CI checkout has none.
|
||||
"$APP_CONFIG_GENERATOR"
|
||||
( cd "$APPLE_DIR" && xcodegen generate >/dev/null )
|
||||
|
||||
rm -rf "$BUILD_DIR" && mkdir -p "$BUILD_DIR" "$DIST_DIR"
|
||||
|
||||
@@ -54,8 +54,20 @@ src/
|
||||
receive.rs # receive, download, export, OutputSinkFile
|
||||
lifecycle.rs # cancel share, delete, status, access mode, shutdown
|
||||
provider.rs # provider messages, per-peer transfer progress
|
||||
saved_devices.rs # saved-device pairing, forget, block
|
||||
targeted.rs # targeted lifecycle and restart restoration
|
||||
targeted_create.rs # targeted import, offer, and sender approval
|
||||
targeted_receive.rs # targeted download, resume, and completion
|
||||
targeted_payload.rs # targeted blob fetch and export bridge
|
||||
targeted_reconciliation.rs # durable delivery and cleanup retries
|
||||
persistence.rs # AppDataStores / persistence open (domain stores)
|
||||
invitation/ # invitation-transfer domain store (type name: Repository)
|
||||
pairing_eligibility/ # eligibility service + store
|
||||
device_relationship/ # store + service + protocol (ALPN pairing)
|
||||
targeted_transfer/ # targeted protocol + store adapter
|
||||
blocked_devices.rs
|
||||
secure_secret/ # custody + platform credential adapters (+ metadata store)
|
||||
filesystem.rs # collect sources, atomic publish, path rules
|
||||
repository.rs # SQLite
|
||||
approval.rs / handshake.rs / ticket.rs / access_policy.rs / event_hub.rs
|
||||
api.rs # UniFFI records/enums
|
||||
tests/ # crate-private unit tests
|
||||
@@ -63,7 +75,8 @@ tests/ # public-API integration tests + support/
|
||||
```
|
||||
|
||||
**Do not** reassemble a single huge `runtime.rs`. Prefer new focused modules if a
|
||||
file approaches ~800 LoC of non-test code.
|
||||
file approaches ~800 LoC of non-test code. Do not add new `SqlitePool` call sites —
|
||||
open domain stores via `persistence::open_all`.
|
||||
|
||||
---
|
||||
|
||||
@@ -78,11 +91,14 @@ file approaches ~800 LoC of non-test code.
|
||||
4. **Cancel:** signal active-transfer oneshot **synchronously** before async DB
|
||||
work. Use existing `take_active_transfer` / facade cancel path. Do not reintroduce
|
||||
nested exclusive `Runtime::block_on` deadlocks.
|
||||
5. **No lock across await:** Clippy `await_holding_lock` fails CI.
|
||||
6. **ReceiveOutputSink:** after successful `start_file`, exactly one of
|
||||
5. **SecureSecretStore:** never call the sync store from an async task body.
|
||||
Linux Secret Service / zbus blocking nests Tokio `block_on`; `SecretCustody`
|
||||
must keep those calls on `spawn_blocking`.
|
||||
6. **No lock across await:** Clippy `await_holding_lock` fails CI.
|
||||
7. **ReceiveOutputSink:** after successful `start_file`, exactly one of
|
||||
`finish_file` or `abort_file` (see `OutputSinkFile` Drop).
|
||||
7. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
|
||||
8. Integration tests must use the **public** API + `tests/support/` only.
|
||||
8. **No-overwrite publish** for path receives (temp + hard link / exclusive rename).
|
||||
9. Integration tests must use the **public** API + `tests/support/` only.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -36,16 +36,61 @@ bytes through Kotlin memory.
|
||||
5. Only `vnd1:` VniDrop tickets are accepted. Raw iroh `BlobTicket` strings are
|
||||
rejected at parse time so receive always runs the approval handshake.
|
||||
|
||||
## Saved Devices And Targeted Transfers
|
||||
|
||||
1. A completed authenticated invitation transfer creates a short-lived pairing
|
||||
eligibility. Both devices must explicitly consent before either relationship
|
||||
becomes `Saved`; decline, forget, block, expiry, or replay cannot save it.
|
||||
2. A saved device's remote display name comes from authenticated transfer
|
||||
metadata and may refresh after later authenticated transfers. A local label
|
||||
is private to this installation, takes display precedence in the UI, and is
|
||||
preserved independently across restart and schema migration.
|
||||
3. `create_targeted_transfer` imports an immutable manifest and sends only a
|
||||
receiver-bound offer. Approval stores protected authorization in core custody;
|
||||
targeted work creates no invitation history, receiver approval request,
|
||||
invitation delivery receipt, received-artifact row, or pairing eligibility.
|
||||
4. Targeted blobs are default-deny and scoped to the intended saved endpoint.
|
||||
Knowing a transfer id, manifest hash, member hash, or blob address does not
|
||||
authorize another device to discover, approve, or download the payload.
|
||||
5. Approved receives use the same streaming, no-overwrite paths, and output-sink
|
||||
terminal-callback guarantees as invitation receives. Verified progress is
|
||||
monotonic and bounded; interrupted work and protected authorization resume
|
||||
after restart without another approval.
|
||||
6. Receiver completion is durable locally and acknowledged to the sender with
|
||||
idempotent retry. Targeted cancel and delete synchronously signal local active
|
||||
work before durable cleanup; forget and block revoke affected relationships
|
||||
and active targeted work within their core operation. All four durably deny
|
||||
reuse and perform idempotent payload/secret cleanup.
|
||||
7. Saved devices, relationships, pairing eligibility, and targeted transfers are
|
||||
shipped Rust core domains. Graduating the KMP and Apple Saved-device UI and
|
||||
their existing experimental preference gates is outside this release gate.
|
||||
|
||||
## Core States And Events
|
||||
|
||||
- Transfer statuses: `sharing`, `receiving`, `done`, `failed`, `cancelled`,
|
||||
`stopped`.
|
||||
- Main event phases: `endpoint`, `import`, `ticket`, `handshake`, `approval`,
|
||||
`access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`.
|
||||
`access`, `transfer`, `download`, `export`, `delivery`, `lifecycle`, `error`,
|
||||
`pairing`, and `targeted_transfer` (see catalog below).
|
||||
- Events are sent to `CoreEventSink` immediately and persisted through the event
|
||||
hub. `list_events` flushes queued persistence before reading SQLite.
|
||||
- `shutdown()` is idempotent and flushes events before stopping the router.
|
||||
|
||||
### Pairing and targeted-transfer event catalog
|
||||
|
||||
Treat every event as a wake-up: refresh durable state via list/get APIs.
|
||||
Targeted progress updates persist monotonic `verified_bytes`; event payloads are
|
||||
advisory and the durable targeted-transfer snapshot is authoritative.
|
||||
|
||||
**`pairing`:** `eligibility-available`, `eligibility-removed`,
|
||||
`relationship-changed`, `relationship-grant-rotated`, `saved-device-forgotten`,
|
||||
`device-blocked`.
|
||||
|
||||
**`targeted_transfer`:** `offer-received`, `approved`, `offer-declined`,
|
||||
`created`, `offering`, `awaiting-approval`, `connecting`, `transferring`,
|
||||
`progress`, `interrupted`, `completed`, `cancelled`, `failed`, `deleted`.
|
||||
Lifecycle payloads identify the durable row with `targeted_transfer_id`.
|
||||
|
||||
## Platform File Rules
|
||||
|
||||
- Desktop uses normal filesystem paths.
|
||||
|
||||
@@ -8,6 +8,9 @@ license = "Apache-2.0"
|
||||
name = "vnidrop"
|
||||
crate-type = ["cdylib", "staticlib", "rlib"]
|
||||
|
||||
[features]
|
||||
integration-test-store = []
|
||||
|
||||
[dependencies]
|
||||
anyhow = "1.0.102"
|
||||
async-channel = "2.5.0"
|
||||
@@ -16,6 +19,7 @@ blake3 = "1.8.3"
|
||||
data-encoding = "2.11.0"
|
||||
futures = "0.3"
|
||||
futures-lite = "2.6.1"
|
||||
getrandom = "0.3.4"
|
||||
iroh = "1.0.3"
|
||||
iroh-blobs = "0.103.0"
|
||||
irpc = "0.17.0"
|
||||
@@ -35,6 +39,20 @@ uniffi = { version = "=0.29.4", features = ["tokio"] }
|
||||
uuid = { version = "1.23.3", features = ["v4", "serde"] }
|
||||
walkdir = "2.5.0"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||
|
||||
[target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies]
|
||||
security-framework = { version = "3.7.0", features = ["OSX_10_15"] }
|
||||
|
||||
[target.'cfg(target_os = "android")'.dependencies]
|
||||
jni = "0.21.1"
|
||||
ndk-context = "0.1.1"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.61.2", features = ["Win32_Foundation", "Win32_Security_Cryptography", "Win32_Storage_FileSystem"] }
|
||||
|
||||
[dev-dependencies]
|
||||
iroh-relay = { version = "1.0.3", features = ["server"] }
|
||||
secret-service = { version = "5.1.0", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||
tempfile = "3.27.0"
|
||||
|
||||
@@ -29,13 +29,6 @@ impl AccessPolicy {
|
||||
self.modes.write().await.insert(transfer_id, mode);
|
||||
}
|
||||
|
||||
pub(crate) async fn allows_without_approval(&self, transfer_id: u64) -> bool {
|
||||
matches!(
|
||||
self.modes.read().await.get(&transfer_id),
|
||||
Some(TransferAccessMode::Public)
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_transfer(&self, transfer_id: u64) {
|
||||
self.modes.write().await.remove(&transfer_id);
|
||||
self.approved_sessions
|
||||
|
||||
@@ -10,6 +10,126 @@ use crate::util::{non_empty, now_ms};
|
||||
pub(crate) const MAX_CUSTOM_RELAYS: usize = 8;
|
||||
pub(crate) const MAX_RELAY_URL_BYTES: usize = 2_048;
|
||||
|
||||
/// Versions the saved-device domain seam and its wire protocols.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct SavedDeviceCapabilities {
|
||||
pub domain_contract_version: u16,
|
||||
pub relationship_protocol_version: u16,
|
||||
pub targeted_transfer_protocol_version: u16,
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
pub fn saved_device_capabilities() -> SavedDeviceCapabilities {
|
||||
SavedDeviceCapabilities {
|
||||
domain_contract_version: 1,
|
||||
relationship_protocol_version: 1,
|
||||
targeted_transfer_protocol_version: 3,
|
||||
}
|
||||
}
|
||||
|
||||
/// Public view of a single-use pairing window after a completed transfer.
|
||||
///
|
||||
/// The eligibility capability itself never crosses this boundary.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct PairingEligibilitySummary {
|
||||
pub peer_endpoint_id: String,
|
||||
pub remote_display_name: Option<String>,
|
||||
pub session_id: String,
|
||||
pub protocol_version: u16,
|
||||
pub created_at: i64,
|
||||
pub expires_at: i64,
|
||||
}
|
||||
|
||||
/// A remote VniDrop app-installation identity that completed mutual consent.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct SavedDevice {
|
||||
pub endpoint_id: String,
|
||||
pub local_label: Option<String>,
|
||||
pub remote_display_name: Option<String>,
|
||||
pub created_at: i64,
|
||||
pub last_authenticated_at: Option<i64>,
|
||||
}
|
||||
|
||||
/// Durable consent lifecycle for one remote app-installation identity.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||
pub enum DeviceRelationshipState {
|
||||
PendingOutgoing,
|
||||
PendingIncoming,
|
||||
Saved,
|
||||
Revoked,
|
||||
Blocked,
|
||||
}
|
||||
|
||||
/// Public relationship state; directional grant material remains core-private.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct DeviceRelationship {
|
||||
pub remote_endpoint_id: String,
|
||||
pub state: DeviceRelationshipState,
|
||||
pub generation: u64,
|
||||
pub minimum_protocol_version: u16,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Rust-owned lifecycle for an immutable one-sender, one-receiver transfer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||
pub enum TargetedTransferState {
|
||||
Preparing,
|
||||
Offering,
|
||||
AwaitingApproval,
|
||||
Approved,
|
||||
Connecting,
|
||||
Transferring,
|
||||
Interrupted,
|
||||
Completed,
|
||||
Declined,
|
||||
Cancelled,
|
||||
Failed,
|
||||
Deleted,
|
||||
}
|
||||
|
||||
/// Immutable recipient-bound transfer snapshot, separate from an ordinary share.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct TargetedTransfer {
|
||||
pub id: String,
|
||||
pub sender_endpoint_id: String,
|
||||
pub receiver_endpoint_id: String,
|
||||
pub manifest_id: String,
|
||||
pub transfer_name: String,
|
||||
pub file_count: u64,
|
||||
pub total_size: u64,
|
||||
/// Bytes verified so far; survives interruption for resume.
|
||||
pub verified_bytes: u64,
|
||||
pub state: TargetedTransferState,
|
||||
pub created_at: i64,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
/// Pre-approval offer summary. Deliberately omits any reusable share ticket.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct PendingTargetedOffer {
|
||||
pub transfer_id: String,
|
||||
pub sender_endpoint_id: String,
|
||||
pub receiver_endpoint_id: String,
|
||||
pub manifest_id: String,
|
||||
pub content_hash: String,
|
||||
pub transfer_name: String,
|
||||
pub file_count: u64,
|
||||
pub total_size: u64,
|
||||
pub protocol_version: u16,
|
||||
pub received_at: i64,
|
||||
}
|
||||
|
||||
/// Local approve/decline outcome for a pending targeted offer.
|
||||
///
|
||||
/// Authorization stays in core custody; callers only receive transfer ids.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||
pub enum TargetedOfferResponse {
|
||||
Approved { transfer_id: String },
|
||||
Declined,
|
||||
AlreadySettled { transfer_id: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, uniffi::Enum)]
|
||||
pub enum CoreRelayMode {
|
||||
Automatic,
|
||||
@@ -180,8 +300,22 @@ pub struct CoreLimits {
|
||||
pub max_metadata_bytes: u64,
|
||||
pub max_events: u64,
|
||||
pub max_pending_approvals: u64,
|
||||
/// Incoming pairing / targeted offers awaiting the local user's decision.
|
||||
pub max_pending_offers: u64,
|
||||
pub max_concurrent_transfers: u64,
|
||||
pub event_queue_capacity: u64,
|
||||
/// Cap on Saved + pending mutual-consent relationships.
|
||||
pub max_saved_devices: u64,
|
||||
/// Quiet period after a decline or repeated malformed control-plane traffic.
|
||||
pub identity_cooldown_ms: u64,
|
||||
/// Malformed control-plane messages from one identity before cooldown.
|
||||
pub malformed_strike_limit: u64,
|
||||
/// Pairing RPC / acknowledgement wait bound (milliseconds).
|
||||
pub pairing_timeout_ms: u64,
|
||||
/// Pre-approval offer decision wait bound (milliseconds).
|
||||
pub offer_timeout_ms: u64,
|
||||
/// Connection establishment bound for targeted transfers (milliseconds).
|
||||
pub connection_timeout_ms: u64,
|
||||
}
|
||||
|
||||
impl Default for CoreLimits {
|
||||
@@ -198,8 +332,17 @@ impl Default for CoreLimits {
|
||||
max_events: 500,
|
||||
// Bound handshake spam / notification pressure on the sender.
|
||||
max_pending_approvals: 64,
|
||||
// A pairing prompt needs the user in front of the device, so this
|
||||
// is far smaller than the handshake queue.
|
||||
max_pending_offers: 16,
|
||||
max_concurrent_transfers: 8,
|
||||
event_queue_capacity: 1_024,
|
||||
max_saved_devices: 256,
|
||||
identity_cooldown_ms: 60_000,
|
||||
malformed_strike_limit: 5,
|
||||
pairing_timeout_ms: 15_000,
|
||||
offer_timeout_ms: 120_000,
|
||||
connection_timeout_ms: 30_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,8 +358,15 @@ impl CoreLimits {
|
||||
("max_metadata_bytes", self.max_metadata_bytes),
|
||||
("max_events", self.max_events),
|
||||
("max_pending_approvals", self.max_pending_approvals),
|
||||
("max_pending_offers", self.max_pending_offers),
|
||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||
("event_queue_capacity", self.event_queue_capacity),
|
||||
("max_saved_devices", self.max_saved_devices),
|
||||
("identity_cooldown_ms", self.identity_cooldown_ms),
|
||||
("malformed_strike_limit", self.malformed_strike_limit),
|
||||
("pairing_timeout_ms", self.pairing_timeout_ms),
|
||||
("offer_timeout_ms", self.offer_timeout_ms),
|
||||
("connection_timeout_ms", self.connection_timeout_ms),
|
||||
];
|
||||
for (name, value) in positive {
|
||||
if value == 0 {
|
||||
@@ -225,8 +375,11 @@ impl CoreLimits {
|
||||
}
|
||||
for (name, value) in [
|
||||
("max_pending_approvals", self.max_pending_approvals),
|
||||
("max_pending_offers", self.max_pending_offers),
|
||||
("max_concurrent_transfers", self.max_concurrent_transfers),
|
||||
("event_queue_capacity", self.event_queue_capacity),
|
||||
("max_saved_devices", self.max_saved_devices),
|
||||
("malformed_strike_limit", self.malformed_strike_limit),
|
||||
] {
|
||||
usize::try_from(value)
|
||||
.with_context(|| format!("core limit {name} exceeds platform capacity"))?;
|
||||
@@ -263,6 +416,8 @@ pub fn default_core_limits() -> CoreLimits {
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)]
|
||||
pub struct CoreEvent {
|
||||
pub id: String,
|
||||
/// Monotonic per-process revision for at-least-once delivery deduplication.
|
||||
pub revision: u64,
|
||||
pub timestamp: i64,
|
||||
pub scope: String,
|
||||
pub transfer_id: Option<u64>,
|
||||
|
||||
@@ -6,13 +6,15 @@ use tokio::sync::{oneshot, Mutex};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
access_policy::{AccessDecision, AccessPolicy, APPROVAL_SESSION_TTL_MS},
|
||||
blocked_devices::BlockStore,
|
||||
event_hub::EventHub,
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse,
|
||||
RequestTransfer,
|
||||
},
|
||||
repository::{ReceiverRequestInsert, Repository},
|
||||
invitation::{ReceiverRequestInsert, Repository},
|
||||
pairing_eligibility::PairingEligibilityService,
|
||||
transfer_state::ReceiverRequestStatus,
|
||||
util::now_ms,
|
||||
};
|
||||
@@ -30,11 +32,13 @@ pub(crate) struct ApprovalDecision {
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct ApprovalService {
|
||||
repository: Repository,
|
||||
blocked: BlockStore,
|
||||
event_hub: Arc<EventHub>,
|
||||
access_policy: Arc<AccessPolicy>,
|
||||
pending: Arc<Mutex<HashMap<String, oneshot::Sender<ApprovalDecision>>>>,
|
||||
max_pending: usize,
|
||||
max_metadata_bytes: u64,
|
||||
pairing_eligibility: Option<PairingEligibilityService>,
|
||||
}
|
||||
|
||||
impl ApprovalService {
|
||||
@@ -54,7 +58,20 @@ impl ApprovalService {
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(()) => {
|
||||
Ok(remote_display_name) => {
|
||||
if let Some(eligibility) = &self.pairing_eligibility {
|
||||
if let Err(error) = eligibility
|
||||
.activate_after_completed_transfer(
|
||||
&remote_endpoint_id,
|
||||
remote_display_name.as_deref(),
|
||||
&receipt.request_id,
|
||||
&receipt.token,
|
||||
)
|
||||
.await
|
||||
{
|
||||
tracing::warn!(%error, "failed to activate pairing eligibility after delivery");
|
||||
}
|
||||
}
|
||||
self.event_hub.emit_transfer(
|
||||
receipt.transfer_id,
|
||||
"send",
|
||||
@@ -115,18 +132,22 @@ impl ApprovalService {
|
||||
|
||||
pub(crate) fn new(
|
||||
repository: Repository,
|
||||
blocked: BlockStore,
|
||||
event_hub: Arc<EventHub>,
|
||||
access_policy: Arc<AccessPolicy>,
|
||||
max_pending: usize,
|
||||
max_metadata_bytes: u64,
|
||||
pairing_eligibility: Option<PairingEligibilityService>,
|
||||
) -> Self {
|
||||
Self {
|
||||
repository,
|
||||
blocked,
|
||||
event_hub,
|
||||
access_policy,
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
max_pending,
|
||||
max_metadata_bytes,
|
||||
pairing_eligibility,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -162,6 +183,17 @@ impl ApprovalService {
|
||||
remote_endpoint_id: String,
|
||||
request: RequestTransfer,
|
||||
) -> HandshakeResponse {
|
||||
if self
|
||||
.blocked
|
||||
.is_blocked(&remote_endpoint_id)
|
||||
.await
|
||||
.unwrap_or(true)
|
||||
{
|
||||
// Indistinguishable from other refusals so probing cannot detect blocks.
|
||||
return self
|
||||
.deny(request.transfer_id, remote_endpoint_id, "not-accepted")
|
||||
.await;
|
||||
}
|
||||
let metadata_values = [
|
||||
request.transfer_hash.as_str(),
|
||||
request.transfer_name.as_str(),
|
||||
@@ -198,10 +230,15 @@ impl ApprovalService {
|
||||
.await
|
||||
{
|
||||
Ok(true) => {
|
||||
// An existing access session means this endpoint was already
|
||||
// authorised: either the share is public, or the sender pushed
|
||||
// this transfer to them. Prompting again would ask the sender
|
||||
// to approve a transfer they themselves initiated.
|
||||
if self
|
||||
.access_policy
|
||||
.allows_without_approval(request.transfer_id)
|
||||
.decide(request.transfer_id, Some(&remote_endpoint_id))
|
||||
.await
|
||||
== AccessDecision::Allow
|
||||
{
|
||||
self.allow_without_sender_decision(remote_endpoint_id, request)
|
||||
.await
|
||||
@@ -277,6 +314,11 @@ impl ApprovalService {
|
||||
request_id,
|
||||
token,
|
||||
expires_at,
|
||||
sender_name: self
|
||||
.repository
|
||||
.send_sender_name(request.transfer_id, &request.transfer_hash)
|
||||
.await
|
||||
.unwrap_or(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,6 +414,11 @@ impl ApprovalService {
|
||||
request_id: decision.request_id,
|
||||
token,
|
||||
expires_at,
|
||||
sender_name: self
|
||||
.repository
|
||||
.send_sender_name(request.transfer_id, &request.transfer_hash)
|
||||
.await
|
||||
.unwrap_or(None),
|
||||
}
|
||||
}
|
||||
Ok(Ok(decision)) => {
|
||||
|
||||
68
crates/vnidrop/src/blocked_devices.rs
Normal file
68
crates/vnidrop/src/blocked_devices.rs
Normal file
@@ -0,0 +1,68 @@
|
||||
//! Identity-wide deny list for saved-device and invitation traffic.
|
||||
|
||||
use anyhow::Result;
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS blocked_endpoints (
|
||||
endpoint_id TEXT PRIMARY KEY,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Durable deny records for one app-data profile.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct BlockStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl BlockStore {
|
||||
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub(crate) async fn block_endpoint(&self, endpoint_id: &str, now_ms: i64) -> Result<()> {
|
||||
sqlx::query(
|
||||
"INSERT INTO blocked_endpoints (endpoint_id, created_at) VALUES (?1, ?2)
|
||||
ON CONFLICT(endpoint_id) DO NOTHING",
|
||||
)
|
||||
.bind(endpoint_id)
|
||||
.bind(now_ms)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn unblock_endpoint(&self, endpoint_id: &str) -> Result<()> {
|
||||
sqlx::query("DELETE FROM blocked_endpoints WHERE endpoint_id = ?1")
|
||||
.bind(endpoint_id)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn is_blocked(&self, endpoint_id: &str) -> Result<bool> {
|
||||
let row =
|
||||
sqlx::query("SELECT EXISTS(SELECT 1 FROM blocked_endpoints WHERE endpoint_id = ?1)")
|
||||
.bind(endpoint_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?;
|
||||
Ok(row.get::<i64, _>(0) == 1)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_blocked(&self) -> Result<Vec<String>> {
|
||||
let rows =
|
||||
sqlx::query("SELECT endpoint_id FROM blocked_endpoints ORDER BY created_at DESC")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(rows.into_iter().map(|row| row.get(0)).collect())
|
||||
}
|
||||
}
|
||||
280
crates/vnidrop/src/control_plane.rs
Normal file
280
crates/vnidrop/src/control_plane.rs
Normal file
@@ -0,0 +1,280 @@
|
||||
//! Saved-device control-plane hardening.
|
||||
//!
|
||||
//! Bounds hostile / noisy peers without imposing quotas on transfers the
|
||||
//! receiver has already accepted.
|
||||
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, Mutex},
|
||||
};
|
||||
|
||||
use data_encoding::HEXLOWER;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::util::now_ms;
|
||||
|
||||
/// Per-identity quiet period after declines or repeated malformed traffic.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct IdentityCooldown {
|
||||
inner: Arc<Mutex<CooldownInner>>,
|
||||
cooldown_ms: i64,
|
||||
strike_limit: u32,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct CooldownInner {
|
||||
until: HashMap<String, i64>,
|
||||
strikes: HashMap<String, u32>,
|
||||
}
|
||||
|
||||
impl IdentityCooldown {
|
||||
pub(crate) fn new(cooldown_ms: u64, strike_limit: u64) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(CooldownInner::default())),
|
||||
cooldown_ms: cooldown_ms as i64,
|
||||
strike_limit: strike_limit as u32,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_cooling(&self, identity: &str) -> bool {
|
||||
let now = now_ms();
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.until.retain(|_, until| *until > now);
|
||||
state.until.contains_key(identity)
|
||||
}
|
||||
|
||||
pub(crate) fn record_decline(&self, identity: &str) {
|
||||
let until = now_ms() + self.cooldown_ms;
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.until.insert(identity.to_string(), until);
|
||||
state.strikes.remove(identity);
|
||||
}
|
||||
|
||||
/// Count a malformed / spoofed / ineligible control-plane message.
|
||||
///
|
||||
/// Trips cooldown once the strike limit is reached. Returns whether the
|
||||
/// identity is now cooling (including an already-active cooldown).
|
||||
pub(crate) fn record_malformed(&self, identity: &str) -> bool {
|
||||
if self.is_cooling(identity) {
|
||||
return true;
|
||||
}
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let strikes = state.strikes.entry(identity.to_string()).or_insert(0);
|
||||
*strikes = strikes.saturating_add(1);
|
||||
if *strikes >= self.strike_limit {
|
||||
state
|
||||
.until
|
||||
.insert(identity.to_string(), now_ms() + self.cooldown_ms);
|
||||
state.strikes.remove(identity);
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub(crate) fn clear_strikes(&self, identity: &str) {
|
||||
let mut state = self
|
||||
.inner
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
state.strikes.remove(identity);
|
||||
}
|
||||
}
|
||||
|
||||
const REDACTED: &str = "[redacted]";
|
||||
|
||||
/// Keys whose values must never appear in production events / diagnostics.
|
||||
fn is_sensitive_key(key: &str) -> bool {
|
||||
matches!(
|
||||
key,
|
||||
"endpoint_id"
|
||||
| "peer_endpoint_id"
|
||||
| "sender_endpoint_id"
|
||||
| "receiver_endpoint_id"
|
||||
| "from_endpoint_id"
|
||||
| "remote_endpoint_id"
|
||||
| "local_endpoint_id"
|
||||
| "ticket"
|
||||
| "blob_ticket"
|
||||
| "authorization"
|
||||
| "capability"
|
||||
| "secret"
|
||||
| "grant"
|
||||
| "grant_id"
|
||||
| "proof"
|
||||
| "mac"
|
||||
| "filename"
|
||||
| "file_name"
|
||||
| "path"
|
||||
| "display_name"
|
||||
| "transfer_name"
|
||||
| "sender_display_name"
|
||||
| "remote_display_name"
|
||||
| "address"
|
||||
| "addrs"
|
||||
| "relay_url"
|
||||
| "relay_urls"
|
||||
)
|
||||
}
|
||||
|
||||
/// Stable fingerprint so diagnostics can correlate without leaking raw values.
|
||||
pub(crate) fn fingerprint(value: &str) -> String {
|
||||
let digest = blake3::hash(value.as_bytes());
|
||||
let hex = HEXLOWER.encode(digest.as_bytes());
|
||||
format!("<redacted:{}>", &hex[..8])
|
||||
}
|
||||
|
||||
pub(crate) fn redact_json(value: Value) -> Value {
|
||||
match value {
|
||||
Value::Object(map) => Value::Object(redact_object(map)),
|
||||
Value::Array(items) => Value::Array(items.into_iter().map(redact_json).collect()),
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn redact_object(map: Map<String, Value>) -> Map<String, Value> {
|
||||
map.into_iter()
|
||||
.map(|(key, value)| {
|
||||
if is_sensitive_key(&key) {
|
||||
let redacted = match value {
|
||||
Value::String(raw) if !raw.is_empty() => Value::String(fingerprint(&raw)),
|
||||
Value::Null => Value::Null,
|
||||
_ => Value::String(REDACTED.to_string()),
|
||||
};
|
||||
(key, redacted)
|
||||
} else {
|
||||
(key, redact_json(value))
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Scrub ticket-like and long opaque blobs from free-form error / log text.
|
||||
pub(crate) fn redact_text(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut rest = input;
|
||||
while let Some(idx) = rest.find("vnd1:") {
|
||||
out.push_str(&rest[..idx]);
|
||||
out.push_str(REDACTED);
|
||||
rest = &rest[idx + 5..];
|
||||
// Skip the remainder of the ticket token (non-whitespace).
|
||||
let end = rest
|
||||
.find(|c: char| c.is_whitespace() || c == '"' || c == '\'')
|
||||
.unwrap_or(rest.len());
|
||||
rest = &rest[end..];
|
||||
}
|
||||
out.push_str(rest);
|
||||
// Collapse long hex runs that look like endpoint ids / grant material.
|
||||
collapse_long_hex(&out)
|
||||
}
|
||||
|
||||
fn collapse_long_hex(input: &str) -> String {
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let bytes = input.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i].is_ascii_hexdigit() {
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
|
||||
i += 1;
|
||||
}
|
||||
let len = i - start;
|
||||
if len >= 32 {
|
||||
out.push_str(REDACTED);
|
||||
} else {
|
||||
out.push_str(&input[start..i]);
|
||||
}
|
||||
} else {
|
||||
out.push(bytes[i] as char);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
/// Documented non-enforcement: accepted transfers have no per-device quota.
|
||||
const ACCEPTED_TRANSFER_QUOTA_FIELDS: &[&str] = &[
|
||||
"max_per_device_files",
|
||||
"max_per_device_bytes",
|
||||
"max_per_device_bandwidth",
|
||||
"max_per_device_transfers",
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn cooldown_trips_after_strike_limit_and_isolates_identities() {
|
||||
let guard = IdentityCooldown::new(60_000, 3);
|
||||
assert!(!guard.record_malformed("a"));
|
||||
assert!(!guard.record_malformed("a"));
|
||||
assert!(guard.record_malformed("a"));
|
||||
assert!(guard.is_cooling("a"));
|
||||
assert!(!guard.is_cooling("b"));
|
||||
guard.record_decline("b");
|
||||
assert!(guard.is_cooling("b"));
|
||||
assert!(guard.is_cooling("a"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redaction_scrubs_sensitive_event_fields() {
|
||||
let raw = json!({
|
||||
"transfer_id": "ok-to-keep",
|
||||
"sender_endpoint_id": "abc123endpointid000000000000000000000000000000000000000000000000",
|
||||
"transfer_name": "secret.pdf",
|
||||
"ticket": "vnd1:deadbeef",
|
||||
"file_count": 2,
|
||||
"nested": { "capability": [1, 2, 3], "state": "saved" }
|
||||
});
|
||||
let redacted = redact_json(raw);
|
||||
let obj = redacted.as_object().unwrap();
|
||||
assert_eq!(obj.get("transfer_id").unwrap(), "ok-to-keep");
|
||||
assert_eq!(obj.get("file_count").unwrap(), 2);
|
||||
let sender = obj.get("sender_endpoint_id").unwrap().as_str().unwrap();
|
||||
assert!(sender.starts_with("<redacted:"));
|
||||
assert!(!sender.contains("abc123"));
|
||||
let name = obj.get("transfer_name").unwrap().as_str().unwrap();
|
||||
assert!(name.starts_with("<redacted:"));
|
||||
assert!(!name.contains("secret"));
|
||||
assert!(obj
|
||||
.get("ticket")
|
||||
.unwrap()
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.starts_with("<redacted:"));
|
||||
let nested = obj.get("nested").unwrap().as_object().unwrap();
|
||||
assert_eq!(nested.get("capability").unwrap(), REDACTED);
|
||||
assert_eq!(nested.get("state").unwrap(), "saved");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn redact_text_strips_tickets_and_long_hex() {
|
||||
let text = "ticket vnd1:abcDEF123 and id 0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
|
||||
let scrubbed = redact_text(text);
|
||||
assert!(!scrubbed.contains("vnd1:"));
|
||||
assert!(!scrubbed.contains("0123456789abcdef"));
|
||||
assert!(scrubbed.contains(REDACTED));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accepted_transfer_quota_fields_are_not_core_limits() {
|
||||
// Control-plane hardening must not invent per-device accepted-transfer quotas.
|
||||
let encoded = serde_json::to_string(&crate::api::CoreLimits::default()).unwrap();
|
||||
for field in ACCEPTED_TRANSFER_QUOTA_FIELDS {
|
||||
assert!(
|
||||
!encoded.contains(field),
|
||||
"CoreLimits must not enforce {field}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
134
crates/vnidrop/src/device_relationship/crypto.rs
Normal file
134
crates/vnidrop/src/device_relationship/crypto.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
//! Relationship-grant possession proofs.
|
||||
|
||||
use crate::{
|
||||
error::VnidropError,
|
||||
grant::{Challenge, GrantId, GrantProof, GrantSecret},
|
||||
secure_secret::SecretMaterial,
|
||||
};
|
||||
|
||||
const RELATIONSHIP_GRANT_CONTEXT: &[u8] = b"vnidrop-relationship-grant-v1";
|
||||
|
||||
pub(super) fn encode_relationship_grant_secret(
|
||||
secret: &GrantSecret,
|
||||
) -> Result<SecretMaterial, VnidropError> {
|
||||
// Custody stores only the 32-byte secret; issuer/holder/generation/protocol
|
||||
// bindings live in the relationship row and are enforced at prove/verify time.
|
||||
SecretMaterial::new(secret.as_bytes().to_vec())
|
||||
}
|
||||
|
||||
pub(super) fn secret_from_material(material: &SecretMaterial) -> Result<GrantSecret, VnidropError> {
|
||||
let bytes: [u8; 32] =
|
||||
material
|
||||
.to_vec()
|
||||
.try_into()
|
||||
.map_err(|_| VnidropError::SecureStorageCorrupted {
|
||||
reason: "relationship grant secret has invalid length".to_string(),
|
||||
})?;
|
||||
Ok(GrantSecret::from_bytes(bytes))
|
||||
}
|
||||
|
||||
pub(super) fn prove_relationship_grant(
|
||||
grant_id: GrantId,
|
||||
secret: &GrantSecret,
|
||||
challenge: &Challenge,
|
||||
issuer: &str,
|
||||
holder: &str,
|
||||
generation: u64,
|
||||
protocol_version: u16,
|
||||
) -> GrantProof {
|
||||
let mac = relationship_mac(
|
||||
secret,
|
||||
challenge,
|
||||
issuer,
|
||||
holder,
|
||||
generation,
|
||||
protocol_version,
|
||||
);
|
||||
GrantProof::from_parts(grant_id, mac)
|
||||
}
|
||||
|
||||
pub(super) fn verify_relationship_grant(
|
||||
secret: &GrantSecret,
|
||||
proof: &GrantProof,
|
||||
challenge: &Challenge,
|
||||
issuer: &str,
|
||||
holder: &str,
|
||||
generation: u64,
|
||||
protocol_version: u16,
|
||||
) -> Result<(), &'static str> {
|
||||
let expected = relationship_mac(
|
||||
secret,
|
||||
challenge,
|
||||
issuer,
|
||||
holder,
|
||||
generation,
|
||||
protocol_version,
|
||||
);
|
||||
if blake3::Hash::from_bytes(expected) != blake3::Hash::from_bytes(*proof.mac()) {
|
||||
return Err("bad relationship grant proof");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn relationship_mac(
|
||||
secret: &GrantSecret,
|
||||
challenge: &Challenge,
|
||||
issuer: &str,
|
||||
holder: &str,
|
||||
generation: u64,
|
||||
protocol_version: u16,
|
||||
) -> [u8; 32] {
|
||||
let mut hasher = blake3::Hasher::new_keyed(secret.as_bytes());
|
||||
hasher.update(RELATIONSHIP_GRANT_CONTEXT);
|
||||
hasher.update(challenge.as_bytes());
|
||||
hasher.update(&(issuer.len() as u64).to_le_bytes());
|
||||
hasher.update(issuer.as_bytes());
|
||||
hasher.update(&(holder.len() as u64).to_le_bytes());
|
||||
hasher.update(holder.as_bytes());
|
||||
hasher.update(&generation.to_le_bytes());
|
||||
hasher.update(&protocol_version.to_le_bytes());
|
||||
*hasher.finalize().as_bytes()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod grant_vectors {
|
||||
use super::*;
|
||||
use crate::api::saved_device_capabilities;
|
||||
use data_encoding::HEXLOWER;
|
||||
|
||||
#[test]
|
||||
fn relationship_grant_proof_vectors_are_stable() {
|
||||
let secret =
|
||||
GrantSecret::decode("0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef")
|
||||
.unwrap();
|
||||
let grant_id = GrantId::decode("0123456789abcdef0123456789abcdef").unwrap();
|
||||
let challenge = Challenge::from_bytes([9u8; 32]);
|
||||
let protocol = saved_device_capabilities().relationship_protocol_version;
|
||||
let proof = prove_relationship_grant(
|
||||
grant_id, &secret, &challenge, "issuer", "holder", 1, protocol,
|
||||
);
|
||||
// Binding and replay resistance: wrong holder or challenge must fail.
|
||||
verify_relationship_grant(&secret, &proof, &challenge, "issuer", "holder", 1, protocol)
|
||||
.unwrap();
|
||||
let mac_hex = HEXLOWER.encode(proof.mac());
|
||||
assert_eq!(
|
||||
mac_hex,
|
||||
"e6cc2641183b84fae9e3805761961d69e09d25a1f8ceeaeede952774ddd95d6b"
|
||||
);
|
||||
assert!(verify_relationship_grant(
|
||||
&secret, &proof, &challenge, "issuer", "other", 1, protocol,
|
||||
)
|
||||
.is_err());
|
||||
let other_challenge = Challenge::from_bytes([8u8; 32]);
|
||||
assert!(verify_relationship_grant(
|
||||
&secret,
|
||||
&proof,
|
||||
&other_challenge,
|
||||
"issuer",
|
||||
"holder",
|
||||
1,
|
||||
protocol,
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
243
crates/vnidrop/src/device_relationship/lifecycle.rs
Normal file
243
crates/vnidrop/src/device_relationship/lifecycle.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
//! Forget, block, grant rotation, and minimal revocation tombstones.
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
use super::{store::RelationshipRow, DeviceRelationshipService};
|
||||
use crate::{
|
||||
api::DeviceRelationshipState, error::VnidropError, grant::GrantRejection,
|
||||
secure_secret::SecretHandle,
|
||||
};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct ForgetOutcome {
|
||||
pub(crate) had_relationship: bool,
|
||||
pub(crate) generation: Option<u64>,
|
||||
pub(crate) issued_grant_id: Option<String>,
|
||||
}
|
||||
|
||||
impl DeviceRelationshipService {
|
||||
/// Forget a saved (or pending) device: revoke locally first, clean secrets,
|
||||
/// then the caller sends a best-effort remote notice. Invitation-domain
|
||||
/// transfers are untouched.
|
||||
pub(crate) async fn forget(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
) -> Result<ForgetOutcome, VnidropError> {
|
||||
let peer_lock = self.lock_peer(&peer_endpoint_id).await;
|
||||
let _guard = peer_lock.lock().await;
|
||||
|
||||
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
|
||||
self.eligibility.remove_for_peer(&peer_endpoint_id).await?;
|
||||
return Ok(ForgetOutcome {
|
||||
had_relationship: false,
|
||||
generation: None,
|
||||
issued_grant_id: None,
|
||||
});
|
||||
};
|
||||
|
||||
let issued_grant_id = row.issued_grant_id.clone();
|
||||
let generation = row.generation;
|
||||
self.tombstone_generation(&peer_endpoint_id, &row).await?;
|
||||
self.delete_relationship(&peer_endpoint_id).await?;
|
||||
self.eligibility.remove_for_peer(&peer_endpoint_id).await?;
|
||||
self.emit_changed(&peer_endpoint_id, DeviceRelationshipState::Revoked);
|
||||
drop(_guard);
|
||||
|
||||
Ok(ForgetOutcome {
|
||||
had_relationship: true,
|
||||
generation: Some(generation),
|
||||
issued_grant_id,
|
||||
})
|
||||
}
|
||||
|
||||
/// Identity-wide block: revoke relationship grants, keep deny + tombstones.
|
||||
/// Caller owns the durable deny record (`blocked_endpoints`).
|
||||
pub(crate) async fn revoke_for_block(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
let peer_lock = self.lock_peer(peer_endpoint_id).await;
|
||||
let _guard = peer_lock.lock().await;
|
||||
|
||||
if let Some(row) = self.find_row(peer_endpoint_id).await? {
|
||||
self.tombstone_generation(peer_endpoint_id, &row).await?;
|
||||
self.delete_relationship(peer_endpoint_id).await?;
|
||||
}
|
||||
self.eligibility.remove_for_peer(peer_endpoint_id).await?;
|
||||
self.emit_changed(peer_endpoint_id, DeviceRelationshipState::Blocked);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Activate a replacement grant: invalidate the prior generation first, then
|
||||
/// mint exactly one new active generation for the issued direction.
|
||||
pub(crate) async fn rotate_relationship_grant(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
) -> Result<u64, VnidropError> {
|
||||
let peer_lock = self.lock_peer(&peer_endpoint_id).await;
|
||||
let _guard = peer_lock.lock().await;
|
||||
|
||||
let Some(row) = self.find_row(&peer_endpoint_id).await? else {
|
||||
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||
"no relationship to rotate"
|
||||
)));
|
||||
};
|
||||
if row.state != DeviceRelationshipState::Saved {
|
||||
return Err(VnidropError::invalid_input(anyhow::anyhow!(
|
||||
"only saved relationships can rotate grants"
|
||||
)));
|
||||
}
|
||||
|
||||
// Invalidate first: tombstone + secret removal before the new generation
|
||||
// becomes active, so a concurrent presenter cannot race past revocation.
|
||||
self.tombstone_generation(&peer_endpoint_id, &row).await?;
|
||||
self.clear_grant_secrets(&row).await?;
|
||||
|
||||
let new_generation = row.generation.saturating_add(1);
|
||||
self.store
|
||||
.begin_grant_rotation(&peer_endpoint_id, new_generation)
|
||||
.await?;
|
||||
|
||||
let _wire = self
|
||||
.mint_and_store_issued_grant(
|
||||
&peer_endpoint_id,
|
||||
new_generation,
|
||||
row.minimum_protocol_version,
|
||||
)
|
||||
.await?;
|
||||
|
||||
self.event_hub.emit_endpoint(
|
||||
"pairing",
|
||||
"relationship-grant-rotated",
|
||||
json!({
|
||||
"peer_endpoint_id": peer_endpoint_id,
|
||||
"generation": new_generation,
|
||||
}),
|
||||
);
|
||||
Ok(new_generation)
|
||||
}
|
||||
|
||||
/// Reject a presented generation when it is tombstoned or not the active one.
|
||||
pub(crate) async fn reject_replayed_generation(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
generation: u64,
|
||||
_grant_id: Option<&str>,
|
||||
) -> Result<(), GrantRejection> {
|
||||
if self
|
||||
.find_tombstone(peer_endpoint_id, generation)
|
||||
.await
|
||||
.map_err(|_| GrantRejection::Unknown)?
|
||||
.is_some()
|
||||
{
|
||||
return Err(GrantRejection::Revoked);
|
||||
}
|
||||
|
||||
let Some(row) = self
|
||||
.find_row(peer_endpoint_id)
|
||||
.await
|
||||
.map_err(|_| GrantRejection::Unknown)?
|
||||
else {
|
||||
return Err(GrantRejection::Unknown);
|
||||
};
|
||||
// Pending pairing and Saved both use the active row generation; only a
|
||||
// mismatch (or tombstone above) means the presenter is replaying.
|
||||
match row.state {
|
||||
DeviceRelationshipState::PendingOutgoing
|
||||
| DeviceRelationshipState::PendingIncoming
|
||||
| DeviceRelationshipState::Saved => {
|
||||
if row.generation != generation {
|
||||
return Err(GrantRejection::Unknown);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
DeviceRelationshipState::Revoked | DeviceRelationshipState::Blocked => {
|
||||
Err(GrantRejection::Unknown)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn list_tombstones(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Vec<super::store::GenerationTombstone>, VnidropError> {
|
||||
self.store.list_tombstones(peer_endpoint_id).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn issued_grant_snapshot(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Option<(u64, String)>, VnidropError> {
|
||||
let Some(row) = self.find_row(peer_endpoint_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(grant_id) = row.issued_grant_id else {
|
||||
return Ok(None);
|
||||
};
|
||||
Ok(Some((row.generation, grant_id)))
|
||||
}
|
||||
|
||||
async fn tombstone_generation(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
row: &RelationshipRow,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.store.insert_tombstone(peer_endpoint_id, row).await
|
||||
}
|
||||
|
||||
async fn find_tombstone(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
generation: u64,
|
||||
) -> Result<Option<super::store::GenerationTombstone>, VnidropError> {
|
||||
self.store
|
||||
.find_tombstone(peer_endpoint_id, generation)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn clear_grant_secrets(&self, row: &RelationshipRow) -> Result<(), VnidropError> {
|
||||
let Some(custody) = &self.custody else {
|
||||
return Ok(());
|
||||
};
|
||||
for handle in [&row.issued_grant_handle, &row.held_grant_handle]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
let _ = custody
|
||||
.remove(&SecretHandle::from_stored(handle.clone()))
|
||||
.await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Apply a best-effort remote revocation notice from a peer.
|
||||
pub(crate) async fn handle_remote_revoke(
|
||||
&self,
|
||||
remote_endpoint_id: String,
|
||||
generation: u64,
|
||||
) -> bool {
|
||||
let peer_lock = self.lock_peer(&remote_endpoint_id).await;
|
||||
let _guard = peer_lock.lock().await;
|
||||
let Ok(Some(row)) = self.find_row(&remote_endpoint_id).await else {
|
||||
return true;
|
||||
};
|
||||
if row.generation != generation
|
||||
&& generation != 0
|
||||
&& self
|
||||
.find_tombstone(&remote_endpoint_id, generation)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.is_some()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
let _ = self.tombstone_generation(&remote_endpoint_id, &row).await;
|
||||
let _ = self.delete_relationship(&remote_endpoint_id).await;
|
||||
let _ = self.eligibility.remove_for_peer(&remote_endpoint_id).await;
|
||||
self.emit_changed(&remote_endpoint_id, DeviceRelationshipState::Revoked);
|
||||
true
|
||||
}
|
||||
}
|
||||
16
crates/vnidrop/src/device_relationship/mod.rs
Normal file
16
crates/vnidrop/src/device_relationship/mod.rs
Normal file
@@ -0,0 +1,16 @@
|
||||
//! Saved-device mutual-consent relationships.
|
||||
//!
|
||||
//! Pending outgoing/incoming states, directional grants bound to relationship
|
||||
//! generation, and Saved only after mutual acknowledgement.
|
||||
|
||||
mod crypto;
|
||||
mod lifecycle;
|
||||
mod protocol;
|
||||
mod service;
|
||||
mod store;
|
||||
|
||||
pub(crate) use protocol::{RelationshipProtocol, WireProof};
|
||||
pub(crate) use service::DeviceRelationshipService;
|
||||
pub(crate) use store::DeviceRelationshipStore;
|
||||
#[cfg(test)]
|
||||
pub(crate) use store::GenerationTombstone;
|
||||
226
crates/vnidrop/src/device_relationship/protocol.rs
Normal file
226
crates/vnidrop/src/device_relationship/protocol.rs
Normal file
@@ -0,0 +1,226 @@
|
||||
//! Iroh ALPN handler and client for mutual-consent pairing.
|
||||
//!
|
||||
//! Wire messages and transport live here; durable state and grant custody stay on
|
||||
//! [`super::service::DeviceRelationshipService`].
|
||||
|
||||
use std::{fmt, sync::Arc};
|
||||
|
||||
use iroh::{
|
||||
endpoint::Connection,
|
||||
protocol::{AcceptError, ProtocolHandler},
|
||||
Endpoint, EndpointAddr,
|
||||
};
|
||||
use irpc::{channel::oneshot, rpc_requests, Client, WithChannels};
|
||||
use irpc_iroh::{read_request, IrohLazyRemoteConnection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use super::DeviceRelationshipService;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RelationshipProtocol {
|
||||
relationships: Arc<DeviceRelationshipService>,
|
||||
}
|
||||
|
||||
impl RelationshipProtocol {
|
||||
pub(crate) const ALPN: &'static [u8] = b"/vnidrop/relationship/1";
|
||||
|
||||
pub(crate) fn new(relationships: Arc<DeviceRelationshipService>) -> Self {
|
||||
Self { relationships }
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for RelationshipProtocol {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.write_str("RelationshipProtocol")
|
||||
}
|
||||
}
|
||||
|
||||
impl ProtocolHandler for RelationshipProtocol {
|
||||
async fn accept(&self, connection: Connection) -> Result<(), AcceptError> {
|
||||
let remote_endpoint_id = connection.remote_id().to_string();
|
||||
while let Some(message) = read_request::<RelationshipMessages>(&connection).await? {
|
||||
match message {
|
||||
RelationshipMessage::PairingRequest(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.relationships
|
||||
.handle_pairing_request(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
RelationshipMessage::PairingConsent(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.relationships
|
||||
.handle_pairing_consent(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
RelationshipMessage::PairingAck(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let response = self
|
||||
.relationships
|
||||
.handle_pairing_ack(remote_endpoint_id.clone(), inner)
|
||||
.await;
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
RelationshipMessage::RevokeNotice(message) => {
|
||||
let WithChannels { inner, tx, .. } = message;
|
||||
let acknowledged = self
|
||||
.relationships
|
||||
.handle_remote_revoke(remote_endpoint_id.clone(), inner.generation)
|
||||
.await;
|
||||
let response = if acknowledged {
|
||||
RevokeNoticeResponse::Acknowledged
|
||||
} else {
|
||||
RevokeNoticeResponse::Rejected
|
||||
};
|
||||
let _ = tx.send(response).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
connection.closed().await;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) struct RelationshipClient {
|
||||
inner: Client<RelationshipMessages>,
|
||||
}
|
||||
|
||||
impl RelationshipClient {
|
||||
pub(super) fn connect(endpoint: Endpoint, addr: EndpointAddr) -> Self {
|
||||
Self {
|
||||
inner: Client::boxed(IrohLazyRemoteConnection::new(
|
||||
endpoint,
|
||||
addr,
|
||||
RelationshipProtocol::ALPN.to_vec(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn pairing_request(
|
||||
&self,
|
||||
request: PairingRequest,
|
||||
) -> Result<PairingRequestResponse, irpc::Error> {
|
||||
self.inner.rpc(request).await
|
||||
}
|
||||
|
||||
pub(super) async fn pairing_consent(
|
||||
&self,
|
||||
consent: PairingConsent,
|
||||
) -> Result<PairingConsentResponse, irpc::Error> {
|
||||
self.inner.rpc(consent).await
|
||||
}
|
||||
|
||||
pub(super) async fn pairing_ack(
|
||||
&self,
|
||||
ack: PairingAck,
|
||||
) -> Result<PairingAckResponse, irpc::Error> {
|
||||
self.inner.rpc(ack).await
|
||||
}
|
||||
|
||||
pub(super) async fn revoke_notice(
|
||||
&self,
|
||||
notice: RevokeNotice,
|
||||
) -> Result<RevokeNoticeResponse, irpc::Error> {
|
||||
self.inner.rpc(notice).await
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PairingRequest {
|
||||
pub(super) session_id: String,
|
||||
pub(super) capability: Vec<u8>,
|
||||
pub(super) protocol_version: u16,
|
||||
pub(super) generation: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum PairingRequestResponse {
|
||||
AwaitingConsent,
|
||||
Merged,
|
||||
AlreadySaved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PairingConsent {
|
||||
pub(super) accepted: bool,
|
||||
pub(super) grant: Option<WireGrant>,
|
||||
pub(super) challenge: Option<String>,
|
||||
pub(super) generation: u64,
|
||||
pub(super) protocol_version: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) enum PairingConsentResponse {
|
||||
Completed {
|
||||
grant: Box<WireGrant>,
|
||||
possession_proof: WireProof,
|
||||
ack_challenge: String,
|
||||
},
|
||||
AlreadySaved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct PairingAck {
|
||||
pub(super) possession_proof: WireProof,
|
||||
pub(super) challenge: String,
|
||||
pub(super) generation: u64,
|
||||
pub(super) protocol_version: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum PairingAckResponse {
|
||||
Acknowledged,
|
||||
AlreadySaved,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct WireGrant {
|
||||
pub(super) grant_id: String,
|
||||
pub(super) secret: String,
|
||||
pub(super) issuer_endpoint_id: String,
|
||||
pub(super) holder_endpoint_id: String,
|
||||
pub(super) generation: u64,
|
||||
pub(super) protocol_version: u16,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct WireProof {
|
||||
pub(crate) grant_id: String,
|
||||
pub(crate) mac: String,
|
||||
pub(crate) challenge: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub(crate) struct RevokeNotice {
|
||||
pub(super) generation: u64,
|
||||
pub(super) issued_grant_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) enum RevokeNoticeResponse {
|
||||
Acknowledged,
|
||||
Rejected,
|
||||
}
|
||||
|
||||
#[rpc_requests(message = RelationshipMessage)]
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
#[allow(
|
||||
clippy::enum_variant_names,
|
||||
reason = "Pairing* names mirror the wire RPC surface"
|
||||
)]
|
||||
enum RelationshipMessages {
|
||||
#[rpc(tx = oneshot::Sender<PairingRequestResponse>)]
|
||||
PairingRequest(PairingRequest),
|
||||
#[rpc(tx = oneshot::Sender<PairingConsentResponse>)]
|
||||
PairingConsent(PairingConsent),
|
||||
#[rpc(tx = oneshot::Sender<PairingAckResponse>)]
|
||||
PairingAck(PairingAck),
|
||||
#[rpc(tx = oneshot::Sender<RevokeNoticeResponse>)]
|
||||
RevokeNotice(RevokeNotice),
|
||||
}
|
||||
1135
crates/vnidrop/src/device_relationship/service.rs
Normal file
1135
crates/vnidrop/src/device_relationship/service.rs
Normal file
File diff suppressed because it is too large
Load Diff
621
crates/vnidrop/src/device_relationship/store.rs
Normal file
621
crates/vnidrop/src/device_relationship/store.rs
Normal file
@@ -0,0 +1,621 @@
|
||||
//! Durable device-relationship rows (schema + queries).
|
||||
//!
|
||||
//! Orchestration (custody, pairing RPC, events) stays on
|
||||
//! [`super::DeviceRelationshipService`]; this store is the domain adapter held
|
||||
//! in [`crate::persistence::AppDataStores`].
|
||||
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{
|
||||
api::{DeviceRelationship, DeviceRelationshipState, SavedDevice},
|
||||
error::VnidropError,
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
/// Minimal non-secret tombstone for a revoked relationship generation.
|
||||
///
|
||||
/// Retains only what is needed to reject replay: peer identity, generation,
|
||||
/// opaque grant ids, and revocation time. No names, filenames, history, or
|
||||
/// capability material.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GenerationTombstone {
|
||||
pub(crate) remote_endpoint_id: String,
|
||||
pub(crate) generation: u64,
|
||||
pub(crate) issued_grant_id: Option<String>,
|
||||
pub(crate) held_grant_id: Option<String>,
|
||||
pub(crate) revoked_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct RelationshipRow {
|
||||
pub(super) state: DeviceRelationshipState,
|
||||
pub(super) generation: u64,
|
||||
pub(super) minimum_protocol_version: u16,
|
||||
pub(super) session_id: Option<String>,
|
||||
pub(super) issued_grant_handle: Option<String>,
|
||||
pub(super) held_grant_handle: Option<String>,
|
||||
pub(super) issued_grant_id: Option<String>,
|
||||
pub(super) held_grant_id: Option<String>,
|
||||
pub(super) created_at: i64,
|
||||
}
|
||||
|
||||
/// Compact projection used by grant-secret reconcile.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(super) struct ReconcileRow {
|
||||
pub(super) remote_endpoint_id: String,
|
||||
pub(super) state: DeviceRelationshipState,
|
||||
pub(super) issued_grant_handle: Option<String>,
|
||||
pub(super) held_grant_handle: Option<String>,
|
||||
}
|
||||
|
||||
pub(super) struct RelationshipUpsert<'a> {
|
||||
pub(super) remote_endpoint_id: &'a str,
|
||||
pub(super) state: DeviceRelationshipState,
|
||||
pub(super) generation: u64,
|
||||
pub(super) minimum_protocol_version: u16,
|
||||
pub(super) session_id: Option<&'a str>,
|
||||
pub(super) remote_display_name: Option<&'a str>,
|
||||
pub(super) last_authenticated_at: Option<i64>,
|
||||
pub(super) issued_grant_handle: Option<&'a str>,
|
||||
pub(super) held_grant_handle: Option<&'a str>,
|
||||
pub(super) issued_grant_id: Option<&'a str>,
|
||||
pub(super) held_grant_id: Option<&'a str>,
|
||||
pub(super) peer_ack: bool,
|
||||
pub(super) local_ack: bool,
|
||||
pub(super) created_at: i64,
|
||||
pub(super) updated_at: i64,
|
||||
}
|
||||
|
||||
/// Domain store for `device_relationships` (+ generation tombstones).
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct DeviceRelationshipStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl DeviceRelationshipStore {
|
||||
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS device_relationships (
|
||||
remote_endpoint_id TEXT PRIMARY KEY,
|
||||
state TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
minimum_protocol_version INTEGER NOT NULL,
|
||||
session_id TEXT,
|
||||
remote_display_name TEXT,
|
||||
last_authenticated_at INTEGER,
|
||||
issued_grant_handle TEXT,
|
||||
held_grant_handle TEXT,
|
||||
issued_grant_id TEXT,
|
||||
held_grant_id TEXT,
|
||||
peer_ack INTEGER NOT NULL DEFAULT 0,
|
||||
local_ack INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let columns = sqlx::query("PRAGMA table_info(device_relationships)")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
let has = |name: &str| columns.iter().any(|row| row.get::<String, _>(1) == name);
|
||||
if !has("issued_grant_id") {
|
||||
sqlx::query("ALTER TABLE device_relationships ADD COLUMN issued_grant_id TEXT")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
if !has("held_grant_id") {
|
||||
sqlx::query("ALTER TABLE device_relationships ADD COLUMN held_grant_id TEXT")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
if !has("local_label") {
|
||||
sqlx::query("ALTER TABLE device_relationships ADD COLUMN local_label TEXT")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
if !has("remote_display_name") {
|
||||
sqlx::query("ALTER TABLE device_relationships ADD COLUMN remote_display_name TEXT")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
if !has("last_authenticated_at") {
|
||||
sqlx::query(
|
||||
"ALTER TABLE device_relationships ADD COLUMN last_authenticated_at INTEGER",
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS relationship_generation_tombstones (
|
||||
remote_endpoint_id TEXT NOT NULL,
|
||||
generation INTEGER NOT NULL,
|
||||
issued_grant_id TEXT,
|
||||
held_grant_id TEXT,
|
||||
revoked_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (remote_endpoint_id, generation)
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn count_active_slots(&self) -> Result<u64, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT COUNT(*) AS n FROM device_relationships
|
||||
WHERE state IN ('saved', 'pending_outgoing', 'pending_incoming')
|
||||
"#,
|
||||
)
|
||||
.fetch_one(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(row.get::<i64, _>("n") as u64)
|
||||
}
|
||||
|
||||
pub(super) async fn list_reconcile_rows(&self) -> Result<Vec<ReconcileRow>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, issued_grant_handle, held_grant_handle, state
|
||||
FROM device_relationships
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
rows.into_iter()
|
||||
.map(|row| {
|
||||
Ok(ReconcileRow {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
state: parse_state(&row.get::<String, _>("state"))?,
|
||||
issued_grant_handle: row.get("issued_grant_handle"),
|
||||
held_grant_handle: row.get("held_grant_handle"),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(super) async fn list(&self) -> Result<Vec<DeviceRelationship>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, state, generation, minimum_protocol_version, created_at, updated_at
|
||||
FROM device_relationships
|
||||
ORDER BY updated_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
rows.into_iter().map(row_to_relationship).collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn list_saved_devices(&self) -> Result<Vec<SavedDevice>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, local_label, remote_display_name, created_at,
|
||||
last_authenticated_at
|
||||
FROM device_relationships
|
||||
WHERE state = 'saved'
|
||||
ORDER BY updated_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| SavedDevice {
|
||||
endpoint_id: row.get("remote_endpoint_id"),
|
||||
local_label: row.get("local_label"),
|
||||
remote_display_name: row.get("remote_display_name"),
|
||||
created_at: row.get("created_at"),
|
||||
last_authenticated_at: row.get("last_authenticated_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn refresh_authenticated_peer(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
remote_display_name: Option<&str>,
|
||||
authenticated_at: i64,
|
||||
) -> Result<bool, VnidropError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET remote_display_name = COALESCE(?2, remote_display_name),
|
||||
last_authenticated_at = ?3,
|
||||
updated_at = ?3
|
||||
WHERE remote_endpoint_id = ?1 AND state = 'saved'
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(remote_display_name)
|
||||
.bind(authenticated_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub(super) async fn set_saved_device_label(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
label: Option<String>,
|
||||
) -> Result<bool, VnidropError> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET local_label = ?2, updated_at = ?3
|
||||
WHERE remote_endpoint_id = ?1 AND state = 'saved'
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(label)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(result.rows_affected() > 0)
|
||||
}
|
||||
|
||||
pub(super) async fn set_issued_grant(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
handle: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET issued_grant_handle = ?2, issued_grant_id = ?3, updated_at = ?4
|
||||
WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(handle)
|
||||
.bind(grant_id)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_held_grant(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
handle: &str,
|
||||
grant_id: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET held_grant_handle = ?2, held_grant_id = ?3, updated_at = ?4
|
||||
WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(handle)
|
||||
.bind(grant_id)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(super) async fn set_minimum_protocol_version(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
minimum_protocol_version: u16,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
"UPDATE device_relationships SET minimum_protocol_version = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1",
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(i64::from(minimum_protocol_version))
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_acks(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
local_ack: bool,
|
||||
peer_ack: bool,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
"UPDATE device_relationships SET local_ack = ?2, peer_ack = ?3, updated_at = ?4 WHERE remote_endpoint_id = ?1",
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(i64::from(local_ack))
|
||||
.bind(i64::from(peer_ack))
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn set_state(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
state: DeviceRelationshipState,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
"UPDATE device_relationships SET state = ?2, updated_at = ?3 WHERE remote_endpoint_id = ?1",
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(state_as_str(state))
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn list_expired_pending_peers(
|
||||
&self,
|
||||
cutoff_ms: i64,
|
||||
) -> Result<Vec<String>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id FROM device_relationships
|
||||
WHERE state IN ('pending_outgoing', 'pending_incoming') AND updated_at < ?1
|
||||
"#,
|
||||
)
|
||||
.bind(cutoff_ms)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(|row| row.get(0)).collect())
|
||||
}
|
||||
|
||||
pub(super) async fn delete(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
|
||||
sqlx::query("DELETE FROM device_relationships WHERE remote_endpoint_id = ?1")
|
||||
.bind(peer_endpoint_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn find_row(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Option<RelationshipRow>, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, state, generation, minimum_protocol_version, session_id,
|
||||
issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id,
|
||||
peer_ack, local_ack, created_at, updated_at
|
||||
FROM device_relationships WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
row.map(relationship_row_from_sql).transpose()
|
||||
}
|
||||
|
||||
pub(super) async fn upsert(&self, entry: RelationshipUpsert<'_>) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO device_relationships (
|
||||
remote_endpoint_id, state, generation, minimum_protocol_version, session_id,
|
||||
remote_display_name,
|
||||
last_authenticated_at,
|
||||
issued_grant_handle, held_grant_handle, issued_grant_id, held_grant_id,
|
||||
peer_ack, local_ack, created_at, updated_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)
|
||||
ON CONFLICT(remote_endpoint_id) DO UPDATE SET
|
||||
state = excluded.state,
|
||||
generation = excluded.generation,
|
||||
minimum_protocol_version = excluded.minimum_protocol_version,
|
||||
session_id = excluded.session_id,
|
||||
remote_display_name = COALESCE(excluded.remote_display_name, device_relationships.remote_display_name),
|
||||
last_authenticated_at = COALESCE(excluded.last_authenticated_at, device_relationships.last_authenticated_at),
|
||||
issued_grant_handle = COALESCE(excluded.issued_grant_handle, device_relationships.issued_grant_handle),
|
||||
held_grant_handle = COALESCE(excluded.held_grant_handle, device_relationships.held_grant_handle),
|
||||
issued_grant_id = COALESCE(excluded.issued_grant_id, device_relationships.issued_grant_id),
|
||||
held_grant_id = COALESCE(excluded.held_grant_id, device_relationships.held_grant_id),
|
||||
peer_ack = excluded.peer_ack,
|
||||
local_ack = excluded.local_ack,
|
||||
updated_at = excluded.updated_at
|
||||
"#,
|
||||
)
|
||||
.bind(entry.remote_endpoint_id)
|
||||
.bind(state_as_str(entry.state))
|
||||
.bind(entry.generation as i64)
|
||||
.bind(i64::from(entry.minimum_protocol_version))
|
||||
.bind(entry.session_id)
|
||||
.bind(entry.remote_display_name)
|
||||
.bind(entry.last_authenticated_at)
|
||||
.bind(entry.issued_grant_handle)
|
||||
.bind(entry.held_grant_handle)
|
||||
.bind(entry.issued_grant_id)
|
||||
.bind(entry.held_grant_id)
|
||||
.bind(i64::from(entry.peer_ack))
|
||||
.bind(i64::from(entry.local_ack))
|
||||
.bind(entry.created_at)
|
||||
.bind(entry.updated_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Bump generation and clear grant columns after a prior generation was tombstoned.
|
||||
pub(super) async fn begin_grant_rotation(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
new_generation: u64,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
UPDATE device_relationships
|
||||
SET generation = ?2,
|
||||
issued_grant_handle = NULL,
|
||||
held_grant_handle = NULL,
|
||||
issued_grant_id = NULL,
|
||||
held_grant_id = NULL,
|
||||
updated_at = ?3
|
||||
WHERE remote_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(new_generation as i64)
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn insert_tombstone(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
row: &RelationshipRow,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO relationship_generation_tombstones (
|
||||
remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(remote_endpoint_id, generation) DO UPDATE SET
|
||||
issued_grant_id = COALESCE(excluded.issued_grant_id, relationship_generation_tombstones.issued_grant_id),
|
||||
held_grant_id = COALESCE(excluded.held_grant_id, relationship_generation_tombstones.held_grant_id),
|
||||
revoked_at = excluded.revoked_at
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(row.generation as i64)
|
||||
.bind(row.issued_grant_id.as_deref())
|
||||
.bind(row.held_grant_id.as_deref())
|
||||
.bind(now_ms())
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn find_tombstone(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
generation: u64,
|
||||
) -> Result<Option<GenerationTombstone>, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
FROM relationship_generation_tombstones
|
||||
WHERE remote_endpoint_id = ?1 AND generation = ?2
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.bind(generation as i64)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(row.map(|row| GenerationTombstone {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
issued_grant_id: row.get("issued_grant_id"),
|
||||
held_grant_id: row.get("held_grant_id"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn list_tombstones(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Vec<GenerationTombstone>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT remote_endpoint_id, generation, issued_grant_id, held_grant_id, revoked_at
|
||||
FROM relationship_generation_tombstones
|
||||
WHERE remote_endpoint_id = ?1
|
||||
ORDER BY generation ASC
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| GenerationTombstone {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
issued_grant_id: row.get("issued_grant_id"),
|
||||
held_grant_id: row.get("held_grant_id"),
|
||||
revoked_at: row.get("revoked_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn state_as_str(state: DeviceRelationshipState) -> &'static str {
|
||||
match state {
|
||||
DeviceRelationshipState::PendingOutgoing => "pending_outgoing",
|
||||
DeviceRelationshipState::PendingIncoming => "pending_incoming",
|
||||
DeviceRelationshipState::Saved => "saved",
|
||||
DeviceRelationshipState::Revoked => "revoked",
|
||||
DeviceRelationshipState::Blocked => "blocked",
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_state(value: &str) -> Result<DeviceRelationshipState, VnidropError> {
|
||||
match value {
|
||||
"pending_outgoing" => Ok(DeviceRelationshipState::PendingOutgoing),
|
||||
"pending_incoming" => Ok(DeviceRelationshipState::PendingIncoming),
|
||||
"saved" => Ok(DeviceRelationshipState::Saved),
|
||||
"revoked" => Ok(DeviceRelationshipState::Revoked),
|
||||
"blocked" => Ok(DeviceRelationshipState::Blocked),
|
||||
_ => Err(VnidropError::Internal {
|
||||
reason: "unknown device relationship state".to_string(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_relationship(row: sqlx::sqlite::SqliteRow) -> Result<DeviceRelationship, VnidropError> {
|
||||
Ok(DeviceRelationship {
|
||||
remote_endpoint_id: row.get("remote_endpoint_id"),
|
||||
state: parse_state(&row.get::<String, _>("state"))?,
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
minimum_protocol_version: row.get::<i64, _>("minimum_protocol_version") as u16,
|
||||
created_at: row.get("created_at"),
|
||||
updated_at: row.get("updated_at"),
|
||||
})
|
||||
}
|
||||
|
||||
fn relationship_row_from_sql(
|
||||
row: sqlx::sqlite::SqliteRow,
|
||||
) -> Result<RelationshipRow, VnidropError> {
|
||||
Ok(RelationshipRow {
|
||||
state: parse_state(&row.get::<String, _>("state"))?,
|
||||
generation: row.get::<i64, _>("generation") as u64,
|
||||
minimum_protocol_version: row.get::<i64, _>("minimum_protocol_version") as u16,
|
||||
session_id: row.get("session_id"),
|
||||
issued_grant_handle: row.get("issued_grant_handle"),
|
||||
held_grant_handle: row.get("held_grant_handle"),
|
||||
issued_grant_id: row.get("issued_grant_id"),
|
||||
held_grant_id: row.get("held_grant_id"),
|
||||
created_at: row.get("created_at"),
|
||||
})
|
||||
}
|
||||
@@ -16,6 +16,14 @@ pub enum VnidropError {
|
||||
StorageFull { reason: String },
|
||||
#[error("network error: {reason}")]
|
||||
Network { reason: String },
|
||||
#[error("device unavailable: {reason}")]
|
||||
DeviceUnavailable { reason: String },
|
||||
#[error("offer timed out: {reason}")]
|
||||
OfferTimeout { reason: String },
|
||||
#[error("relay policy incompatible: {reason}")]
|
||||
RelayPolicyIncompatible { reason: String },
|
||||
#[error("protocol incompatible: {reason}")]
|
||||
ProtocolIncompatible { reason: String },
|
||||
#[error("transfer error: {reason}")]
|
||||
Transfer { reason: String },
|
||||
#[error("permission error: {reason}")]
|
||||
@@ -26,20 +34,28 @@ pub enum VnidropError {
|
||||
Cancelled { reason: String },
|
||||
#[error("invalid input: {reason}")]
|
||||
InvalidInput { reason: String },
|
||||
#[error("invalid targeted transfer transition: {reason}")]
|
||||
InvalidTransition { reason: String },
|
||||
#[error("secure storage is locked: {reason}")]
|
||||
SecureStorageLocked { reason: String },
|
||||
#[error("secure storage item is missing: {reason}")]
|
||||
SecureStorageMissing { reason: String },
|
||||
#[error("secure storage item is corrupted: {reason}")]
|
||||
SecureStorageCorrupted { reason: String },
|
||||
#[error("secure storage is unavailable: {reason}")]
|
||||
SecureStorageUnavailable { reason: String },
|
||||
#[error("internal error: {reason}")]
|
||||
Internal { reason: String },
|
||||
}
|
||||
|
||||
impl VnidropError {
|
||||
pub(crate) fn initialization(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::Initialization {
|
||||
reason: error.into().to_string(),
|
||||
}
|
||||
Self::from_error(error.into(), |reason| Self::Initialization { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn ticket(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::Ticket {
|
||||
reason: error.into().to_string(),
|
||||
reason: crate::control_plane::redact_text(&error.into().to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +68,24 @@ impl VnidropError {
|
||||
Self::from_error(error.into(), |reason| Self::Network { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn device_unavailable(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::from_error(error.into(), |reason| Self::DeviceUnavailable { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn offer_timeout(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::from_error(error.into(), |reason| Self::OfferTimeout { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn relay_policy_incompatible(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::from_error(error.into(), |reason| Self::RelayPolicyIncompatible {
|
||||
reason,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn protocol_incompatible(error: impl Into<anyhow::Error>) -> Self {
|
||||
Self::from_error(error.into(), |reason| Self::ProtocolIncompatible { reason })
|
||||
}
|
||||
|
||||
pub(crate) fn transfer(error: impl Into<anyhow::Error>) -> Self {
|
||||
let error = error.into();
|
||||
Self::classify(error, |reason| Self::Transfer { reason })
|
||||
@@ -88,11 +122,20 @@ impl VnidropError {
|
||||
Self::DestinationExists { .. } => "destination_exists",
|
||||
Self::StorageFull { .. } => "storage_full",
|
||||
Self::Network { .. } => "network",
|
||||
Self::DeviceUnavailable { .. } => "device_unavailable",
|
||||
Self::OfferTimeout { .. } => "offer_timeout",
|
||||
Self::RelayPolicyIncompatible { .. } => "relay_policy_incompatible",
|
||||
Self::ProtocolIncompatible { .. } => "protocol_incompatible",
|
||||
Self::Transfer { .. } => "transfer",
|
||||
Self::Permission { .. } => "permission_denied",
|
||||
Self::Repository { .. } => "repository",
|
||||
Self::Cancelled { .. } => "cancelled",
|
||||
Self::InvalidInput { .. } => "invalid_input",
|
||||
Self::InvalidTransition { .. } => "invalid_transition",
|
||||
Self::SecureStorageLocked { .. } => "secure_storage_locked",
|
||||
Self::SecureStorageMissing { .. } => "secure_storage_missing",
|
||||
Self::SecureStorageCorrupted { .. } => "secure_storage_corrupted",
|
||||
Self::SecureStorageUnavailable { .. } => "secure_storage_unavailable",
|
||||
Self::Internal { .. } => "internal",
|
||||
}
|
||||
}
|
||||
@@ -106,17 +149,26 @@ impl VnidropError {
|
||||
| Self::DestinationExists { reason }
|
||||
| Self::StorageFull { reason }
|
||||
| Self::Network { reason }
|
||||
| Self::DeviceUnavailable { reason }
|
||||
| Self::OfferTimeout { reason }
|
||||
| Self::RelayPolicyIncompatible { reason }
|
||||
| Self::ProtocolIncompatible { reason }
|
||||
| Self::Transfer { reason }
|
||||
| Self::Permission { reason }
|
||||
| Self::Repository { reason }
|
||||
| Self::Cancelled { reason }
|
||||
| Self::InvalidInput { reason }
|
||||
| Self::InvalidTransition { reason }
|
||||
| Self::SecureStorageLocked { reason }
|
||||
| Self::SecureStorageMissing { reason }
|
||||
| Self::SecureStorageCorrupted { reason }
|
||||
| Self::SecureStorageUnavailable { reason }
|
||||
| Self::Internal { reason } => reason,
|
||||
}
|
||||
}
|
||||
|
||||
fn classify(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
|
||||
let reason = error.to_string();
|
||||
let reason = crate::control_plane::redact_text(&error.to_string());
|
||||
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
|
||||
return existing.with_reason(reason);
|
||||
}
|
||||
@@ -141,7 +193,7 @@ impl VnidropError {
|
||||
}
|
||||
|
||||
fn from_error(error: anyhow::Error, fallback: impl FnOnce(String) -> Self) -> Self {
|
||||
let reason = error.to_string();
|
||||
let reason = crate::control_plane::redact_text(&error.to_string());
|
||||
if let Some(existing) = error.chain().find_map(|cause| cause.downcast_ref::<Self>()) {
|
||||
existing.with_reason(reason)
|
||||
} else {
|
||||
@@ -158,11 +210,20 @@ impl VnidropError {
|
||||
Self::DestinationExists { .. } => Self::DestinationExists { reason },
|
||||
Self::StorageFull { .. } => Self::StorageFull { reason },
|
||||
Self::Network { .. } => Self::Network { reason },
|
||||
Self::DeviceUnavailable { .. } => Self::DeviceUnavailable { reason },
|
||||
Self::OfferTimeout { .. } => Self::OfferTimeout { reason },
|
||||
Self::RelayPolicyIncompatible { .. } => Self::RelayPolicyIncompatible { reason },
|
||||
Self::ProtocolIncompatible { .. } => Self::ProtocolIncompatible { reason },
|
||||
Self::Transfer { .. } => Self::Transfer { reason },
|
||||
Self::Permission { .. } => Self::Permission { reason },
|
||||
Self::Repository { .. } => Self::Repository { reason },
|
||||
Self::Cancelled { .. } => Self::Cancelled { reason },
|
||||
Self::InvalidInput { .. } => Self::InvalidInput { reason },
|
||||
Self::InvalidTransition { .. } => Self::InvalidTransition { reason },
|
||||
Self::SecureStorageLocked { .. } => Self::SecureStorageLocked { reason },
|
||||
Self::SecureStorageMissing { .. } => Self::SecureStorageMissing { reason },
|
||||
Self::SecureStorageCorrupted { .. } => Self::SecureStorageCorrupted { reason },
|
||||
Self::SecureStorageUnavailable { .. } => Self::SecureStorageUnavailable { reason },
|
||||
Self::Internal { .. } => Self::Internal { reason },
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ use tokio::{
|
||||
|
||||
use crate::{
|
||||
api::{CoreEvent, CoreEventSink},
|
||||
repository::Repository,
|
||||
control_plane::redact_json,
|
||||
invitation::Repository,
|
||||
transfer_state::TransferDirection,
|
||||
util::now_ms,
|
||||
};
|
||||
@@ -47,6 +48,10 @@ enum EventPhase {
|
||||
Transfer,
|
||||
/// Delivery receipts from receivers (completed download acknowledgements).
|
||||
Delivery,
|
||||
/// Saved-device pairing eligibility and consent prompts.
|
||||
Pairing,
|
||||
/// Saved-device targeted-transfer pre-approval prompts.
|
||||
TargetedTransfer,
|
||||
}
|
||||
|
||||
impl EventPhase {
|
||||
@@ -68,6 +73,8 @@ impl EventPhase {
|
||||
"approval" => Some(Self::Approval),
|
||||
"transfer" => Some(Self::Transfer),
|
||||
"delivery" => Some(Self::Delivery),
|
||||
"pairing" => Some(Self::Pairing),
|
||||
"targeted_transfer" => Some(Self::TargetedTransfer),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -90,6 +97,8 @@ impl EventPhase {
|
||||
Self::Approval => "approval",
|
||||
Self::Transfer => "transfer",
|
||||
Self::Delivery => "delivery",
|
||||
Self::Pairing => "pairing",
|
||||
Self::TargetedTransfer => "targeted_transfer",
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -230,22 +239,26 @@ impl EventHub {
|
||||
.sequence
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let id = format!("{timestamp}-{}", *sequence);
|
||||
*sequence += 1;
|
||||
let revision = *sequence;
|
||||
let id = format!("{timestamp}-{revision}");
|
||||
*sequence = sequence.saturating_add(1);
|
||||
drop(sequence);
|
||||
|
||||
// Compose observes this event synchronously, while SQLite persistence is
|
||||
// serialized through the queue. That keeps the UI responsive without
|
||||
// losing the ability to flush persisted history during shutdown/tests.
|
||||
// Production diagnostics redact endpoint ids, tickets, grants, and paths;
|
||||
// typed UniFFI list APIs still expose the values the UI needs.
|
||||
let event = CoreEvent {
|
||||
id,
|
||||
revision,
|
||||
timestamp,
|
||||
scope: scope.as_str().to_string(),
|
||||
transfer_id,
|
||||
direction: direction.map(|direction| direction.as_str().to_string()),
|
||||
phase: phase.as_str().to_string(),
|
||||
kind: kind.0,
|
||||
data_json: data.to_string(),
|
||||
data_json: redact_json(data).to_string(),
|
||||
};
|
||||
if let Err(error) = self.tx.try_send(EventCommand::Persist(event.clone())) {
|
||||
tracing::warn!(event_id = %event.id, %error, "event persistence queue dropped event");
|
||||
|
||||
181
crates/vnidrop/src/grant.rs
Normal file
181
crates/vnidrop/src/grant.rs
Normal file
@@ -0,0 +1,181 @@
|
||||
//! Grant identity and possession-proof primitives for saved-device relationships.
|
||||
//!
|
||||
//! The issuer is the only party that can validate a grant, which is what makes
|
||||
//! both consent and revocation enforceable. This module is pure: no storage and
|
||||
//! no network. Relationship-bound MACs live in `device_relationship::crypto`.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use data_encoding::HEXLOWER;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const GRANT_ID_LEN: usize = 16;
|
||||
const GRANT_SECRET_LEN: usize = 32;
|
||||
const CHALLENGE_LEN: usize = 32;
|
||||
const PROOF_LEN: usize = 32;
|
||||
|
||||
/// Opaque public identifier for a grant. Safe to send in the clear.
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub(crate) struct GrantId([u8; GRANT_ID_LEN]);
|
||||
|
||||
impl GrantId {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.context("invalid grant id encoding")?;
|
||||
let bytes: [u8; GRANT_ID_LEN] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("invalid grant id length"))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrantId {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "GrantId({})", self.encode())
|
||||
}
|
||||
}
|
||||
|
||||
/// Key material. Never logged, never emitted in an event, never returned across
|
||||
/// the UniFFI boundary.
|
||||
#[derive(Clone, PartialEq, Eq)]
|
||||
pub(crate) struct GrantSecret([u8; GRANT_SECRET_LEN]);
|
||||
|
||||
impl GrantSecret {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn as_bytes(&self) -> &[u8; GRANT_SECRET_LEN] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub(crate) fn from_bytes(bytes: [u8; GRANT_SECRET_LEN]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.context("invalid grant secret encoding")?;
|
||||
let bytes: [u8; GRANT_SECRET_LEN] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("invalid grant secret length"))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
// Redacted on purpose: a secret must not reach a log line through a derived
|
||||
// Debug on some enclosing struct.
|
||||
impl fmt::Debug for GrantSecret {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("GrantSecret(redacted)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Random challenge sent by the issuer to bind a proof to one connection.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct Challenge([u8; CHALLENGE_LEN]);
|
||||
|
||||
impl Challenge {
|
||||
pub(crate) fn generate() -> Self {
|
||||
Self(random_bytes())
|
||||
}
|
||||
|
||||
pub(crate) fn as_bytes(&self) -> &[u8; CHALLENGE_LEN] {
|
||||
&self.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn from_bytes(bytes: [u8; CHALLENGE_LEN]) -> Self {
|
||||
Self(bytes)
|
||||
}
|
||||
|
||||
pub(crate) fn encode(&self) -> String {
|
||||
HEXLOWER.encode(&self.0)
|
||||
}
|
||||
|
||||
pub(crate) fn decode(value: &str) -> Result<Self> {
|
||||
let bytes = HEXLOWER
|
||||
.decode(value.as_bytes())
|
||||
.context("invalid challenge encoding")?;
|
||||
let bytes: [u8; CHALLENGE_LEN] = bytes
|
||||
.try_into()
|
||||
.map_err(|_| anyhow::anyhow!("invalid challenge length"))?;
|
||||
Ok(Self(bytes))
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for Challenge {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("Challenge(..)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Proof that the sender holds the secret behind `grant_id`.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub(crate) struct GrantProof {
|
||||
pub(crate) grant_id: GrantId,
|
||||
mac: [u8; PROOF_LEN],
|
||||
}
|
||||
|
||||
impl GrantProof {
|
||||
pub(crate) fn from_parts(grant_id: GrantId, mac: [u8; PROOF_LEN]) -> Self {
|
||||
Self { grant_id, mac }
|
||||
}
|
||||
|
||||
pub(crate) fn mac(&self) -> &[u8; PROOF_LEN] {
|
||||
&self.mac
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for GrantProof {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.debug_struct("GrantProof")
|
||||
.field("grant_id", &self.grant_id)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
/// Why a presented proof was not accepted.
|
||||
///
|
||||
/// `Revoked` is reported to the peer so its client can drop the dead entry.
|
||||
/// `Unknown` is deliberately also used for blocked endpoints, so blocking
|
||||
/// cannot be detected by probing.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) enum GrantRejection {
|
||||
Unknown,
|
||||
Revoked,
|
||||
}
|
||||
|
||||
impl GrantRejection {
|
||||
pub(crate) fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Unknown => "unknown",
|
||||
Self::Revoked => "revoked",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Cryptographically secure random bytes.
|
||||
///
|
||||
/// Panics if the OS entropy source fails. That is unrecoverable and must never
|
||||
/// degrade into a weak grant, so it is not surfaced as a fallible API.
|
||||
fn random_bytes<const N: usize>() -> [u8; N] {
|
||||
let mut bytes = [0u8; N];
|
||||
getrandom::fill(&mut bytes).expect("OS entropy source unavailable");
|
||||
bytes
|
||||
}
|
||||
@@ -142,6 +142,7 @@ pub(crate) enum HandshakeResponse {
|
||||
request_id: String,
|
||||
token: String,
|
||||
expires_at: i64,
|
||||
sender_name: Option<String>,
|
||||
},
|
||||
Denied {
|
||||
reason: String,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
use std::{path::Path, str::FromStr};
|
||||
//! Invitation-transfer domain store (history, approvals, delivery receipts).
|
||||
//!
|
||||
//! This is the invitation half of [`crate::persistence::AppDataStores`]. It owns
|
||||
//! only invitation-transfer tables — not relationships, eligibility, blocks, or
|
||||
//! secret metadata (those have their own domain stores).
|
||||
|
||||
#[cfg(test)]
|
||||
use std::path::Path;
|
||||
|
||||
#[cfg(test)]
|
||||
use std::sync::{
|
||||
@@ -7,10 +14,7 @@ use std::sync::{
|
||||
};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::{
|
||||
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
||||
Row, SqlitePool,
|
||||
};
|
||||
use sqlx::{Row, SqlitePool};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
@@ -20,8 +24,11 @@ use crate::{
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const SCHEMA_VERSION: i64 = 7;
|
||||
const SCHEMA_VERSION: i64 = 13;
|
||||
|
||||
/// Invitation-transfer durable store (history, receiver requests, receipts, events).
|
||||
///
|
||||
/// Type name kept for call-site stability; module path is [`crate::invitation`].
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct Repository {
|
||||
pool: SqlitePool,
|
||||
@@ -37,6 +44,7 @@ pub(crate) struct TransferUpsert<'a> {
|
||||
pub(crate) direction: TransferDirection,
|
||||
pub(crate) status: TransferStatus,
|
||||
pub(crate) transfer_name: Option<&'a str>,
|
||||
pub(crate) sender_name: Option<&'a str>,
|
||||
pub(crate) content_hash: Option<&'a str>,
|
||||
pub(crate) ticket: Option<&'a str>,
|
||||
pub(crate) file_count: u64,
|
||||
@@ -99,29 +107,26 @@ pub(crate) struct PendingDeliveryReceiptInsert<'a> {
|
||||
}
|
||||
|
||||
impl Repository {
|
||||
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
|
||||
let db_path = app_data_dir.join("vnidrop.sqlite3");
|
||||
let options = SqliteConnectOptions::from_str("sqlite://")?
|
||||
.filename(db_path)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect_with(options)
|
||||
.await?;
|
||||
let repository = Self {
|
||||
pub(crate) fn from_pool(pool: SqlitePool) -> Self {
|
||||
Self {
|
||||
pool,
|
||||
#[cfg(test)]
|
||||
fail_next_write: Arc::new(AtomicBool::new(false)),
|
||||
#[cfg(test)]
|
||||
fail_receive_history_after_dependants: Arc::new(AtomicBool::new(false)),
|
||||
};
|
||||
repository.ensure_schema().await?;
|
||||
Ok(repository)
|
||||
}
|
||||
}
|
||||
|
||||
async fn ensure_schema(&self) -> Result<()> {
|
||||
// The app owns this SQLite file. Keep migrations explicit so future
|
||||
// desktop/mobile releases can move user history forward in place.
|
||||
/// Test/helper entry: opens [`AppDataStores`] and returns the invitation store.
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn open(app_data_dir: &Path) -> Result<Self> {
|
||||
Ok(crate::persistence::open_all(app_data_dir).await?.invitation)
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_schema(&self) -> Result<()> {
|
||||
// Invitation-transfer tables only. Other domains apply schema from
|
||||
// [`crate::persistence::open_all`]. Keep migrations explicit so releases
|
||||
// can move invitation history forward in place.
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS transfers (
|
||||
@@ -132,6 +137,7 @@ impl Repository {
|
||||
direction TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
transfer_name TEXT,
|
||||
sender_name TEXT,
|
||||
content_hash TEXT,
|
||||
ticket TEXT,
|
||||
file_count INTEGER NOT NULL DEFAULT 0,
|
||||
@@ -151,6 +157,14 @@ impl Repository {
|
||||
let has_access_mode = columns
|
||||
.iter()
|
||||
.any(|row| row.get::<String, _>(1) == "access_mode");
|
||||
if !columns
|
||||
.iter()
|
||||
.any(|row| row.get::<String, _>(1) == "sender_name")
|
||||
{
|
||||
sqlx::query("ALTER TABLE transfers ADD COLUMN sender_name TEXT")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
if !has_access_mode {
|
||||
sqlx::query(
|
||||
"ALTER TABLE transfers ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'approval_required'",
|
||||
@@ -221,6 +235,7 @@ impl Repository {
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS transfer_events (
|
||||
id TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
timestamp INTEGER NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
transfer_id INTEGER,
|
||||
@@ -234,6 +249,20 @@ impl Repository {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
let event_columns = sqlx::query("PRAGMA table_info(transfer_events)")
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
if !event_columns
|
||||
.iter()
|
||||
.any(|row| row.get::<String, _>(1) == "revision")
|
||||
{
|
||||
sqlx::query(
|
||||
"ALTER TABLE transfer_events ADD COLUMN revision INTEGER NOT NULL DEFAULT 0",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX IF NOT EXISTS idx_transfer_events_transfer_id ON transfer_events(transfer_id, timestamp);",
|
||||
)
|
||||
@@ -482,19 +511,21 @@ impl Repository {
|
||||
UPDATE transfers
|
||||
SET status = ?1,
|
||||
transfer_name = ?2,
|
||||
content_hash = ?3,
|
||||
ticket = ?4,
|
||||
file_count = ?5,
|
||||
total_size = ?6,
|
||||
access_mode = ?7,
|
||||
updated_at = ?8
|
||||
WHERE transfer_id = ?9
|
||||
sender_name = ?3,
|
||||
content_hash = ?4,
|
||||
ticket = ?5,
|
||||
file_count = ?6,
|
||||
total_size = ?7,
|
||||
access_mode = ?8,
|
||||
updated_at = ?9
|
||||
WHERE transfer_id = ?10
|
||||
AND direction = 'send'
|
||||
AND status = 'importing'
|
||||
"#,
|
||||
)
|
||||
.bind(transfer.status.as_str())
|
||||
.bind(transfer.transfer_name)
|
||||
.bind(transfer.sender_name)
|
||||
.bind(transfer.content_hash)
|
||||
.bind(transfer.ticket)
|
||||
.bind(to_db_id(transfer.file_count)?)
|
||||
@@ -786,12 +817,13 @@ impl Repository {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT OR REPLACE INTO transfer_events (
|
||||
id, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);
|
||||
"#,
|
||||
)
|
||||
.bind(&event.id)
|
||||
.bind(to_db_id(event.revision)?)
|
||||
.bind(event.timestamp)
|
||||
.bind(&event.scope)
|
||||
.bind(event.transfer_id.map(to_db_id).transpose()?)
|
||||
@@ -890,7 +922,7 @@ impl Repository {
|
||||
transfer_id: u64,
|
||||
remote_endpoint_id: &str,
|
||||
token_hash: &str,
|
||||
) -> Result<()> {
|
||||
) -> Result<Option<String>> {
|
||||
let result = sqlx::query(
|
||||
r#"
|
||||
UPDATE receiver_requests
|
||||
@@ -906,31 +938,45 @@ impl Repository {
|
||||
.bind(token_hash)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
if result.rows_affected() == 1 {
|
||||
return Ok(());
|
||||
}
|
||||
let already_recorded = sqlx::query(
|
||||
r#"
|
||||
if result.rows_affected() != 1 {
|
||||
let already_recorded = sqlx::query(
|
||||
r#"
|
||||
SELECT EXISTS(
|
||||
SELECT 1 FROM receiver_requests
|
||||
WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3
|
||||
AND receipt_token_hash = ?4 AND status = 'completed'
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(to_db_id(transfer_id)?)
|
||||
.bind(remote_endpoint_id)
|
||||
.bind(token_hash)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.get::<i64, _>(0)
|
||||
!= 0;
|
||||
if !already_recorded {
|
||||
anyhow::bail!("delivery receipt did not match an accepted receiver request");
|
||||
}
|
||||
}
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT receiver_name, receiver_device_name
|
||||
FROM receiver_requests
|
||||
WHERE id = ?1 AND transfer_id = ?2 AND remote_endpoint_id = ?3
|
||||
AND receipt_token_hash = ?4 AND status = 'completed'
|
||||
"#,
|
||||
)
|
||||
.bind(id)
|
||||
.bind(to_db_id(transfer_id)?)
|
||||
.bind(remote_endpoint_id)
|
||||
.bind(token_hash)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.get::<i64, _>(0)
|
||||
!= 0;
|
||||
if already_recorded {
|
||||
Ok(())
|
||||
} else {
|
||||
anyhow::bail!("delivery receipt did not match an accepted receiver request")
|
||||
}
|
||||
.await?;
|
||||
Ok(row
|
||||
.get::<Option<String>, _>("receiver_device_name")
|
||||
.or_else(|| row.get("receiver_name")))
|
||||
}
|
||||
|
||||
pub(crate) async fn fail_receiver_delivery(
|
||||
@@ -1037,6 +1083,25 @@ impl Repository {
|
||||
Ok(row.get::<i64, _>(0) != 0)
|
||||
}
|
||||
|
||||
pub(crate) async fn send_sender_name(
|
||||
&self,
|
||||
transfer_id: u64,
|
||||
content_hash: &str,
|
||||
) -> Result<Option<String>> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT sender_name FROM transfers
|
||||
WHERE transfer_id = ?1 AND content_hash = ?2
|
||||
AND direction = 'send' AND status = 'sharing'
|
||||
"#,
|
||||
)
|
||||
.bind(to_db_id(transfer_id)?)
|
||||
.bind(content_hash)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(row.and_then(|row| row.get("sender_name")))
|
||||
}
|
||||
|
||||
pub(crate) async fn list_transfers(&self) -> Result<Vec<StoredTransfer>> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
@@ -1135,10 +1200,10 @@ impl Repository {
|
||||
let rows = if let Some(transfer_id) = transfer_id {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
SELECT id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
FROM transfer_events
|
||||
WHERE transfer_id = ?1
|
||||
ORDER BY timestamp ASC
|
||||
ORDER BY timestamp ASC, revision ASC
|
||||
LIMIT ?2
|
||||
"#,
|
||||
)
|
||||
@@ -1149,9 +1214,9 @@ impl Repository {
|
||||
} else {
|
||||
sqlx::query(
|
||||
r#"
|
||||
SELECT id, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
SELECT id, revision, timestamp, scope, transfer_id, direction, phase, kind, data_json
|
||||
FROM transfer_events
|
||||
ORDER BY timestamp DESC
|
||||
ORDER BY timestamp DESC, revision DESC
|
||||
LIMIT ?1
|
||||
"#,
|
||||
)
|
||||
@@ -1218,6 +1283,7 @@ fn row_to_transfer(row: sqlx::sqlite::SqliteRow) -> Result<StoredTransfer> {
|
||||
fn row_to_event(row: sqlx::sqlite::SqliteRow) -> CoreEvent {
|
||||
CoreEvent {
|
||||
id: row.get("id"),
|
||||
revision: row.get::<i64, _>("revision") as u64,
|
||||
timestamp: row.get("timestamp"),
|
||||
scope: row.get("scope"),
|
||||
transfer_id: row
|
||||
@@ -1,23 +1,37 @@
|
||||
mod access_policy;
|
||||
mod api;
|
||||
mod approval;
|
||||
mod blocked_devices;
|
||||
mod control_plane;
|
||||
mod device_relationship;
|
||||
mod error;
|
||||
mod event_hub;
|
||||
mod filesystem;
|
||||
mod grant;
|
||||
mod handshake;
|
||||
mod invitation;
|
||||
mod logging;
|
||||
mod repository;
|
||||
mod pairing_eligibility;
|
||||
mod persistence;
|
||||
mod runtime;
|
||||
mod secret;
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "the private custody seam is activated by platform credential adapters"
|
||||
)]
|
||||
mod secure_secret;
|
||||
mod targeted_transfer;
|
||||
mod ticket;
|
||||
mod transfer_state;
|
||||
mod util;
|
||||
|
||||
pub use api::{
|
||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config, CoreEvent,
|
||||
CoreEventSink, CoreLimits, CoreNetworkConfig, CoreRelayMode, CoreStorageUsage, PublishedOutput,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest,
|
||||
RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource, SourceKind, StoredTransfer,
|
||||
clear_inactive_transfer_cache, default_core_limits, default_core_network_config,
|
||||
saved_device_capabilities, CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig,
|
||||
CoreRelayMode, CoreStorageUsage, DeviceRelationship, DeviceRelationshipState,
|
||||
PairingEligibilitySummary, PendingTargetedOffer, PublishedOutput, ReceiveOutputSink,
|
||||
ReceiveOutputSinkV2, ReceivedArtifact, ReceivedLocatorKind, ReceiverRequest, RuntimeStatus,
|
||||
SavedDevice, SavedDeviceCapabilities, ShareMetadataInput, ShareResult, ShareSource, SourceKind,
|
||||
StoredTransfer, TargetedOfferResponse, TargetedTransfer, TargetedTransferState,
|
||||
TicketInspection, TransferAccessMode, TransferMetadata,
|
||||
};
|
||||
pub use error::VnidropError;
|
||||
|
||||
398
crates/vnidrop/src/pairing_eligibility/mod.rs
Normal file
398
crates/vnidrop/src/pairing_eligibility/mod.rs
Normal file
@@ -0,0 +1,398 @@
|
||||
//! Pairing eligibility after completed authenticated invitation transfers.
|
||||
//!
|
||||
//! The capability is derived from the shared approval session token and becomes
|
||||
//! usable only after the transfer reaches a durable completed state. Public APIs
|
||||
//! expose eligibility state, never the capability bytes.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::json;
|
||||
|
||||
mod store;
|
||||
|
||||
pub(crate) use store::PairingEligibilityStore;
|
||||
|
||||
use crate::{
|
||||
api::{saved_device_capabilities, PairingEligibilitySummary},
|
||||
device_relationship::DeviceRelationshipStore,
|
||||
error::VnidropError,
|
||||
event_hub::EventHub,
|
||||
secure_secret::{SecretCustody, SecretHandle, SecretKind, SecretMaterial},
|
||||
util::now_ms,
|
||||
};
|
||||
|
||||
const ELIGIBILITY_TTL_MS: i64 = 24 * 60 * 60 * 1_000;
|
||||
const CAPABILITY_CONTEXT: &str = "vnidrop-pairing-eligibility-v1";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PairingEligibilityService {
|
||||
store: PairingEligibilityStore,
|
||||
relationships: DeviceRelationshipStore,
|
||||
custody: Option<Arc<SecretCustody>>,
|
||||
event_hub: Arc<EventHub>,
|
||||
local_endpoint_id: String,
|
||||
}
|
||||
|
||||
impl PairingEligibilityService {
|
||||
pub(crate) fn new(
|
||||
store: PairingEligibilityStore,
|
||||
relationships: DeviceRelationshipStore,
|
||||
custody: Option<Arc<SecretCustody>>,
|
||||
event_hub: Arc<EventHub>,
|
||||
local_endpoint_id: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
relationships,
|
||||
custody,
|
||||
event_hub,
|
||||
local_endpoint_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes orphaned eligibility secrets and rows whose secrets are missing.
|
||||
pub(crate) async fn reconcile(&self) -> Result<(), VnidropError> {
|
||||
let records = self.store.list_records().await?;
|
||||
let mut referenced = HashSet::new();
|
||||
for entry in records {
|
||||
referenced.insert(entry.secret_handle.clone());
|
||||
let Some(custody) = &self.custody else {
|
||||
continue;
|
||||
};
|
||||
let handle = SecretHandle::from_stored(entry.secret_handle.clone());
|
||||
if custody.load(&handle).await.is_err() {
|
||||
self.delete_entry_silent(&entry).await?;
|
||||
}
|
||||
}
|
||||
if let Some(custody) = &self.custody {
|
||||
for handle in custody
|
||||
.list_active_handles(SecretKind::PairingEligibility)
|
||||
.await?
|
||||
{
|
||||
if !referenced.contains(handle.as_str()) {
|
||||
let _ = custody.remove(&handle).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
self.expire_due(true).await
|
||||
}
|
||||
|
||||
pub(crate) async fn list(&self) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
|
||||
self.expire_due(true).await?;
|
||||
self.store.list_summaries().await
|
||||
}
|
||||
|
||||
/// Activates eligibility after a durable completed authenticated transfer.
|
||||
pub(crate) async fn activate_after_completed_transfer(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
remote_display_name: Option<&str>,
|
||||
session_id: &str,
|
||||
approval_token: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
let Some(custody) = &self.custody else {
|
||||
return Ok(());
|
||||
};
|
||||
if peer_endpoint_id.is_empty() || session_id.is_empty() || approval_token.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let remote_display_name = remote_display_name
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty());
|
||||
let authenticated_at = now_ms();
|
||||
if self
|
||||
.relationships
|
||||
.refresh_authenticated_peer(peer_endpoint_id, remote_display_name, authenticated_at)
|
||||
.await?
|
||||
{
|
||||
self.remove_for_peer(peer_endpoint_id).await?;
|
||||
return Ok(());
|
||||
}
|
||||
if self.store.find_by_session(session_id).await?.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let protocol_version = saved_device_capabilities().relationship_protocol_version;
|
||||
let capability = derive_capability(
|
||||
approval_token,
|
||||
&self.local_endpoint_id,
|
||||
peer_endpoint_id,
|
||||
session_id,
|
||||
protocol_version,
|
||||
)?;
|
||||
// Credential custody already stages then activates the secret. Domain
|
||||
// metadata is written only after that verify; a crash leaves an orphan
|
||||
// secret that reconcile() removes on the next start.
|
||||
let handle = custody
|
||||
.protect(SecretKind::PairingEligibility, capability, None)
|
||||
.await?;
|
||||
let created_at = authenticated_at;
|
||||
let expires_at = created_at + ELIGIBILITY_TTL_MS;
|
||||
if let Err(error) = self
|
||||
.store
|
||||
.insert(PairingEligibilityInsert {
|
||||
peer_endpoint_id,
|
||||
remote_display_name,
|
||||
session_id,
|
||||
protocol_version,
|
||||
secret_handle: handle.as_str(),
|
||||
created_at,
|
||||
expires_at,
|
||||
})
|
||||
.await
|
||||
{
|
||||
let _ = custody.remove(&handle).await;
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
self.event_hub.emit_endpoint(
|
||||
"pairing",
|
||||
"eligibility-available",
|
||||
json!({
|
||||
"peer_endpoint_id": peer_endpoint_id,
|
||||
"session_id": session_id,
|
||||
"protocol_version": protocol_version,
|
||||
"expires_at": expires_at,
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Starts a local pairing attempt when eligibility exists.
|
||||
///
|
||||
/// Returns `false` when eligibility is missing/expired (silent reject). A
|
||||
/// successful start consumes the single-use eligibility for that session.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "retained for eligibility-only callers; mutual consent uses take_eligibility"
|
||||
)]
|
||||
pub(crate) async fn request_pairing(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<bool, VnidropError> {
|
||||
Ok(self.take_eligibility(peer_endpoint_id).await?.is_some())
|
||||
}
|
||||
|
||||
/// Takes and consumes eligibility for a peer, returning the capability material.
|
||||
pub(crate) async fn take_eligibility(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Option<TakenEligibility>, VnidropError> {
|
||||
self.expire_due(true).await?;
|
||||
let entries = self.store.list_for_peer(peer_endpoint_id).await?;
|
||||
let Some(entry) = entries.into_iter().next() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.expires_at <= now_ms() {
|
||||
self.delete_entry_silent(&entry).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(custody) = &self.custody else {
|
||||
self.delete_entry_silent(&entry).await?;
|
||||
return Ok(None);
|
||||
};
|
||||
let capability = match custody
|
||||
.load(&SecretHandle::from_stored(entry.secret_handle.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(material) => material,
|
||||
Err(_) => {
|
||||
self.delete_entry_silent(&entry).await?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
self.delete_entry(&entry).await?;
|
||||
Ok(Some(TakenEligibility {
|
||||
session_id: entry.session_id,
|
||||
protocol_version: entry.protocol_version,
|
||||
remote_display_name: entry.remote_display_name,
|
||||
authenticated_at: entry.created_at,
|
||||
capability,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Validates an inbound eligibility presentation without prompts or events on failure.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "inbound pairing wire acceptance lands with mutual-consent ticket 08"
|
||||
)]
|
||||
pub(crate) async fn accept_presented_eligibility(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
session_id: &str,
|
||||
capability: &SecretMaterial,
|
||||
) -> Result<bool, VnidropError> {
|
||||
let Some(entry) = self
|
||||
.validate_presented_capability(peer_endpoint_id, session_id, capability)
|
||||
.await?
|
||||
else {
|
||||
return Ok(false);
|
||||
};
|
||||
self.delete_entry(&entry).await?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Consumes eligibility for one session without requiring the capability bytes.
|
||||
pub(crate) async fn consume_session(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
session_id: &str,
|
||||
) -> Result<(), VnidropError> {
|
||||
if let Some(entry) = self.store.find_by_session(session_id).await? {
|
||||
if entry.peer_endpoint_id == peer_endpoint_id {
|
||||
self.delete_entry(&entry).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn decline(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
|
||||
self.remove_for_peer(peer_endpoint_id).await
|
||||
}
|
||||
|
||||
pub(crate) async fn remove_for_peer(&self, peer_endpoint_id: &str) -> Result<(), VnidropError> {
|
||||
let entries = self.store.list_for_peer(peer_endpoint_id).await?;
|
||||
for entry in entries {
|
||||
self.delete_entry(&entry).await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the matching record when the capability is valid; otherwise `None`
|
||||
/// without emitting prompts or eligibility-removed events for the reject path.
|
||||
#[allow(
|
||||
dead_code,
|
||||
reason = "inbound pairing wire acceptance lands with mutual-consent ticket 08"
|
||||
)]
|
||||
pub(crate) async fn validate_presented_capability(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
session_id: &str,
|
||||
capability: &SecretMaterial,
|
||||
) -> Result<Option<PairingEligibilityRecord>, VnidropError> {
|
||||
self.expire_due(false).await?;
|
||||
let Some(custody) = &self.custody else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(entry) = self.store.find_by_session(session_id).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
if entry.peer_endpoint_id != peer_endpoint_id || entry.expires_at <= now_ms() {
|
||||
return Ok(None);
|
||||
}
|
||||
let stored = match custody
|
||||
.load(&SecretHandle::from_stored(entry.secret_handle.clone()))
|
||||
.await
|
||||
{
|
||||
Ok(material) => material,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if stored == *capability {
|
||||
Ok(Some(entry))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
async fn expire_due(&self, emit_events: bool) -> Result<(), VnidropError> {
|
||||
let now = now_ms();
|
||||
let expired = self.store.list_expired(now).await?;
|
||||
for entry in expired {
|
||||
if emit_events {
|
||||
self.delete_entry(&entry).await?;
|
||||
} else {
|
||||
self.delete_entry_silent(&entry).await?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn delete_entry(&self, entry: &PairingEligibilityRecord) -> Result<(), VnidropError> {
|
||||
self.delete_entry_silent(entry).await?;
|
||||
self.event_hub.emit_endpoint(
|
||||
"pairing",
|
||||
"eligibility-removed",
|
||||
json!({
|
||||
"peer_endpoint_id": entry.peer_endpoint_id,
|
||||
"session_id": entry.session_id,
|
||||
}),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn force_expiry_for_test(
|
||||
&self,
|
||||
session_id: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.store
|
||||
.force_expiry_for_test(session_id, expires_at)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn delete_entry_silent(
|
||||
&self,
|
||||
entry: &PairingEligibilityRecord,
|
||||
) -> Result<(), VnidropError> {
|
||||
if let Some(custody) = &self.custody {
|
||||
let handle = SecretHandle::from_stored(entry.secret_handle.clone());
|
||||
let _ = custody.remove(&handle).await;
|
||||
}
|
||||
self.store.delete(&entry.session_id).await
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct PairingEligibilityInsert<'a> {
|
||||
pub(crate) peer_endpoint_id: &'a str,
|
||||
pub(crate) remote_display_name: Option<&'a str>,
|
||||
pub(crate) session_id: &'a str,
|
||||
pub(crate) protocol_version: u16,
|
||||
pub(crate) secret_handle: &'a str,
|
||||
pub(crate) created_at: i64,
|
||||
pub(crate) expires_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct TakenEligibility {
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) protocol_version: u16,
|
||||
pub(crate) remote_display_name: Option<String>,
|
||||
pub(crate) authenticated_at: i64,
|
||||
pub(crate) capability: SecretMaterial,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PairingEligibilityRecord {
|
||||
pub(crate) peer_endpoint_id: String,
|
||||
pub(crate) remote_display_name: Option<String>,
|
||||
pub(crate) session_id: String,
|
||||
pub(crate) protocol_version: u16,
|
||||
pub(crate) secret_handle: String,
|
||||
pub(crate) created_at: i64,
|
||||
pub(crate) expires_at: i64,
|
||||
}
|
||||
|
||||
fn derive_capability(
|
||||
approval_token: &str,
|
||||
local_endpoint_id: &str,
|
||||
peer_endpoint_id: &str,
|
||||
session_id: &str,
|
||||
protocol_version: u16,
|
||||
) -> Result<SecretMaterial, VnidropError> {
|
||||
let mut endpoints = [local_endpoint_id, peer_endpoint_id];
|
||||
endpoints.sort_unstable();
|
||||
let mut hasher = blake3::Hasher::new_derive_key(CAPABILITY_CONTEXT);
|
||||
hasher.update(approval_token.as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update(endpoints[0].as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update(endpoints[1].as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update(session_id.as_bytes());
|
||||
hasher.update(&[0]);
|
||||
hasher.update(&protocol_version.to_le_bytes());
|
||||
let bytes = *hasher.finalize().as_bytes();
|
||||
SecretMaterial::new(bytes.to_vec())
|
||||
}
|
||||
216
crates/vnidrop/src/pairing_eligibility/store.rs
Normal file
216
crates/vnidrop/src/pairing_eligibility/store.rs
Normal file
@@ -0,0 +1,216 @@
|
||||
//! Durable pairing-eligibility rows (schema + queries).
|
||||
|
||||
use sqlx::{Row, SqlitePool};
|
||||
|
||||
use crate::{api::PairingEligibilitySummary, error::VnidropError};
|
||||
|
||||
use super::{PairingEligibilityInsert, PairingEligibilityRecord};
|
||||
|
||||
/// Domain store for `pairing_eligibilities`.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct PairingEligibilityStore {
|
||||
pool: SqlitePool,
|
||||
}
|
||||
|
||||
impl PairingEligibilityStore {
|
||||
pub(crate) fn new(pool: SqlitePool) -> Self {
|
||||
Self { pool }
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_schema(pool: &SqlitePool) -> anyhow::Result<()> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE IF NOT EXISTS pairing_eligibilities (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
peer_endpoint_id TEXT NOT NULL,
|
||||
remote_display_name TEXT,
|
||||
protocol_version INTEGER NOT NULL,
|
||||
secret_handle TEXT NOT NULL UNIQUE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
let columns = sqlx::query("PRAGMA table_info(pairing_eligibilities)")
|
||||
.fetch_all(pool)
|
||||
.await?;
|
||||
if !columns
|
||||
.iter()
|
||||
.any(|row| row.get::<String, _>(1) == "remote_display_name")
|
||||
{
|
||||
sqlx::query("ALTER TABLE pairing_eligibilities ADD COLUMN remote_display_name TEXT")
|
||||
.execute(pool)
|
||||
.await?;
|
||||
}
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE INDEX IF NOT EXISTS pairing_eligibilities_peer
|
||||
ON pairing_eligibilities(peer_endpoint_id);
|
||||
"#,
|
||||
)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn insert(
|
||||
&self,
|
||||
entry: PairingEligibilityInsert<'_>,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO pairing_eligibilities (
|
||||
session_id, peer_endpoint_id, remote_display_name, protocol_version,
|
||||
secret_handle, created_at, expires_at
|
||||
) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
"#,
|
||||
)
|
||||
.bind(entry.session_id)
|
||||
.bind(entry.peer_endpoint_id)
|
||||
.bind(entry.remote_display_name)
|
||||
.bind(i64::from(entry.protocol_version))
|
||||
.bind(entry.secret_handle)
|
||||
.bind(entry.created_at)
|
||||
.bind(entry.expires_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_summaries(
|
||||
&self,
|
||||
) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, remote_display_name, session_id, protocol_version,
|
||||
created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
ORDER BY created_at DESC
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows
|
||||
.into_iter()
|
||||
.map(|row| PairingEligibilitySummary {
|
||||
peer_endpoint_id: row.get("peer_endpoint_id"),
|
||||
remote_display_name: row.get("remote_display_name"),
|
||||
session_id: row.get("session_id"),
|
||||
protocol_version: row.get::<i64, _>("protocol_version") as u16,
|
||||
created_at: row.get("created_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_records(&self) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, remote_display_name, session_id, protocol_version,
|
||||
secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
"#,
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_record).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_for_peer(
|
||||
&self,
|
||||
peer_endpoint_id: &str,
|
||||
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, remote_display_name, session_id, protocol_version,
|
||||
secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE peer_endpoint_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(peer_endpoint_id)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_record).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn list_expired(
|
||||
&self,
|
||||
now_ms: i64,
|
||||
) -> Result<Vec<PairingEligibilityRecord>, VnidropError> {
|
||||
let rows = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, remote_display_name, session_id, protocol_version,
|
||||
secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE expires_at <= ?1
|
||||
"#,
|
||||
)
|
||||
.bind(now_ms)
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(rows.into_iter().map(row_to_record).collect())
|
||||
}
|
||||
|
||||
pub(crate) async fn find_by_session(
|
||||
&self,
|
||||
session_id: &str,
|
||||
) -> Result<Option<PairingEligibilityRecord>, VnidropError> {
|
||||
let row = sqlx::query(
|
||||
r#"
|
||||
SELECT peer_endpoint_id, remote_display_name, session_id, protocol_version,
|
||||
secret_handle, created_at, expires_at
|
||||
FROM pairing_eligibilities
|
||||
WHERE session_id = ?1
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(row.map(row_to_record))
|
||||
}
|
||||
|
||||
pub(crate) async fn delete(&self, session_id: &str) -> Result<(), VnidropError> {
|
||||
sqlx::query("DELETE FROM pairing_eligibilities WHERE session_id = ?1")
|
||||
.bind(session_id)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) async fn force_expiry_for_test(
|
||||
&self,
|
||||
session_id: &str,
|
||||
expires_at: i64,
|
||||
) -> Result<(), VnidropError> {
|
||||
sqlx::query("UPDATE pairing_eligibilities SET expires_at = ?2 WHERE session_id = ?1")
|
||||
.bind(session_id)
|
||||
.bind(expires_at)
|
||||
.execute(&self.pool)
|
||||
.await
|
||||
.map_err(VnidropError::repository)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn row_to_record(row: sqlx::sqlite::SqliteRow) -> PairingEligibilityRecord {
|
||||
PairingEligibilityRecord {
|
||||
peer_endpoint_id: row.get("peer_endpoint_id"),
|
||||
remote_display_name: row.get("remote_display_name"),
|
||||
session_id: row.get("session_id"),
|
||||
protocol_version: row.get::<i64, _>("protocol_version") as u16,
|
||||
secret_handle: row.get("secret_handle"),
|
||||
created_at: row.get("created_at"),
|
||||
expires_at: row.get("expires_at"),
|
||||
}
|
||||
}
|
||||
72
crates/vnidrop/src/persistence.rs
Normal file
72
crates/vnidrop/src/persistence.rs
Normal file
@@ -0,0 +1,72 @@
|
||||
//! Persistence open: one SQLite pool, every domain schema, [`AppDataStores`].
|
||||
//!
|
||||
//! Runtime talks to domain stores — not a raw pool. Schema application for each
|
||||
//! domain is owned here (not orchestrated from the invitation store).
|
||||
|
||||
use std::{path::Path, str::FromStr};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions};
|
||||
|
||||
use crate::{
|
||||
blocked_devices::{self, BlockStore},
|
||||
device_relationship::DeviceRelationshipStore,
|
||||
invitation::Repository,
|
||||
pairing_eligibility::PairingEligibilityStore,
|
||||
secure_secret::{self, SecretMetadataStore},
|
||||
targeted_transfer::{self, TargetedTransferStore},
|
||||
};
|
||||
|
||||
/// Concrete domain stores for one app-data profile.
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AppDataStores {
|
||||
/// Invitation-transfer history and related invitation tables.
|
||||
pub(crate) invitation: Repository,
|
||||
/// Targeted-transfer durable rows.
|
||||
pub(crate) targeted: TargetedTransferStore,
|
||||
/// Mutual-consent device relationships (+ generation tombstones).
|
||||
pub(crate) relationships: DeviceRelationshipStore,
|
||||
/// Post-transfer pairing eligibility rows.
|
||||
pub(crate) eligibility: PairingEligibilityStore,
|
||||
/// Non-secret metadata for protected credential handles.
|
||||
pub(crate) secrets: SecretMetadataStore,
|
||||
/// Identity-wide deny list.
|
||||
pub(crate) blocked: BlockStore,
|
||||
}
|
||||
|
||||
/// Create the profile pool, apply all domain schemas, return [`AppDataStores`].
|
||||
pub(crate) async fn open_all(app_data_dir: &Path) -> Result<AppDataStores> {
|
||||
let db_path = app_data_dir.join("vnidrop.sqlite3");
|
||||
let options = SqliteConnectOptions::from_str("sqlite://")?
|
||||
.filename(db_path)
|
||||
.create_if_missing(true);
|
||||
let pool = SqlitePoolOptions::new()
|
||||
.max_connections(4)
|
||||
.connect_with(options)
|
||||
.await
|
||||
.context("failed to open app data sqlite")?;
|
||||
|
||||
// Unreleased device-history prototype tables — no migration path.
|
||||
for table in ["held_offers", "grants_held", "grants_issued", "contacts"] {
|
||||
sqlx::query(&format!("DROP TABLE IF EXISTS {table}"))
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
}
|
||||
|
||||
let invitation = Repository::from_pool(pool.clone());
|
||||
invitation.ensure_schema().await?;
|
||||
blocked_devices::ensure_schema(&pool).await?;
|
||||
secure_secret::ensure_schema(&pool).await?;
|
||||
DeviceRelationshipStore::ensure_schema(&pool).await?;
|
||||
targeted_transfer::ensure_schema(&pool).await?;
|
||||
PairingEligibilityStore::ensure_schema(&pool).await?;
|
||||
|
||||
Ok(AppDataStores {
|
||||
targeted: TargetedTransferStore::new(pool.clone()),
|
||||
relationships: DeviceRelationshipStore::new(pool.clone()),
|
||||
eligibility: PairingEligibilityStore::new(pool.clone()),
|
||||
secrets: SecretMetadataStore::new(pool.clone()),
|
||||
blocked: BlockStore::new(pool),
|
||||
invitation,
|
||||
})
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use crate::{
|
||||
handshake::{
|
||||
DeliveryFailureReceipt, DeliveryReceipt, DeliveryReceiptResponse, HandshakeService,
|
||||
},
|
||||
repository::PendingDeliveryReceipt,
|
||||
invitation::PendingDeliveryReceipt,
|
||||
ticket::parse_persisted_sender_address,
|
||||
};
|
||||
|
||||
|
||||
@@ -3,20 +3,24 @@ use std::{future::Future, path::PathBuf, sync::Arc};
|
||||
use anyhow::Context;
|
||||
use serde_json::json;
|
||||
|
||||
use super::CoreInner;
|
||||
use super::{CoreInner, IdentityMode};
|
||||
use crate::{
|
||||
api::{
|
||||
CoreEvent, CoreEventSink, CoreLimits, CoreNetworkConfig, CoreStorageUsage,
|
||||
ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact, ReceiverRequest, RuntimeStatus,
|
||||
ShareMetadataInput, ShareResult, ShareSource, StoredTransfer, TicketInspection,
|
||||
TransferAccessMode,
|
||||
PairingEligibilitySummary, ReceiveOutputSink, ReceiveOutputSinkV2, ReceivedArtifact,
|
||||
ReceiverRequest, RuntimeStatus, ShareMetadataInput, ShareResult, ShareSource,
|
||||
StoredTransfer, TicketInspection, TransferAccessMode,
|
||||
},
|
||||
error::VnidropError,
|
||||
filesystem::platform_path,
|
||||
secure_secret::{lock_profile, platform_secret_store},
|
||||
ticket::parse_transfer_ticket_with_limits,
|
||||
transfer_state::{TransferDirection, TransferStatus},
|
||||
};
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::secure_secret::unlocked_profile_for_test;
|
||||
|
||||
#[derive(uniffi::Object)]
|
||||
pub struct VnidropCore {
|
||||
runtime: tokio::runtime::Runtime,
|
||||
@@ -24,6 +28,30 @@ pub struct VnidropCore {
|
||||
}
|
||||
|
||||
impl VnidropCore {
|
||||
fn initialize_protected(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
let app_data_path = PathBuf::from(app_data_dir);
|
||||
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
||||
let app_data_path =
|
||||
std::fs::canonicalize(app_data_path).map_err(VnidropError::filesystem)?;
|
||||
let profile_lock = lock_profile(&app_data_path)?;
|
||||
let store = platform_secret_store(&app_data_path)?;
|
||||
Self::initialize_with_identity_mode(
|
||||
app_data_path.to_string_lossy().into_owned(),
|
||||
event_sink,
|
||||
limits,
|
||||
network_config,
|
||||
IdentityMode::Protected {
|
||||
store,
|
||||
profile_lock,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/// Drive work on this core's multi-thread runtime from a sync API boundary.
|
||||
///
|
||||
/// Uses [`tokio::runtime::Handle::block_on`] rather than exclusive
|
||||
@@ -34,6 +62,386 @@ impl VnidropCore {
|
||||
fn block_on<F: Future>(&self, future: F) -> F::Output {
|
||||
self.runtime.handle().block_on(future)
|
||||
}
|
||||
|
||||
fn initialize_with_identity_mode(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
identity_mode: IdentityMode,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
limits.validate().map_err(VnidropError::initialization)?;
|
||||
let relay_urls = network_config
|
||||
.validated_relay_urls()
|
||||
.map_err(VnidropError::initialization)?;
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.thread_name("vnidrop")
|
||||
.build()?;
|
||||
let inner = runtime
|
||||
.block_on(CoreInner::start(
|
||||
PathBuf::from(app_data_dir),
|
||||
event_sink,
|
||||
limits,
|
||||
network_config.mode,
|
||||
relay_urls,
|
||||
identity_mode,
|
||||
))
|
||||
.map_err(|error| match error.downcast::<VnidropError>() {
|
||||
Ok(error) => error,
|
||||
Err(error) => VnidropError::initialization(error),
|
||||
})?;
|
||||
Ok(Arc::new(Self { runtime, inner }))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(feature = "integration-test-store", debug_assertions))]
|
||||
impl VnidropCore {
|
||||
/// Non-production Rust test harness entry that selects protected in-memory custody.
|
||||
#[doc(hidden)]
|
||||
pub fn initialize_for_integration_test(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
let path = std::fs::canonicalize(&app_data_dir)
|
||||
.or_else(|_| {
|
||||
std::fs::create_dir_all(&app_data_dir)?;
|
||||
std::fs::canonicalize(&app_data_dir)
|
||||
})
|
||||
.map_err(VnidropError::filesystem)?;
|
||||
crate::secure_secret::install_platform_secret_store_for_test(
|
||||
&path,
|
||||
Arc::new(crate::secure_secret::FaultInjectingSecretStore::default()),
|
||||
);
|
||||
Self::initialize_with_limits_and_network_config(
|
||||
path.to_string_lossy().into_owned(),
|
||||
event_sink,
|
||||
limits,
|
||||
network_config,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl VnidropCore {
|
||||
/// Test-only protected identity with an injected secret store.
|
||||
pub(crate) fn initialize_with_test_secret_store(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
Self::initialize_with_test_secret_store_and_network(
|
||||
app_data_dir,
|
||||
event_sink,
|
||||
store,
|
||||
CoreNetworkConfig::default(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_with_test_secret_store_and_network(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
|
||||
network_config: CoreNetworkConfig,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
Self::initialize_with_test_secret_store_limits_and_network(
|
||||
app_data_dir,
|
||||
event_sink,
|
||||
store,
|
||||
CoreLimits::default(),
|
||||
network_config,
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn targeted_blob_ticket_for_test(
|
||||
&self,
|
||||
id: String,
|
||||
) -> Result<(u64, String), VnidropError> {
|
||||
self.block_on(async {
|
||||
let row = self
|
||||
.inner
|
||||
.targeted_store()
|
||||
.get_row(&id)
|
||||
.await?
|
||||
.ok_or_else(|| VnidropError::invalid_input(anyhow::anyhow!("unknown transfer")))?;
|
||||
let encoded = self
|
||||
.inner
|
||||
.load_stored_authorization(&row)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
VnidropError::invalid_input(anyhow::anyhow!("authorization unavailable"))
|
||||
})?;
|
||||
Ok((
|
||||
row.protocol_transfer_id,
|
||||
crate::targeted_transfer::TargetedAuthorization::decode(&encoded)?.blob_ticket,
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn suppress_targeted_completion_for_test(&self, suppress: bool) {
|
||||
self.inner
|
||||
.suppress_targeted_completion
|
||||
.store(suppress, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub(crate) fn suppress_targeted_authorization_delivery_for_test(&self, suppress: bool) {
|
||||
self.inner
|
||||
.suppress_targeted_authorization_delivery
|
||||
.store(suppress, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
|
||||
pub(crate) fn accept_targeted_offer_without_waiting_for_test(
|
||||
&self,
|
||||
id: String,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(async {
|
||||
let offer = self
|
||||
.inner
|
||||
.targeted_offers
|
||||
.pending_for_acceptance(&id)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
VnidropError::invalid_input(anyhow::anyhow!("unknown targeted offer"))
|
||||
})?;
|
||||
self.inner
|
||||
.targeted_store()
|
||||
.persist_accepted_offer_intent(&offer)
|
||||
.await?;
|
||||
self.inner
|
||||
.targeted_offers
|
||||
.accept_live(&id)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
VnidropError::device_unavailable(anyhow::anyhow!(
|
||||
"sender disconnected before acceptance"
|
||||
))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn persist_block_without_cleanup_for_test(
|
||||
&self,
|
||||
endpoint_id: String,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(async {
|
||||
self.inner
|
||||
.blocked_devices
|
||||
.block_endpoint(&endpoint_id, crate::util::now_ms())
|
||||
.await
|
||||
.map_err(VnidropError::repository)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn corrupt_targeted_content_hash_for_test(
|
||||
&self,
|
||||
id: String,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.targeted_store()
|
||||
.corrupt_content_hash_for_test(&id),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn create_orphaned_targeted_authorization_for_test(
|
||||
&self,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(async {
|
||||
let custody = self.inner.secret_custody.as_ref().ok_or_else(|| {
|
||||
VnidropError::SecureStorageUnavailable {
|
||||
reason: "test custody unavailable".to_string(),
|
||||
}
|
||||
})?;
|
||||
custody
|
||||
.create_orphaned_targeted_authorization_for_test()
|
||||
.await
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn targeted_authorization_handle_count_for_test(
|
||||
&self,
|
||||
) -> Result<usize, VnidropError> {
|
||||
self.block_on(async {
|
||||
let custody = self.inner.secret_custody.as_ref().ok_or_else(|| {
|
||||
VnidropError::SecureStorageUnavailable {
|
||||
reason: "test custody unavailable".to_string(),
|
||||
}
|
||||
})?;
|
||||
Ok(custody
|
||||
.list_active_handles(crate::secure_secret::SecretKind::TargetedAuthorization)
|
||||
.await?
|
||||
.len())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn redeliver_targeted_authorization_for_test(
|
||||
&self,
|
||||
id: String,
|
||||
) -> Result<bool, VnidropError> {
|
||||
self.block_on(async {
|
||||
let row = self
|
||||
.inner
|
||||
.targeted_store()
|
||||
.get_row(&id)
|
||||
.await?
|
||||
.ok_or_else(|| VnidropError::invalid_input(anyhow::anyhow!("unknown transfer")))?;
|
||||
self.inner.deliver_stored_targeted_authorization(&row).await
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn targeted_authorization_delivery_attempts_for_test(&self) -> u64 {
|
||||
self.inner
|
||||
.targeted_authorization_delivery_attempts
|
||||
.load(std::sync::atomic::Ordering::SeqCst)
|
||||
}
|
||||
|
||||
pub(crate) fn hold_all_transfer_slots_for_test(&self) -> tokio::sync::oneshot::Sender<()> {
|
||||
let inner = self.inner.clone();
|
||||
let permits = inner.limits.max_concurrent_transfers as u32;
|
||||
let (ready_tx, ready_rx) = std::sync::mpsc::sync_channel(1);
|
||||
let (release_tx, release_rx) = tokio::sync::oneshot::channel();
|
||||
self.runtime.handle().spawn(async move {
|
||||
let _permits = inner
|
||||
.transfer_slots
|
||||
.acquire_many(permits)
|
||||
.await
|
||||
.expect("transfer limiter open");
|
||||
ready_tx.send(()).expect("slot holder ready");
|
||||
let _ = release_rx.await;
|
||||
});
|
||||
ready_rx.recv().expect("slot holder started");
|
||||
release_tx
|
||||
}
|
||||
|
||||
pub(crate) fn targeted_payload_is_registered_for_test(
|
||||
&self,
|
||||
id: String,
|
||||
) -> Result<bool, VnidropError> {
|
||||
self.block_on(async {
|
||||
let row = self
|
||||
.inner
|
||||
.targeted_store()
|
||||
.get_row(&id)
|
||||
.await?
|
||||
.ok_or_else(|| VnidropError::invalid_input(anyhow::anyhow!("unknown transfer")))?;
|
||||
let hash = row
|
||||
.content_hash
|
||||
.parse::<iroh_blobs::Hash>()
|
||||
.map_err(|error| VnidropError::invalid_input(anyhow::anyhow!(error)))?;
|
||||
Ok(self
|
||||
.inner
|
||||
.transfer_ids_for_hash(hash)
|
||||
.await
|
||||
.contains(&row.protocol_transfer_id))
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn initialize_with_test_secret_store_limits_and_network(
|
||||
app_data_dir: String,
|
||||
event_sink: Arc<dyn CoreEventSink>,
|
||||
store: Arc<dyn crate::secure_secret::SecureSecretStore>,
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
let app_data_path = PathBuf::from(&app_data_dir);
|
||||
std::fs::create_dir_all(&app_data_path).map_err(VnidropError::filesystem)?;
|
||||
// In-process restart tests reopen the same directory immediately after
|
||||
// drop; skip exclusive locking and rely on the injected store instead.
|
||||
let profile_lock = unlocked_profile_for_test(&app_data_path)?;
|
||||
Self::initialize_with_identity_mode(
|
||||
app_data_dir,
|
||||
event_sink,
|
||||
limits,
|
||||
network_config,
|
||||
IdentityMode::Protected {
|
||||
store,
|
||||
profile_lock,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn force_pairing_eligibility_expiry_for_test(
|
||||
&self,
|
||||
session_id: String,
|
||||
expires_at: i64,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.pairing_eligibility
|
||||
.force_expiry_for_test(&session_id, expires_at),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn submit_pairing_eligibility_for_test(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
session_id: String,
|
||||
capability: Vec<u8>,
|
||||
) -> Result<bool, VnidropError> {
|
||||
self.block_on(self.inner.submit_pairing_eligibility_for_test(
|
||||
peer_endpoint_id,
|
||||
session_id,
|
||||
capability,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn relationship_issued_grant_for_test(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
) -> Result<Option<(u64, String)>, VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.device_relationships
|
||||
.issued_grant_snapshot(&peer_endpoint_id),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn relationship_tombstones_for_test(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
) -> Result<Vec<crate::device_relationship::GenerationTombstone>, VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.device_relationships
|
||||
.list_tombstones(&peer_endpoint_id),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn reject_relationship_generation_for_test(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
generation: u64,
|
||||
grant_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
self.block_on(async {
|
||||
self.inner
|
||||
.device_relationships
|
||||
.reject_replayed_generation(&peer_endpoint_id, generation, grant_id.as_deref())
|
||||
.await
|
||||
.map_err(|rejection| rejection.as_str().to_string())
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn targeted_cancel_log_for_test(&self) -> Vec<String> {
|
||||
self.inner.targeted_cancel_log_for_test()
|
||||
}
|
||||
|
||||
pub(crate) fn force_relationship_protocol_floor_for_test(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
minimum_protocol_version: u16,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.device_relationships
|
||||
.force_minimum_protocol_version_for_test(
|
||||
&peer_endpoint_id,
|
||||
minimum_protocol_version,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
#[uniffi::export]
|
||||
@@ -86,25 +494,7 @@ impl VnidropCore {
|
||||
limits: CoreLimits,
|
||||
network_config: CoreNetworkConfig,
|
||||
) -> Result<Arc<Self>, VnidropError> {
|
||||
limits.validate().map_err(VnidropError::initialization)?;
|
||||
let relay_urls = network_config
|
||||
.validated_relay_urls()
|
||||
.map_err(VnidropError::initialization)?;
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.enable_all()
|
||||
.thread_name("vnidrop")
|
||||
.build()?;
|
||||
let app_data_dir = PathBuf::from(app_data_dir);
|
||||
let inner = runtime
|
||||
.block_on(CoreInner::start(
|
||||
app_data_dir,
|
||||
event_sink,
|
||||
limits,
|
||||
network_config.mode,
|
||||
relay_urls,
|
||||
))
|
||||
.map_err(VnidropError::initialization)?;
|
||||
Ok(Arc::new(Self { runtime, inner }))
|
||||
Self::initialize_protected(app_data_dir, event_sink, limits, network_config)
|
||||
}
|
||||
|
||||
pub fn status(&self) -> RuntimeStatus {
|
||||
@@ -271,6 +661,224 @@ impl VnidropCore {
|
||||
.map_err(VnidropError::permission)
|
||||
}
|
||||
|
||||
/// Single-use pairing windows created by completed authenticated transfers.
|
||||
///
|
||||
/// Returns eligibility state only — never the capability material.
|
||||
pub fn list_pairing_eligibilities(
|
||||
&self,
|
||||
) -> Result<Vec<PairingEligibilitySummary>, VnidropError> {
|
||||
self.block_on(self.inner.list_pairing_eligibilities())
|
||||
}
|
||||
|
||||
/// Declines and removes pairing eligibility for a peer. Idempotent.
|
||||
pub fn decline_pairing_eligibility(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.decline_pairing_eligibility(peer_endpoint_id))
|
||||
}
|
||||
|
||||
/// Initiates saved-device pairing when local eligibility exists.
|
||||
///
|
||||
/// Returns `false` when eligibility is missing or already consumed. Invalid
|
||||
/// attempts produce no pairing prompt.
|
||||
pub fn request_saved_device_pairing(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
) -> Result<bool, VnidropError> {
|
||||
self.block_on(self.inner.request_saved_device_pairing(peer_endpoint_id))
|
||||
}
|
||||
|
||||
pub fn list_device_relationships(
|
||||
&self,
|
||||
) -> Result<Vec<crate::api::DeviceRelationship>, VnidropError> {
|
||||
self.block_on(self.inner.list_device_relationships())
|
||||
}
|
||||
|
||||
pub fn list_saved_devices(&self) -> Result<Vec<crate::api::SavedDevice>, VnidropError> {
|
||||
self.block_on(self.inner.list_saved_devices())
|
||||
}
|
||||
|
||||
/// Sets the user-owned local label for a Saved device.
|
||||
pub fn set_saved_device_label(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
label: Option<String>,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.device_relationships
|
||||
.set_saved_device_label(peer_endpoint_id, label),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn respond_to_device_pairing(
|
||||
&self,
|
||||
peer_endpoint_id: String,
|
||||
accepted: bool,
|
||||
) -> Result<bool, VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.respond_to_device_pairing(peer_endpoint_id, accepted),
|
||||
)
|
||||
}
|
||||
|
||||
/// Forget a saved device: revoke locally, clean secrets, cancel that
|
||||
/// relationship's targeted transfers, and best-effort notify the peer.
|
||||
pub fn forget_saved_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.forget_saved_device(peer_endpoint_id))
|
||||
}
|
||||
|
||||
/// Identity-wide deny across pairing, targeted transfer, invitation, and handshake.
|
||||
pub fn block_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.block_device(peer_endpoint_id))
|
||||
}
|
||||
|
||||
/// Remove only the deny rule; does not restore grants or relationships.
|
||||
pub fn unblock_device(&self, peer_endpoint_id: String) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.unblock_device(peer_endpoint_id))
|
||||
}
|
||||
|
||||
pub fn list_blocked_devices(&self) -> Result<Vec<String>, VnidropError> {
|
||||
self.block_on(self.inner.list_blocked_devices())
|
||||
}
|
||||
|
||||
/// Invalidate the prior relationship generation, then activate a replacement grant.
|
||||
pub fn rotate_relationship_grant(&self, peer_endpoint_id: String) -> Result<u64, VnidropError> {
|
||||
self.block_on(self.inner.rotate_relationship_grant(peer_endpoint_id))
|
||||
}
|
||||
|
||||
/// Create an immutable one-receiver transfer and submit its pre-approval offer.
|
||||
///
|
||||
/// Blocks until the saved receiver approves or declines. On approval the
|
||||
/// receiver stores bound authorization locally via
|
||||
/// [`Self::respond_to_targeted_offer`]. This path creates no invitation
|
||||
/// transfer, receiver approval request, invitation delivery receipt,
|
||||
/// received-artifact record, or pairing eligibility.
|
||||
pub fn create_targeted_transfer(
|
||||
&self,
|
||||
receiver_endpoint_id: String,
|
||||
sources: Vec<ShareSource>,
|
||||
transfer_name: Option<String>,
|
||||
) -> Result<crate::api::TargetedTransfer, VnidropError> {
|
||||
self.block_on(self.inner.create_targeted_transfer(
|
||||
receiver_endpoint_id,
|
||||
sources,
|
||||
transfer_name,
|
||||
))
|
||||
}
|
||||
|
||||
/// Ticket-free pending offers awaiting explicit local approval.
|
||||
pub fn list_pending_targeted_offers(&self) -> Vec<crate::api::PendingTargetedOffer> {
|
||||
self.block_on(self.inner.list_pending_targeted_offers())
|
||||
}
|
||||
|
||||
/// Approve or decline a pending targeted offer.
|
||||
///
|
||||
/// On approval, authorization stays in core custody; callers pull content
|
||||
/// with [`Self::receive_targeted_transfer`] using the transfer id.
|
||||
pub fn respond_to_targeted_offer(
|
||||
&self,
|
||||
transfer_id: String,
|
||||
accepted: bool,
|
||||
) -> Result<crate::api::TargetedOfferResponse, VnidropError> {
|
||||
self.block_on(self.inner.respond_to_targeted_offer(transfer_id, accepted))
|
||||
}
|
||||
|
||||
/// Pull an approved targeted transfer through existing output-sink machinery.
|
||||
pub fn receive_targeted_transfer(
|
||||
&self,
|
||||
transfer_id: String,
|
||||
output_dir: String,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.receive_targeted_transfer(transfer_id, output_dir),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn receive_targeted_transfer_with_output_sink(
|
||||
&self,
|
||||
transfer_id: String,
|
||||
output_sink: Arc<dyn ReceiveOutputSink>,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.receive_targeted_transfer_with_output_sink(transfer_id, output_sink),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn receive_targeted_transfer_with_output_sink_v2(
|
||||
&self,
|
||||
transfer_id: String,
|
||||
output_sink: Arc<dyn ReceiveOutputSinkV2>,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.receive_targeted_transfer_with_output_sink_v2(transfer_id, output_sink),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn get_targeted_transfer(
|
||||
&self,
|
||||
id: String,
|
||||
) -> Result<Option<crate::api::TargetedTransfer>, VnidropError> {
|
||||
self.block_on(self.inner.get_targeted_transfer(id))
|
||||
}
|
||||
|
||||
pub fn list_targeted_transfers(
|
||||
&self,
|
||||
) -> Result<Vec<crate::api::TargetedTransfer>, VnidropError> {
|
||||
self.block_on(self.inner.list_targeted_transfers())
|
||||
}
|
||||
|
||||
/// Withdraw an offer or revoke an approved transfer.
|
||||
///
|
||||
/// Stops active streaming synchronously before asynchronous cleanup.
|
||||
pub fn cancel_targeted_transfer(&self, id: String) -> Result<(), VnidropError> {
|
||||
let _ = self.inner.signal_targeted_transfer_cancel_by_id(&id);
|
||||
self.block_on(self.inner.cancel_targeted_transfer(id))
|
||||
}
|
||||
|
||||
/// Durably remove authorization, resumable state, and content service.
|
||||
///
|
||||
/// Local denial is mandatory even when remote cleanup fails.
|
||||
pub fn delete_targeted_transfer(&self, id: String) -> Result<(), VnidropError> {
|
||||
let _ = self.inner.signal_targeted_transfer_cancel_by_id(&id);
|
||||
self.block_on(self.inner.delete_targeted_transfer(id))
|
||||
}
|
||||
|
||||
/// Resume an approved/interrupted transfer without another approval.
|
||||
pub fn resume_targeted_transfer(
|
||||
&self,
|
||||
id: String,
|
||||
output_dir: String,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(self.inner.resume_targeted_transfer(id, output_dir))
|
||||
}
|
||||
|
||||
pub fn resume_targeted_transfer_with_output_sink(
|
||||
&self,
|
||||
id: String,
|
||||
output_sink: Arc<dyn ReceiveOutputSink>,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.resume_targeted_transfer_with_output_sink(id, output_sink),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn resume_targeted_transfer_with_output_sink_v2(
|
||||
&self,
|
||||
id: String,
|
||||
output_sink: Arc<dyn ReceiveOutputSinkV2>,
|
||||
) -> Result<(), VnidropError> {
|
||||
self.block_on(
|
||||
self.inner
|
||||
.resume_targeted_transfer_with_output_sink_v2(id, output_sink),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_transfers(&self) -> Result<Vec<StoredTransfer>, VnidropError> {
|
||||
self.block_on(self.inner.repository.list_transfers())
|
||||
.map_err(VnidropError::repository)
|
||||
|
||||
@@ -179,7 +179,7 @@ impl CoreInner {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) async fn shutdown(&self) {
|
||||
pub(crate) async fn shutdown(&self) {
|
||||
if self.shutdown_started.swap(true, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
@@ -191,6 +191,10 @@ impl CoreInner {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
if let Some(task) = self.targeted_reconciliation_task.lock().await.take() {
|
||||
task.abort();
|
||||
let _ = task.await;
|
||||
}
|
||||
if let Err(error) = self.router.shutdown().await {
|
||||
self.emit_endpoint(
|
||||
"shutdown",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user