diff --git a/.gitignore b/.gitignore
index 0956075..94459d4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -19,3 +19,6 @@ captures
node_modules/
target/
.junie
+
+# Local design export scratch
+output/
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..c210366
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,308 @@
+# AGENTS.md
+
+Operational instructions for coding agents working in this repository.
+Humans: see `README.md` for product overview and run configs.
+Agents: read this file (and the nearest nested `AGENTS.md`) before editing.
+
+Nested guides take precedence when editing under those trees:
+
+- [`crates/vnidrop/AGENTS.md`](crates/vnidrop/AGENTS.md) — Rust core
+- [`shared/AGENTS.md`](shared/AGENTS.md) — Compose Multiplatform UI / KMP
+
+---
+
+## Project overview
+
+VniDrop is a cross-platform **local P2P file transfer** app (Android, iOS, Desktop).
+
+| Layer | Path | Responsibility |
+|-------|------|----------------|
+| Rust core | `crates/vnidrop/` | Iroh endpoint, blobs, SQLite, tickets, approval, streaming |
+| Shared KMP | `shared/` | Compose UI, ViewModels, expect/actual platform bridges |
+| Hosts | `androidApp/`, `iosApp/`, `desktopApp/` | Thin app shells |
+
+**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
+Do not design features that move transfer payloads through Kotlin heap by default.
+
+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)
+
+---
+
+## Absolute rules
+
+1. Prefer PRs into `master`. 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
+ unsigned commits to “unblock” yourself.
+4. Change only files required for the task. No drive-by refactors, dependency bumps,
+ or repo-wide formatting.
+5. Do not force architecture migrations (MVI, Hilt, Nav3, etc.) unless requested.
+6. Never commit secrets, key material, or passphrases.
+7. Destructive git (`reset --hard`, `push --force`, dropping DBs) only with explicit
+ user approval.
+8. **Every bug fix includes a regression test** at the lowest layer that catches it.
+9. After code changes, run the **relevant** checks in [Build and test](#build-and-test)
+ and fix failures before finishing.
+
+---
+
+## Build and test
+
+Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17,
+Android NDK/SDK only if building Android, Xcode only for iOS.
+
+### Rust core (`crates/vnidrop` or workspace root)
+
+Run from the **repo root** (Cargo workspace):
+
+```bash
+cargo fmt --all -- --check
+cargo clippy --workspace --all-targets -- -D warnings
+cargo test --workspace --all-targets
+```
+
+Focused:
+
+```bash
+cargo test -p vnidrop
+cargo test -p vnidrop --test output_sink
+cargo test -p vnidrop --test transfer
+cargo test -p vnidrop --test approval
+cargo test -p vnidrop --test lifecycle
+```
+
+After finishing Rust edits, format:
+
+```bash
+cargo fmt --all
+```
+
+CI also runs `cargo doc --workspace --no-deps` with `RUSTDOCFLAGS=-D warnings`
+(see `.github/workflows/rust-core.yml`). Run it before large Rust public-API changes.
+
+### Shared KMP / Compose (`shared/`)
+
+```bash
+./gradlew :shared:jvmTest
+./gradlew :shared:compileKotlinJvm
+```
+
+Other targets (slower / machine-dependent):
+
+```bash
+./gradlew :shared:testAndroidHostTest
+./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode
+./gradlew :androidApp:assembleDebug
+./gradlew :desktopApp:run
+```
+
+**Note:** `jvmTest` CI runs on **macOS** because Gobley host cargo is enabled for
+the current Gobley host; Linux JVM cargo may be disabled in
+`shared/build.gradle.kts`. Prefer macOS for local parity with CI.
+
+### What to run before finishing
+
+| You changed… | Minimum verification |
+|--------------|----------------------|
+| `crates/vnidrop/**` only | `cargo fmt`, `cargo clippy … -D warnings`, `cargo test -p vnidrop` |
+| Cancel / export / sinks | Above + `cargo test -p vnidrop --test output_sink` |
+| `shared/**` only | `./gradlew :shared:jvmTest` |
+| Both | Rust suite + `:shared:jvmTest` |
+| Docs only | No suite required; verify links/paths |
+
+Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several
+minutes with no output; first builds are slow.
+
+---
+
+## Repository map (edit here)
+
+### Rust runtime (keep split; do not re-merge into one file)
+
+```
+crates/vnidrop/src/runtime/
+ mod.rs # CoreInner, startup recovery, emit helpers
+ facade.rs # UniFFI VnidropCore + block_on
+ share.rs # import / share
+ receive.rs # receive, download, export, output sinks
+ lifecycle.rs # cancel, delete, status, access mode, shutdown
+ provider.rs # provider events, per-connection send progress
+```
+
+Other core modules: `filesystem.rs`, `repository.rs`, `approval.rs`,
+`handshake.rs`, `ticket.rs`, `access_policy.rs`, `event_hub.rs`, `api.rs`.
+
+### Shared app
+
+```
+shared/src/commonMain/kotlin/com/vnidrop/app/
+ core/ # CoreGateway, models, pickers interfaces
+ feature/send|receive|approvals|settings|app/
+ ui/theme|components|navigation|feedback|state/
+androidMain|iosMain|jvmMain/ # expect/actual implementations
+```
+
+### Platform file rules (do not violate)
+
+- Desktop / path-based iOS: paths; directory walk in Rust when `is_directory`.
+- Android **share**: ParcelFileDescriptor **file** FDs only — never a directory FD.
+ Folder share expands SAF trees in Kotlin to per-file FDs + relative names.
+- Android **receive** default: MediaStore Downloads sink; custom trees via SAF write.
+- Receive publish: no-overwrite temp + hard link / exclusive rename
+ (see `CORE_FLOW.md`).
+
+---
+
+## Code style
+
+### General
+
+- Match surrounding code (naming, imports, error handling).
+- Prefer small, reviewable diffs. Avoid files growing past ~800 LoC without
+ splitting when adding substantial logic.
+- Do not add one-off helpers used only once if an inline block is clearer.
+- Prefer exhaustive `when` / `match`; avoid wildcards that hide new cases.
+
+### Comments (strict)
+
+Comment **why**, invariants, and platform/concurrency traps only.
+
+- Do comment: cancel-before-await ordering, SAF/FD limits, security-scoped
+ leases, “exactly one finish/abort after start_file”, durability rules.
+- Do **not** comment: restating the next line, tutorial narration, section
+ banners that repeat the function name, pasted docs from this file.
+
+### Rust
+
+- Follow Clippy with `-D warnings` (CI fails otherwise).
+- Do not hold `std::sync::MutexGuard` or other guards across `.await`.
+- Prefer `Handle::block_on` via existing `VnidropCore::block_on` for concurrent
+ API entry; cancel signals active transfers **synchronously** before async work.
+- Prefer private modules; export only what UniFFI / other crates need.
+- New public traits/types: short docs when the role is non-obvious.
+
+### Kotlin / Compose
+
+For UI and presentation work, **load and follow** the in-repo skill:
+
+```text
+.codex/skills/compose-skill/SKILL.md
+```
+
+- Open at most one `references/*.md` file when the skill’s Quick Routing requires it.
+- 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.
+- Theme via `LocalVniDropColors` / `VniDropThemeTokens` only.
+ Brand primary (light): HSL `271, 91%, 65%` ≈ `#A855F7`.
+- Strings: CMP `Res.string.*` / composeResources — not Android `R` in `commonMain`.
+- Verify multiplatform target support before adding AndroidX/Jetpack deps to
+ `commonMain`.
+
+Details: [`shared/AGENTS.md`](shared/AGENTS.md).
+
+---
+
+## Testing instructions
+
+- Prefer deterministic tests (gates, fixed sizes, public API fixtures).
+- Avoid long sleeps; if polling is required: short interval + hard timeout +
+ clear assertion message.
+- Rust integration tests use **public** UniFFI API + `tests/support/` only.
+- Failure paths: assert durable status and/or events when applicable, not only
+ the error string.
+- Do not add tests for pure static constants.
+- Do not add negative tests for code you deleted.
+- Prefer comparing whole objects when equality is meaningful.
+
+Layout:
+
+| Layer | Location |
+|-------|----------|
+| Rust unit / private | `crates/vnidrop/src/tests/` |
+| Rust integration | `crates/vnidrop/tests/` |
+| Shared logic | `shared/src/commonTest/` |
+| Shared Compose/JVM | `shared/src/jvmTest/` |
+
+---
+
+## Git and PR instructions
+
+### Branches
+
+Name the change, not a roadmap step:
+
+- Good: `feat/folder-share`, `fix/cancel-export-hang`, `docs/agents-md`
+- Bad: `feat/step3-remaining`, `wip`, `temp`
+
+After a PR merges: delete the feature branch **locally and on `origin`**, then
+branch from updated `master`.
+
+### Commits
+
+- Style in history: `feat(scope):`, `fix(scope):`, `refactor(scope):`, `docs:`, `ci:`.
+- Subject = outcome; body only when needed.
+- Signed when repo requires it.
+
+### Pull requests
+
+- Title matches the main change.
+- Summary: short bullets of what/why.
+- **Test plan must be executable for this PR**:
+ - exact commands, and/or
+ - 1–2 concrete scenarios that would catch a regression.
+- No filler plans (“everything works”, “CI green”) without commands or scenarios.
+
+---
+
+## Security considerations
+
+- Treat tickets and endpoint IDs as sensitive enough not to log full blobs in
+ production paths.
+- Do not weaken approval/access checks for convenience.
+- Do not store secrets in the repo; app data dirs and key files stay out of git.
+- Be careful with file publish races (no-clobber rename/link policy exists for a reason).
+
+---
+
+## Common tasks → start files
+
+| Task | Start here |
+|------|------------|
+| Share / multi-file / folders | `runtime/share.rs`, `filesystem.rs`, platform `FileSystemService.*` |
+| 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` |
+| Approvals | `feature/approvals/`, `approval.rs` |
+| QR / NFC invitations | `TransferShareActions.*`, `ReceiveInvitationActions.*` |
+| Theme / brand | `ui/theme/VniDropTheme.kt` |
+| Compose skill | `.codex/skills/compose-skill/SKILL.md` |
+
+---
+
+## Anti-patterns (never)
+
+- Streaming multi-MB transfer data through Kotlin as the primary design
+- Passing Android **directory** FDs into Rust
+- Nested exclusive `Runtime::block_on` that deadlocks cancel during receive
+- Holding locks across `.await`
+- Rebuilding a monolithic `runtime.rs`
+- Flaky multi-minute sleeps in tests
+- Unsigned commits when signing is required
+- Force-push or secret commits without explicit user direction
+
+---
+
+## Implementation checklist
+
+1. Read this file + nearest nested `AGENTS.md`.
+2. For Compose/UI: load `compose-skill`.
+3. Smallest correct change; tests for bugs/behavior changes.
+4. Run relevant build/test commands; fix failures.
+5. Sparse comments only where non-obvious.
+6. Commit (signed) / push / PR only as the user requests.
+7. Summarize what changed and what you ran.
diff --git a/androidApp/src/main/ic_launcher-playstore.png b/androidApp/src/main/ic_launcher-playstore.png
new file mode 100644
index 0000000..f8f0179
Binary files /dev/null and b/androidApp/src/main/ic_launcher-playstore.png differ
diff --git a/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml b/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
index b4e3546..a8e49b1 100644
--- a/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
+++ b/androidApp/src/main/res/drawable-v24/ic_launcher_foreground.xml
@@ -1,31 +1,35 @@
+
-
-
-
-
-
-
-
-
-
+ xmlns:aapt="http://schemas.android.com/aapt"
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/androidApp/src/main/res/drawable/ic_launcher_background.xml b/androidApp/src/main/res/drawable/ic_launcher_background.xml
index 869e6b7..a723447 100644
--- a/androidApp/src/main/res/drawable/ic_launcher_background.xml
+++ b/androidApp/src/main/res/drawable/ic_launcher_background.xml
@@ -1,170 +1,10 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+ android:width="108dp"
+ android:height="108dp"
+ android:viewportWidth="108"
+ android:viewportHeight="108">
+
diff --git a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
index dbde2b7..65291b9 100644
--- a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
@@ -1,5 +1,6 @@
-
-
-
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
index dbde2b7..65291b9 100644
--- a/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ b/androidApp/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
@@ -1,5 +1,6 @@
-
-
-
+
+
+
+
\ No newline at end of file
diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png
deleted file mode 100644
index a571e60..0000000
Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp
new file mode 100644
index 0000000..503daac
Binary files /dev/null and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher.webp differ
diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..3acda44
Binary files /dev/null and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_foreground.webp differ
diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png
deleted file mode 100644
index 61da551..0000000
Binary files a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..7b68f67
Binary files /dev/null and b/androidApp/src/main/res/mipmap-hdpi/ic_launcher_round.webp differ
diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png
deleted file mode 100644
index c41dd28..0000000
Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp
new file mode 100644
index 0000000..db442ba
Binary files /dev/null and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher.webp differ
diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..b8f2bc7
Binary files /dev/null and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_foreground.webp differ
diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png
deleted file mode 100644
index db5080a..0000000
Binary files a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..5fb172b
Binary files /dev/null and b/androidApp/src/main/res/mipmap-mdpi/ic_launcher_round.webp differ
diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png
deleted file mode 100644
index 6dba46d..0000000
Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp
new file mode 100644
index 0000000..4e8b941
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher.webp differ
diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..4515eb3
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_foreground.webp differ
diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png
deleted file mode 100644
index da31a87..0000000
Binary files a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..c6ad6a0
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xhdpi/ic_launcher_round.webp differ
diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png
deleted file mode 100644
index 15ac681..0000000
Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..2c7315d
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher.webp differ
diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..e3df4ec
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ
diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
deleted file mode 100644
index b216f2d..0000000
Binary files a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..f0012f2
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp differ
diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png
deleted file mode 100644
index f25a419..0000000
Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
new file mode 100644
index 0000000..94d681c
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher.webp differ
diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp
new file mode 100644
index 0000000..122ac21
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ
diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
deleted file mode 100644
index e96783c..0000000
Binary files a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png and /dev/null differ
diff --git a/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
new file mode 100644
index 0000000..c2fc611
Binary files /dev/null and b/androidApp/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp differ
diff --git a/androidApp/src/main/res/values/ic_launcher_background.xml b/androidApp/src/main/res/values/ic_launcher_background.xml
new file mode 100644
index 0000000..c5d5899
--- /dev/null
+++ b/androidApp/src/main/res/values/ic_launcher_background.xml
@@ -0,0 +1,4 @@
+
+
+ #FFFFFF
+
\ No newline at end of file
diff --git a/assets/1024x1024.png b/assets/1024x1024.png
new file mode 100644
index 0000000..b2540f9
Binary files /dev/null and b/assets/1024x1024.png differ
diff --git a/assets/1024x1024.svg b/assets/1024x1024.svg
new file mode 100644
index 0000000..5598da1
--- /dev/null
+++ b/assets/1024x1024.svg
@@ -0,0 +1,26 @@
+
diff --git a/assets/108x108.svg b/assets/108x108.svg
new file mode 100644
index 0000000..3be9180
--- /dev/null
+++ b/assets/108x108.svg
@@ -0,0 +1,26 @@
+
diff --git a/assets/android/app-icon.png b/assets/android/app-icon.png
new file mode 100644
index 0000000..7ac58fb
Binary files /dev/null and b/assets/android/app-icon.png differ
diff --git a/assets/android/app-icon.svg b/assets/android/app-icon.svg
new file mode 100644
index 0000000..818ff1e
--- /dev/null
+++ b/assets/android/app-icon.svg
@@ -0,0 +1,30 @@
+
diff --git a/assets/desktop/app-icon.png b/assets/desktop/app-icon.png
new file mode 100644
index 0000000..9a21223
Binary files /dev/null and b/assets/desktop/app-icon.png differ
diff --git a/assets/desktop/app-icon.svg b/assets/desktop/app-icon.svg
new file mode 100644
index 0000000..f8eb118
--- /dev/null
+++ b/assets/desktop/app-icon.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/desktop/foreground.png b/assets/desktop/foreground.png
new file mode 100644
index 0000000..b2540f9
Binary files /dev/null and b/assets/desktop/foreground.png differ
diff --git a/assets/desktop/foreground.svg b/assets/desktop/foreground.svg
new file mode 100644
index 0000000..5598da1
--- /dev/null
+++ b/assets/desktop/foreground.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/ios/app-icon.png b/assets/ios/app-icon.png
new file mode 100644
index 0000000..91e521f
Binary files /dev/null and b/assets/ios/app-icon.png differ
diff --git a/assets/ios/app-icon.svg b/assets/ios/app-icon.svg
new file mode 100644
index 0000000..b2a33e3
--- /dev/null
+++ b/assets/ios/app-icon.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/ios/foreground.png b/assets/ios/foreground.png
new file mode 100644
index 0000000..b2540f9
Binary files /dev/null and b/assets/ios/foreground.png differ
diff --git a/assets/ios/foreground.svg b/assets/ios/foreground.svg
new file mode 100644
index 0000000..5598da1
--- /dev/null
+++ b/assets/ios/foreground.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/linux/app-icon.png b/assets/linux/app-icon.png
new file mode 100644
index 0000000..9a21223
Binary files /dev/null and b/assets/linux/app-icon.png differ
diff --git a/assets/linux/app-icon.svg b/assets/linux/app-icon.svg
new file mode 100644
index 0000000..f8eb118
--- /dev/null
+++ b/assets/linux/app-icon.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/linux/foreground.png b/assets/linux/foreground.png
new file mode 100644
index 0000000..b2540f9
Binary files /dev/null and b/assets/linux/foreground.png differ
diff --git a/assets/linux/foreground.svg b/assets/linux/foreground.svg
new file mode 100644
index 0000000..5598da1
--- /dev/null
+++ b/assets/linux/foreground.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/macos/app-icon.icns b/assets/macos/app-icon.icns
new file mode 100644
index 0000000..5d93c92
Binary files /dev/null and b/assets/macos/app-icon.icns differ
diff --git a/assets/macos/app-icon.png b/assets/macos/app-icon.png
new file mode 100644
index 0000000..9a21223
Binary files /dev/null and b/assets/macos/app-icon.png differ
diff --git a/assets/macos/app-icon.svg b/assets/macos/app-icon.svg
new file mode 100644
index 0000000..f8eb118
--- /dev/null
+++ b/assets/macos/app-icon.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/macos/foreground.png b/assets/macos/foreground.png
new file mode 100644
index 0000000..b2540f9
Binary files /dev/null and b/assets/macos/foreground.png differ
diff --git a/assets/macos/foreground.svg b/assets/macos/foreground.svg
new file mode 100644
index 0000000..5598da1
--- /dev/null
+++ b/assets/macos/foreground.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/windows/app-icon.ico b/assets/windows/app-icon.ico
new file mode 100644
index 0000000..451dbbf
Binary files /dev/null and b/assets/windows/app-icon.ico differ
diff --git a/assets/windows/app-icon.png b/assets/windows/app-icon.png
new file mode 100644
index 0000000..9a21223
Binary files /dev/null and b/assets/windows/app-icon.png differ
diff --git a/assets/windows/app-icon.svg b/assets/windows/app-icon.svg
new file mode 100644
index 0000000..f8eb118
--- /dev/null
+++ b/assets/windows/app-icon.svg
@@ -0,0 +1,29 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/assets/windows/foreground.png b/assets/windows/foreground.png
new file mode 100644
index 0000000..b2540f9
Binary files /dev/null and b/assets/windows/foreground.png differ
diff --git a/assets/windows/foreground.svg b/assets/windows/foreground.svg
new file mode 100644
index 0000000..5598da1
--- /dev/null
+++ b/assets/windows/foreground.svg
@@ -0,0 +1,26 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/crates/vnidrop/AGENTS.md b/crates/vnidrop/AGENTS.md
new file mode 100644
index 0000000..ec71e19
--- /dev/null
+++ b/crates/vnidrop/AGENTS.md
@@ -0,0 +1,128 @@
+# AGENTS.md — `crates/vnidrop` (Rust core)
+
+Nearest guide when editing under `crates/vnidrop/`. Root [`AGENTS.md`](../../AGENTS.md)
+still applies; this file wins for Rust-specific commands and conventions.
+
+---
+
+## Purpose
+
+This crate is the transfer backend exposed to Kotlin via UniFFI (`VnidropCore`).
+It owns Iroh, blobs, SQLite history, tickets, approval handshake, and file streaming.
+
+Read [`CORE_FLOW.md`](CORE_FLOW.md) before changing send/receive/export/cancel.
+
+---
+
+## Commands (run from repo root)
+
+Always prefer workspace commands so lockfile/fmt stay consistent:
+
+```bash
+cargo fmt --all
+cargo fmt --all -- --check
+cargo clippy --workspace --all-targets -- -D warnings
+cargo test -p vnidrop
+cargo test --workspace --all-targets
+```
+
+Focused integration suites:
+
+```bash
+cargo test -p vnidrop --test transfer
+cargo test -p vnidrop --test approval
+cargo test -p vnidrop --test lifecycle
+cargo test -p vnidrop --test output_sink
+```
+
+Docs (CI uses `-D warnings`):
+
+```bash
+RUSTDOCFLAGS='-D warnings' cargo doc -p vnidrop --no-deps
+```
+
+Run `cargo fmt --all` after finishing Rust edits without asking.
+
+---
+
+## Module layout
+
+```
+src/
+ runtime/
+ mod.rs # CoreInner, startup recovery, emit helpers
+ facade.rs # UniFFI surface, block_on, cancel entry
+ share.rs # share / import
+ receive.rs # receive, download, export, OutputSinkFile
+ lifecycle.rs # cancel share, delete, status, access mode, shutdown
+ provider.rs # provider messages, per-peer transfer progress
+ 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
+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.
+
+---
+
+## Hard constraints
+
+1. **Public API stability:** UniFFI surface changes break Kotlin. Prefer additive
+ changes; update shared Kotlin call sites in the same change when required.
+2. **Streaming stays in Rust.** Platform passes paths or FDs; core does not pull
+ whole files into Kotlin.
+3. **Android FDs are files only.** `SourceKind::FileDescriptor` with
+ `is_directory=true` must fail; directories are expanded on the platform side.
+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
+ `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.
+
+---
+
+## Code style (Rust)
+
+- Clippy clean with `-D warnings`.
+- Prefer exhaustive `match`; avoid catch-all arms that hide new enum variants.
+- Prefer comparing whole objects in tests when practical.
+- Comment only non-obvious why (concurrency, durability, platform FS quirks).
+- Do not add one-off private helpers used once if inline is clearer.
+- Prefer private modules; export deliberately via `lib.rs` / UniFFI.
+
+---
+
+## Testing
+
+- Unit / private: `src/tests/` (see crate `tests.rs` paths).
+- Integration: `tests/*.rs` + `tests/support/mod.rs` (`TestNode`, `MemoryOutputSink`,
+ `CoreGuard`, etc.).
+- Bug fixes need a regression test.
+- Prefer gates/latches over multi-second sleeps (see output_sink cancel test).
+- Failure tests: durable status and/or events when applicable.
+- Recovery tests: shut down core, reopen same data dir.
+
+Details: [`tests/README.md`](tests/README.md).
+
+---
+
+## PR / verify checklist for this crate
+
+```bash
+cargo fmt --all -- --check
+cargo clippy --workspace --all-targets -- -D warnings
+cargo test -p vnidrop
+```
+
+If you touched cancel, export, or sinks, also:
+
+```bash
+cargo test -p vnidrop --test output_sink
+```
diff --git a/desktopApp/build.gradle.kts b/desktopApp/build.gradle.kts
index a0a7f62..deeb310 100644
--- a/desktopApp/build.gradle.kts
+++ b/desktopApp/build.gradle.kts
@@ -25,6 +25,15 @@ compose.desktop {
targetFormats(TargetFormat.Dmg, TargetFormat.Msi, TargetFormat.Deb)
packageName = "com.vnidrop.app"
packageVersion = "1.0.0"
+ macOS {
+ iconFile.set(project.file("../assets/macos/app-icon.icns"))
+ }
+ windows {
+ iconFile.set(project.file("../assets/windows/app-icon.ico"))
+ }
+ linux {
+ iconFile.set(project.file("../assets/linux/app-icon.png"))
+ }
fileAssociation(
mimeType = "application/vnd.vnidrop.transfer",
extension = "vnd",
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
index 6dc06c4..91523fc 100644
--- a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
+++ b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/Contents.json
@@ -1,36 +1,36 @@
{
- "images": [
- {
- "filename": "app-icon-1024.png",
- "idiom": "universal",
- "platform": "ios",
- "size": "1024x1024"
- },
- {
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "dark"
- }
- ],
- "idiom": "universal",
- "platform": "ios",
- "size": "1024x1024"
- },
- {
- "appearances": [
- {
- "appearance": "luminosity",
- "value": "tinted"
- }
- ],
- "idiom": "universal",
- "platform": "ios",
- "size": "1024x1024"
- }
- ],
- "info": {
- "author": "xcode",
- "version": 1
- }
+ "images" : [
+ {
+ "filename" : "app-icon.png",
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "dark"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ },
+ {
+ "appearances" : [
+ {
+ "appearance" : "luminosity",
+ "value" : "tinted"
+ }
+ ],
+ "idiom" : "universal",
+ "platform" : "ios",
+ "size" : "1024x1024"
+ }
+ ],
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png
deleted file mode 100644
index 53fc536..0000000
Binary files a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon-1024.png and /dev/null differ
diff --git a/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png
new file mode 100644
index 0000000..91e521f
Binary files /dev/null and b/iosApp/iosApp/Assets.xcassets/AppIcon.appiconset/app-icon.png differ
diff --git a/iosApp/iosApp/Assets.xcassets/Contents.json b/iosApp/iosApp/Assets.xcassets/Contents.json
index d458f1c..73c0059 100644
--- a/iosApp/iosApp/Assets.xcassets/Contents.json
+++ b/iosApp/iosApp/Assets.xcassets/Contents.json
@@ -1,6 +1,6 @@
{
- "info": {
- "author": "xcode",
- "version": 1
- }
+ "info" : {
+ "author" : "xcode",
+ "version" : 1
+ }
}
diff --git a/shared/AGENTS.md b/shared/AGENTS.md
new file mode 100644
index 0000000..26ecea6
--- /dev/null
+++ b/shared/AGENTS.md
@@ -0,0 +1,126 @@
+# AGENTS.md — `shared/` (Compose Multiplatform + KMP)
+
+Nearest guide when editing under `shared/`. Root [`AGENTS.md`](../AGENTS.md)
+still applies; this file wins for UI/KMP work.
+
+---
+
+## Purpose
+
+`shared` is the multiplatform app layer: Compose UI, feature ViewModels, and
+`expect`/`actual` bridges into Android, iOS, and desktop. Native transfer work
+goes through UniFFI `VnidropCore` (see `crates/vnidrop`).
+
+---
+
+## Compose skill (required for UI work)
+
+For screens, components, theme, navigation, resources, ViewModel↔UI wiring,
+lists, animation, accessibility:
+
+1. Load [`.codex/skills/compose-skill/SKILL.md`](../.codex/skills/compose-skill/SKILL.md).
+2. Follow its workflow and defaults.
+3. Open **at most one** file under `.codex/skills/compose-skill/references/` when
+ the skill’s Quick Routing table says you need deeper guidance.
+4. Do **not** invent a parallel Compose style guide.
+
+### Project policy (overrides generic skill defaults)
+
+| Topic | Do this |
+|-------|---------|
+| Architecture | Keep **MVVM-style** ViewModels: immutable `*State`, `StateFlow`, **named methods**. Do not force MVI `onEvent` sealed hierarchies unless asked. |
+| Structure | Feature packages under `com.vnidrop.app.feature.*`; thin route/wiring + screen/composables. |
+| Theme | Only `LocalVniDropColors` / `VniDropThemeTokens` (`ui/theme/VniDropTheme.kt`). Brand primary light ≈ `#A855F7` (HSL 271, 91%, 65%). |
+| Strings | CMP composeResources / `Res.string.*` — not Android `R` in `commonMain`. |
+| DI | Follow existing `AppGraph` construction; no unprompted Hilt/Koin migration. |
+| Platform | `androidMain` / `iosMain` / `jvmMain` for pickers, SAF, security-scoped URLs, NFC/QR. |
+| Dependencies | Before adding Jetpack/AndroidX to `commonMain`, verify multiplatform artifacts for all targets. |
+
+compose-skill “Existing Project Policy”: adapt to this repo; do not force-migrate.
+
+---
+
+## Commands
+
+From repo root:
+
+```bash
+./gradlew :shared:jvmTest
+./gradlew :shared:compileKotlinJvm
+```
+
+Optional:
+
+```bash
+./gradlew :shared:testAndroidHostTest
+./gradlew :shared:iosSimulatorArm64Test
+./gradlew :desktopApp:run
+./gradlew :androidApp:assembleDebug
+```
+
+CI `:shared:jvmTest` runs on **macOS** (Gobley host cargo). Prefer macOS for
+local parity.
+
+When Kotlin changes touch UniFFI-generated APIs, rebuild/test with a full
+`jvmTest` so Gobley/native pieces stay aligned.
+
+---
+
+## Layout
+
+```
+src/
+ commonMain/kotlin/com/vnidrop/app/
+ core/ # models, CoreGateway, FilePicker interfaces
+ feature/send|receive|approvals|settings|app/
+ ui/ # theme, components, navigation, feedback, state helpers
+ commonMain/composeResources/
+ androidMain|iosMain|jvmMain/
+ commonTest|jvmTest|...
+```
+
+### Platform file bridging (must preserve)
+
+- **Android share:** open content URIs as FDs; expand **folder trees** to per-file
+ documents with relative `displayName` paths before calling Rust
+ (`FileSystemService.android.kt` / `expandShareDirectory`).
+- **Android receive:** MediaStore Downloads sink and/or SAF tree write sink.
+- **iOS:** keep security-scoped leases alive while Rust reads paths.
+- **Desktop:** filesystem paths; directories may be marked `isDirectory` for Rust walk.
+
+Never pass a directory as a single Android FD into `SourceKind.FILE_DESCRIPTOR`.
+
+---
+
+## Code style (Kotlin)
+
+- Match existing feature style (imports, naming, state updates via `update { }`).
+- Composables render state and invoke callbacks; no business rules in `@Composable`
+ bodies (network, share creation, ticket parse — ViewModel/core).
+- Prefer stable list keys from domain IDs.
+- Comments only for non-obvious platform or concurrency reasons.
+- Do not hard-code brand colors; use theme tokens.
+
+---
+
+## Testing
+
+- Logic: `src/commonTest/` (e.g. ViewModel fakes, `AppUiModels` progress helpers).
+- Compose/JVM: `src/jvmTest/`.
+- Add/adjust tests when changing state machines, progress aggregation, or
+ share/receive eligibility.
+- Prefer fakes in `commonTest` support over real UniFFI in pure unit tests.
+
+```bash
+./gradlew :shared:jvmTest
+```
+
+---
+
+## Anti-patterns
+
+- Streaming transfer bytes through Kotlin as the main design
+- Directory FDs on Android
+- New DI framework “because best practice”
+- Loading every compose-skill reference file for a small UI tweak
+- Android `R.string` in `commonMain`