221 Commits

Author SHA1 Message Date
bece2af179 docs: add device history UI branch handoff 2026-08-13 13:28:36 +02:00
beab4100ed feat(shared): refine saved devices experience 2026-08-13 13:18:56 +02:00
ac77950ac3 feat(shared): graduate saved devices experience 2026-08-13 00:53:16 +02:00
6ab658fea2 feat(shared): graduate saved-device transfer experience
Add the VniDrop-specific Compose architecture skill, unify invitation and targeted transfer drafts, and promote Saved devices to an adaptive first-class destination.
2026-08-12 21:45:18 +02:00
2ac9166b34 fix(core): harden targeted transfer recovery 2026-08-12 18:48:13 +02:00
4f09e474c5 test(core): add saved devices production release gate 2026-08-12 17:19:03 +02:00
2c941dc623 refactor(core): remove experimental saved device contract 2026-08-12 17:00:28 +02:00
5b57917f52 refactor(bindings): use production saved device APIs 2026-08-12 16:48:20 +02:00
5ffd86afbc feat(core): promote protected saved device APIs 2026-08-12 16:38:21 +02:00
67a557af7c feat(core): expose durable targeted transfer lifecycle 2026-08-12 16:11:53 +02:00
32d69b9771 fix(core): complete targeted transfers without invitation state 2026-08-12 15:32:06 +02:00
644c9bfda3 fix(core): isolate targeted transfers and persist peer names 2026-08-12 15:06:01 +02:00
dac8232324 fix(apple): realign UI layer with regenerated core bindings
- map the new VnidropError cases (DeviceUnavailable, OfferTimeout,
  RelayPolicyIncompatible, ProtocolIncompatible) to existing catalog keys
- drop the stale .map(\.share) now that sharePickedFiles returns Share
- remove the duplicate .transfersChanged pattern in the signal switch
- replace the deprecated String(cString:) sysctl decode
- close the CoreGateway protocol declaration
2026-08-12 11:34:49 +02:00
ebdff3df4b fix(desktop): unblock protected-core startup snackbars
Run Secret Service IO on spawn_blocking so Linux zbus cannot nest Tokio
runtimes during init, and wait for core initialize before experimental
saved-device coordinators refresh.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 21:40:17 +02:00
b9884b566a feat(shared): enable experimental saved-devices on desktop
Surface Settings → Experimental on Windows/Linux Compose, keep generic
Desktop hosts gated off, and assert path-based targeted receive when no
output sink is available.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:51:48 +02:00
37cc238888 fix(android): unblock protected-core startup on Android
Use flock for profile locks, hash secret record filenames under NAME_MAX,
and dismiss the starting overlay after the first init attempt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 19:46:14 +02:00
eabc2754a7 feat(shared): Android experimental saved-devices KMP UI
Gate pairing and targeted transfers behind Settings → Experimental, with
CoreGateway wake-ups, in-flow prompts, and one Android dogfood round-trip.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 18:09:37 +02:00
edf7426672 chore: drop design-section cross-refs from core comments
Keep module docs self-describing without pointing at DESIGN §N.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 17:03:42 +02:00
d91820b867 feat(core): id-centric targeted approve/receive with output sinks
Stop returning grant strings across UniFFI; approve yields typed outcomes and
pull/resume use transfer id plus path or ReceiveOutputSink. Document the
pairing/targeted event catalog and cover Android MediaStore-style sink contracts.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:50:10 +02:00
3b7fd3d468 refactor(core): deepen domain stores and invitation-only persistence
Peel relationships, eligibility, and secrets off the shared pool into
AppDataStores adapters, split pairing service/protocol, and move the
invitation Repository into its own module so open_all owns schemas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 15:51:35 +02:00
5c66c2ca65 refactor(core): open AppDataStores instead of exporting SqlitePool
Introduce persistence::open_all so domain stores (invitation, targeted,
blocked) are constructed once. Extract TargetedTransferStore and own it on
CoreInner; soft-close raw pool access for new callers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 08:50:16 +02:00
a2385912c2 refactor(core): remove prototype contact paths for experimental saved devices
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 06:05:31 +02:00
0a6ecb5c3b merge: ticket 14 Apple saved-device core contract
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:39:40 +02:00
9c7697c90b feat(apple): exercise saved-device core contract harness
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:39:10 +02:00
039d3f942b merge: ticket 17 Linux saved-device core contract
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:13:00 +02:00
1e601e118a feat(linux): exercise saved-device core contract harness
Prove Secret Service-backed identity restart, the public saved-device
lifecycle, fault isolation, event recovery, and binding hygiene without
product UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:12:20 +02:00
e79aba7bcf merge: ticket 16 Windows saved-device core contract
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:11:51 +02:00
f603f5fb8d feat(windows): exercise saved-device core contract harness
Prove the DPAPI-backed Windows bridge can drive the full public saved-device
and targeted-transfer contract via an injectable API fake on non-Windows hosts
and real DPAPI under cfg(windows), without product UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:11:26 +02:00
0c317efe59 feat(android): exercise saved-device core contract harness
Prove Keystore-backed Android secret storage can drive the full public
saved-device and targeted-transfer contract without product UI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 05:10:04 +02:00
8e8cab9b24 feat(core): add event revisions and saved-device local labels
Platform contract harnesses need monotonic event revisions for at-least-once
dedup and a typed rename API that survives listing.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 04:47:28 +02:00
89c2206f0f feat(core): harden saved-device control plane against hostile peers
Bound pending offers, enforce pre-approval limits and max_saved_devices,
add per-identity cooldowns, silently reject invalid traffic, and redact
sensitive values from production events and errors without inventing
accepted-transfer quotas.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 04:33:38 +02:00
a01d8328c9 merge: integrate resumable transfers with network profile compatibility
Combine ticket 11 resume/cancel/delete/idempotency with ticket 12 relay
profiles, protocol floors, and typed offer outcomes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 04:02:07 +02:00
8798edf0f2 feat(core): make targeted transfers resumable and idempotent
Preserve approved transfer auth and state across restart, resume without
re-approval, and keep cancel/delete from leaving usable orphan authorization.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:56:46 +02:00
4a83b18d42 feat(core): honor network profiles and protocol floors for saved devices
Targeted offers and pairing now validate relay-policy compatibility and
reject protocol downgrades with typed unavailable/timeout/incompatibility
errors, without falling back to ordinary shares.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:54:13 +02:00
e2cfad7fb3 fix(core): tear down shares on forget/block and fail-closed blocks
Cancel targeted protocol shares synchronously before await, and treat block
lookup errors as denied so store failures cannot admit blocked peers.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:35:18 +02:00
22c842acb1 merge: integrate targeted transfers with revoke/forget/block
Combine ticket 09 lifecycle APIs with ticket 10 targeted-transfer protocol
and wire forget/block to real cancel_targeted_transfers_for_peer.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:30:33 +02:00
11fae16391 feat(core): revoke, forget, block, and rotate device relationships
Give saved-device owners immediate local control over grants and identity-wide
denies, with minimal tombstones for replay rejection and a ticket-10 hook for
targeted-transfer cancellation.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:27:28 +02:00
f4cec1415c feat(core): complete one approved targeted transfer between saved devices
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:22:38 +02:00
193fe7c757 feat(core): save devices through mutual consent and grants
Establish PendingOutgoing/PendingIncoming relationships over a token-bound
pairing protocol, exchange directional grants with challenge-response proofs
and a final ack before Saved, and merge simultaneous initiations without a
second prompt.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 03:03:43 +02:00
04a31e8a24 feat(core): create pairing eligibility after completed transfers
Add the experimental eligibility control plane so either endpoint of a
fully completed authenticated invitation transfer can start one single-use
pairing attempt within 24 hours, with secrets held in custody and invalid
requests rejected silently.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 02:34:18 +02:00
e740942f63 chore: ignore bin directory in git repository 2026-08-09 21:24:25 +02:00
eefedd0cb0 fix(core): harden protected identity startup 2026-08-09 20:25:16 +02:00
429987785e feat(core): protect secrets on native platforms 2026-08-09 19:34:34 +02:00
931b297321 test(core): harden secret custody recovery 2026-08-09 18:44:58 +02:00
1cd09a2ec3 feat(core): add recoverable secret custody 2026-08-09 18:37:54 +02:00
b128457137 docs(core): clarify experimental domain versions 2026-08-09 18:07:03 +02:00
564b86c28c feat(core): establish saved device domain seam 2026-08-09 18:01:02 +02:00
0ec0daef2a docs: redesign saved device foundation 2026-08-09 18:00:48 +02:00
c852a68f28 fix(apple): correct the device id row on the detail screen
The fingerprint row reused the no-name placeholder as its label, so it read
"A nearby device — aaab1c58", and it duplicated the device id shown
directly beneath it. Now one selectable full id, which is what someone
comparing devices actually needs.
2026-08-07 10:59:00 +02:00
225ff9ad22 fix: stop the device picker hanging on an offer
Two causes. The connect step had no timeout, so an unreachable device was
retried indefinitely instead of falling through to hold-for-later; it now
gives up after 15s and holds the offer as designed.

The picker also waited on the whole exchange, which includes a person on the
other device deciding — up to two minutes. It now closes on tap and reports
the outcome as a message, and a decline or an unanswered offer is shown as
information rather than an error, since the offer did arrive.
2026-08-07 10:49:32 +02:00
677fc3c6d5 fix(apple): make the device detail screen reachable
The Settings stack has a typed path of [SettingsSection], so a
NavigationLink carrying a String could never push onto it: tapping a device
in the list did nothing. Contact detail is now a SettingsSection case, and
the path maps it to the two-level push the way the bug report screen
already does.
2026-08-07 10:33:10 +02:00
3441280599 feat(apple): send a transfer to a device from the share panel
Send to a device now sits alongside the QR code, NFC, and export actions,
since an offer is another way to deliver the same invitation. Picking a
device pushes the existing transfer rather than re-sharing the files.

The picker lists only devices holding a live grant, so nothing offered there
can fail on tap, and it distinguishes accepted from waiting for that device
to open the app.

Also fixes the deprecated SF Symbol and the two Sendable warnings introduced
with the contacts screen: the sections now talk to the model directly rather
than storing view callbacks that a Binding setter has to convert.
2026-08-07 10:24:05 +02:00
d8587851a3 feat(core): offer an existing share to a remembered device
Another way to deliver an invitation the user already created, alongside the
QR code, rather than a second share of the same files: the ticket handed over
is the stored one and the transfer id is unchanged.

Only an active share can be offered. A stopped one no longer serves its
content, so handing out its ticket would promise nothing.
2026-08-07 10:14:53 +02:00
cba51ad504 docs(design): mark device history as implemented 2026-08-06 19:00:24 +02:00
0f70663263 feat(apple): collect transfers held for this device
Adds the opt-in foreground check and an explicit Check now, the waiting-to-
be-delivered list on the sender side, and honest reporting when a send could
not be delivered: a closed app is a delay, not a success nobody received.

The setting is off by default and its footer states that checking reveals
app-open times to remembered devices, since that is the reason it is a
setting at all.

Records in the design doc that this shipped as one global toggle rather than
the per-contact opt-in originally specified.
2026-08-06 19:00:04 +02:00
94a8ba2103 feat(core): hold undeliverable offers and collect them on demand
An unreachable device is a delay, not a failure: the share stays here and
the ticket waits in held_offers (schema 8 -> 9) until that device comes and
collects it. No server and no push, per the design.

Polling needs no grant proof. iroh has already authenticated the remote
endpoint key, and a device is only handed offers addressed to precisely that
endpoint, so a stranger polling learns nothing. Offers are consumed on
delivery, so polling twice does not re-deliver, and cancelling the transfer
withdraws the waiting ticket.

Polling is rate limited per device: it tells every contact the app was
opened, so it must never become a presence beacon.
2026-08-06 18:47:29 +02:00
268fbf161d feat(apple): send files to a remembered device
Adds the Send files action to the device detail, routing the picked
selection through sendToContact.

Rather than a second picker path, sharePickedFiles now takes a
ShareDestination, so the macOS security-scoped access handling covers both
routes. A contact destination carries no access policy, matching the core's
rule that an offer share is never public.
2026-08-06 18:21:44 +02:00
dc23e87c56 feat(apple): offer to remember a device after a transfer
Closes the loop: until now nothing in the UI could create a contact, so the
list stayed empty unless the peer initiated.

A completed receive names its sender and a completed delivery names its
receiver, so both sides get the suggestion. Declining is persisted, or every
later transfer with the same device would re-ask a question already
answered; pairing deliberately afterwards clears that.

The suggestion sheet ranks below the two other prompts, since nobody is
waiting on the answer.
2026-08-06 18:06:19 +02:00
1369df4578 feat(apple): contacts screen and consent prompts
Device list, detail, and block list under Settings, plus the two sheets:
an incoming offer and a device asking to be remembered. Both are
answer-only, since swiping away would leave the sender waiting.

Accepting an offer routes the released ticket into the ordinary receive
path, so the platform still chooses the destination. The prompt does not
ask a second time; it only falls back to the review sheet when the
destination is unusable.
2026-08-06 17:57:54 +02:00
256ff89423 feat(apple): add the contacts feature model
MVVM model for the device list, its detail, and the two consent prompts.
Accepting an offer is the only path that returns a ticket, matching the
core: a declined offer hands over nothing.

Grant lifetime lives in preferences because the core holds it in memory
only, so it is pushed back on every start rather than silently reverting to
the default.
2026-08-06 17:48:18 +02:00
3c89c34c6e feat(apple): expose device history through the core gateway
Adds contact, pairing, and offer models plus the gateway surface the feature
models will use. Contacts and offers are endpoint-scoped events with no
transfer id, so they get their own coalesced signals.

DeviceContact.displayName prefers the local label over the name the peer
claims, and carries a short endpoint fingerprint for telling apart devices
using the same name.
2026-08-06 17:40:38 +02:00
178def0629 i18n: add device history strings
Contacts list and detail, pairing consent prompts, incoming offer prompt,
and the grant lifetime setting, in all nine supported languages. Added to
strings.json and regenerated; targeted at both platforms so the Compose
resources exist when the KMP side is built.
2026-08-06 17:29:43 +02:00
5f5f7e0515 chore(apple): drop the unused SwiftPM manifest
Package.swift named its module VniDropApp while all 16 test files import
VniDrop, and it never declared the SFSafeSymbols dependency that project.yml
links, so neither swift build nor swift test worked. The Xcode project is
already the only functioning build definition for the UI and the tests.

Removes a second dependency list that had drifted, and corrects the README,
which documented the CLI path as if it worked.
2026-08-06 17:21:51 +02:00
e041fbeda2 feat(core): send transfers straight to a paired device
Adds SubmitOffer to the contacts ALPN: the sender creates an ordinary share
and pushes the ticket over an authenticated connection, replacing the QR
code without changing the transfer itself.

Only the receiving user is prompted. The sender pre-authorises the target
endpoint before offering, and the approval service now honours an existing
access session, so the handshake the receiver runs next does not ask the
sender to approve a transfer they initiated. An unsolicited ticket receive
still prompts as before.

The ticket leaves the core only when the user accepts; declining yields
nothing. Offer-created shares are never public, one prompt per device is
pending at a time, a decline starts a cooldown, and forgetting a device
clears any prompt it left on screen.
2026-08-06 16:56:52 +02:00
7aa99304b2 feat(core): add the contacts protocol and grant exchange
New /vnidrop/offer/1 ALPN carrying grant delivery and revocation, with a
per-connection challenge so a captured proof cannot be replayed onto another
connection. Unlike the transfer handshake, this serves nobody without a
grant, so an unpaired device cannot raise a prompt on the far side.

A delivered grant is never stored on arrival: it waits for the local user's
consent, so an unsolicited grant cannot create a contact. Forgetting a
contact revokes locally first and notifies the peer best effort. A blocked
endpoint is refused indistinguishably from any other refusal.

Adds the UniFFI surface for listing, pairing, forgetting, blocking, labels,
and grant lifetime.
2026-08-06 16:35:36 +02:00
9fbcf653e8 feat(core): persist contacts, grants, and the block list
Schema 7 -> 8 adds contacts, grants_issued, grants_held, and
blocked_endpoints. Kept in their own module so repository.rs does not grow
further; the tables migrate with the rest of the schema through the shared
pool.

Revocation tombstones rather than deletes, so a returning peer is answered
Revoked instead of Unknown and can drop its dead entry. Blocking revokes any
outstanding grant, and unblocking does not hand access back.
2026-08-06 16:12:31 +02:00
4cfee786fc feat(core): add grant primitives for device history
Grants are the capability a device issues so a known peer may reach it. The
issuer is the only party that can validate one, which is what makes consent
and revocation enforceable without the peer's cooperation.

Pure module: proof construction and constant-time verification bound to the
challenge and both endpoint ids, idle expiry renewed on use, and secrets
redacted in Debug output.
2026-08-06 16:01:20 +02:00
0388422318 docs(design): specify delivery when the recipient is not running
Sender-held offers with a bounded foreground pull instead of push
infrastructure. Records that APNs is out of scope and that mobile-to-mobile
with both apps closed is unsupported.
2026-08-06 15:51:00 +02:00
7afc7d0892 docs(design): device history and direct offers
Design for remembering devices after a transfer and sending to them without
a new invitation. Grant-based contacts so consent and revocation are
enforceable by the party being remembered. Local network discovery
considered and deferred (Appendix A).
2026-08-06 13:35:02 +02:00
Hammed Abass
e8c3eadfc8 Merge pull request #41 from sudosylabs/feat/shared-app-config
Shared app config + remove telemetry (keep bug reports)
2026-08-02 19:50:49 +02:00
877083a3ed refactor(diagnostics): remove bug report breadcrumbs 2026-08-02 19:18:18 +02:00
7cb2270d56 ci(apple): add Xcode Cloud post-clone script
Xcode Cloud only checks out the repo, so ci_post_clone.sh installs swiftlint,
xcodegen and bun, downloads the prebuilt core (vnidrop.xcframework + Vnidrop.swift)
from the matching GitHub Release asset, and generates the project via localization,
version/app config codegen and xcodegen. Rust is never built on Xcode Cloud.
2026-08-02 10:29:46 +02:00
eb8498168d fix(shared): avoid java accessor shadowing when reading app.properties
In a Gradle Kotlin DSL script `java` resolves to the Java plugin extension
accessor, so `java.util.Properties` failed script compilation with
"Unresolved reference 'util'", breaking the shared/Linux/Windows KMP jobs.
Import java.util.Properties and use it unqualified, matching the root build.
2026-08-02 10:29:45 +02:00
ac6e837560 docs: describe bug reports instead of telemetry
Update the site privacy policy (no telemetry/analytics, bug-report only, v1.2)
and the README/apple README to reflect that only user-submitted bug reports
remain.
2026-08-02 10:03:29 +02:00
3e18378610 refactor(diagnostics-api): drop telemetry and crash ingestion, keep bug reports
Remove the /v1/events and /v1/crashes routes, their normalizers and storage
paths, and simplify retention to the bugs table. Add a migration dropping the
now-unused event_batches and crashes tables, and regenerate worker types.
2026-08-02 10:03:12 +02:00
b68d338097 refactor(apple): remove diagnostics opt-in toggle, keep bug reports
Drop the Share-diagnostics preference, its Settings toggle and the
DiagnosticsBuildConfig stub. Bug reporting (NoopBugReportService) and the
diagnostics install id used for bug-report correlation are retained.
2026-08-02 10:02:49 +02:00
b8a002a2ad refactor(shared): remove telemetry and crash reporting, keep bug reports
Delete the TelemetryRecorder, CrashReporter, PendingCrashStore and platform
crash hooks along with their models, JSON encoders and the diagnostics opt-in
preference. The DiagnosticsTransport interface is narrowed to sendBugReport, and
DiagnosticsCoordinator now only wires the bug-report service and install id.

Bug reporting, the breadcrumb buffer, log redaction and the diagnostics endpoint
config are kept. Regenerate localization after dropping the diagnostics_* keys.
2026-08-02 10:02:28 +02:00
232fb125d3 feat(config): shared app.properties for app-wide constants
Add a single source of truth (root app.properties) for public app-wide
constants, injected at build time on both platforms instead of hardcoding.

- Apple: generate-appconfig.sh -> Generated/AppConfig.swift (wired into
  `make apple-app-config`), consumed as AppConfig.privacyPolicyURL.
- KMP: generateAppConfig task -> AppConfig.kt (mirrors DiagnosticsBuildConfig),
  consumed as AppConfig.PRIVACY_POLICY_URL.

Replaces the stale hardcoded privacy-policy URL on both sides with
https://vnidrop.sudosy.fr/privacy/.

Also fix the Apple release core build: disable release LTO in build-core.sh
(Cargo forbids lto in a build-override) to avoid the proc-macro
"mis-aligned LINKEDIT string pool" corruption, so release archives are
compact instead of shipping the debug core.

Update the app icon.

Tests: shell test for the generator (escaping, missing/duplicate key),
plus XCTest and jvmTest asserting the generated value matches app.properties.
2026-08-01 19:56:07 +02:00
Hammed Abass
d52ac52cea Merge pull request #40 from sudosylabs/feat/macos-approval-modal-fix
fix(apple): show macOS approval modal + publish prebuilt core bundle
2026-07-31 17:07:40 +02:00
fe97c21c7a fix(apple): keep the snackbar above the approval overlay
The earlier approval-modal fix folded SnackbarHost and the approval modal into a
single OverlayLayer child; nested that way the approval host's full-bleed clear
layer covered the toast, so snackbars stopped appearing.

Split them: rename OverlayLayer to ApprovalLayer (approval modal only) and hoist
SnackbarHost to a top-most direct child of the root ZStack, observing the live
`graph.messages` directly. The toast now renders above the overlay again.
2026-07-31 12:46:12 +02:00
c670dda0a9 fix(apple): run the notification delegate on the main actor (iOS crash)
Tapping an approval notification while the app was backgrounded crashed on iOS
with "Call must be made on main thread". The UNUserNotificationCenterDelegate
methods are `async` and nonisolated, so their continuation resumes off the main
thread at the return point — where UIKit synchronously runs state-restoration /
snapshot work, tripping the main-thread assertion. (The empty iOS `didReceive`
body didn't matter; even an empty async method returns off-main.)

Isolate NotificationPresenter to `@MainActor` so the delegate returns on the main
thread. `@preconcurrency` on the UNUserNotificationCenterDelegate conformance is
required because those requirements are nonisolated with non-Sendable UN*
parameters, which strict concurrency won't otherwise let a main-actor type
witness. The macOS branch's now-redundant `await MainActor.run { … }` is dropped.
2026-07-31 12:46:05 +02:00
ff391f5502 build(apple): publish prebuilt core bundle in release assets
Bundle the compiled Apple core — vnidrop.xcframework plus the generated UniFFI
bindings (Vnidrop.swift, a source file that lives outside the xcframework) — into
VnidropCore-<version>.zip with a checksum, and attach it to the GitHub Release.
This lets a consumer (e.g. Xcode Cloud, later) use the prebuilt core instead of
installing Rust and running build-core.sh.

No duplicate builds: the release job compiles the core once (build-apple-dmg ->
build-core.sh release), links it into the signed DMG, and package-core.sh only
zips that same output. Package.swift is unchanged (still binaryTarget(path:)).

- apple/scripts/package-core.sh: stage xcframework + Vnidrop.swift and zip them
  with a sha256sum/shasum-compatible checksum sidecar (macOS-native).
- Makefile: package-apple-core target.
- apple-release.yml: run package-apple-core after the DMG and upload the zip +
  checksum in the macOS artifact.
- assemble-release.sh: verify the core zip's checksum, copy it into the final
  assets, and list it in release-manifest.json + SHA256SUMS (+ fixture update).
2026-07-31 11:40:34 +02:00
9079c81409 build(apple): pin ARCHS to arm64 project-wide
The Rust core's macOS slice (vnidrop.xcframework) is built aarch64-apple-darwin
only, so every target is Apple-Silicon-only — not just the Release-Direct build.
Hoist ARCHS: arm64 from the VniDropDirect target into the project-wide base
settings so no configuration attempts a universal link that would fail looking
for x86_64 symbols. Intel Macs are unsupported (EOL with macOS 28).
2026-07-31 11:20:36 +02:00
5424da855e fix(apple): show receiver-approval modal on macOS release builds
The approval modal never appeared for a macOS sender: the receiver request
reached the core and even fired its notification, but the modal stayed hidden.

Root cause was observation, not presentation. `RootView` derived `approvals`
and `messages` as `@ObservedObject` in `init` from a freshly built `AppGraph`.
`init` runs on every view re-creation and each run makes a throwaway graph, so
those observed objects were repointed to a dead `ApprovalCoordinator` that never
receives core events — while the persisted `@StateObject graph` (and the models
wired to it) kept the live one. Debug happened not to re-init the view, so it
stayed on the live instance; release re-inits it, exposing the bug.

Move the snackbar + approval modal into an `OverlayLayer` child view that takes
the coordinator/messages as `@ObservedObject` and is constructed in `body` from
the persisted `graph`, so the subscription is always against the live instances.

While here:
- Present the approval only after any open share/QR sheet has actually finished
  dismissing (macOS can't stack sheets), driven off the sheet's real
  `onDismiss` completion via a new `AdaptiveDrawer.onDismissed` hook and
  `SendModel.shareSheetsDismissed` — no wall-clock delay.
- Move the list-level share-sheet state (`shareTargetId`) into `SendModel` so the
  approval flow can dismiss every share surface centrally.
- Add a fallback: pending receiver rows in the Receivers panel now offer an
  Approve action (`SendModel.acceptReceiver`) alongside Refuse, for the case the
  modal didn't surface.
2026-07-31 11:19:28 +02:00
56d19014d4 chore(release): prepare 0.2.4 2026-07-31 04:59:36 +02:00
51bf0abba2 fix(release): configure Store CLI before settings 2026-07-31 04:51:31 +02:00
e0fb84ccb9 chore(release): prepare 0.2.3 2026-07-30 22:22:11 +02:00
30025a4ebf fix(release): download Play APK media 2026-07-30 22:19:40 +02:00
50e9a6c1cc chore(release): prepare 0.2.2 2026-07-30 21:54:54 +02:00
224a8e0e7a fix(release): enforce Apple hardened runtime 2026-07-30 21:27:34 +02:00
efacfab213 fix(release): expose Apple notarization failures 2026-07-30 21:03:30 +02:00
Hammed Abass
0ec7618ce8 Merge pull request #39 from sudosylabs/feat/release-pipeline-fixes
fix(release): repair Apple and Android builds
2026-07-30 20:30:28 +02:00
8b75423b7a fix(release): repair Apple and Android builds 2026-07-30 20:26:27 +02:00
Hammed Abass
7236933b76 Merge pull request #38 from sudosylabs/feat/release-0.2.1
chore(release): prepare 0.2.1
2026-07-30 19:43:19 +02:00
fc732e1b77 chore(release): prepare 0.2.1 2026-07-30 19:27:57 +02:00
Hammed Abass
d097c82f6a Merge pull request #37 from sudosylabs/feat/microsoft-store-publishing
ci: automate Microsoft Store updates
2026-07-30 19:25:15 +02:00
caaa9a472d ci: automate Microsoft Store updates 2026-07-30 19:12:50 +02:00
Hammed Abass
4ce124da7c Merge pull request #36 from sudosylabs/feat/automated-release-versions
feat(release): automate derived store versions
2026-07-30 17:58:18 +02:00
94a8b3481b feat(release): automate derived store versions 2026-07-30 17:40:00 +02:00
Hammed Abass
6d908d8dc3 Merge pull request #35 from sudosylabs/feat/release-pipeline
ci: add coordinated cross-platform release pipeline
2026-07-28 11:58:18 +02:00
c6655da7db refactor(version): derive Apple build numbers 2026-07-28 11:27:45 +02:00
2d7982bbb9 ci: add coordinated release pipeline 2026-07-28 11:09:53 +02:00
Hammed Abass
52d4102308 Merge pull request #34 from sudosylabs/feat/unified-versioning
feat(release): unify cross-platform versioning
2026-07-28 08:45:02 +02:00
407a0d2d60 feat(release): unify cross-platform versioning 2026-07-28 08:22:58 +02:00
Hammed Abass
fc1d27bf45 Merge pull request #33 from sudosylabs/feat/release-test-flight
feat(apple): stable iOS/macOS release + macOS direct-download channel
2026-07-27 16:25:55 +02:00
4730554c2c refactor(apple): make InvitationError typed and localize NFC prompts
Replace the free-form InvitationError.message(String) case with semantic
cases mapped to L10n keys at the UI boundary (Error.uiText), so user-facing
error text is localized instead of substring-matched from English blobs.
.raw(String) remains only for genuinely dynamic system/core messages.

Localize the CoreNFC alertMessage prompts via existing L10n keys, and add
SwiftLint rules (raw_alert_message, raw_invitation_error) to catch raw
alert strings and literal .raw("…") errors going forward.
2026-07-27 16:07:19 +02:00
cbb535d998 chore(apple): stop tracking RELEASE-MACOS.md
Keep the macOS release notes local-only; remove from the index and ignore
so the working copy stays on disk without being committed.
2026-07-27 16:05:35 +02:00
a0ebd7c71b fix(apple): restore macOS approval modal and sandboxed file sharing
Approval modal: since the Share/QR sheet auto-opens after creating a transfer,
it is always up when a receiver request arrives, and macOS silently drops a sheet
presented while another is still dismissing — so the approval sheet never appeared.
Drive the approval sheet from explicit state (not a constant binding) and, on
macOS, defer its presentation one dismiss-beat after closing the Share/QR sheet so
the hand-off is serialized. Still a non-dismissable sheet; iOS timing unchanged.

Sandboxed file sharing: the macOS picker released its security scope immediately,
so the core's later import failed with EPERM under the App Store sandbox (the
non-sandboxed .dmg was unaffected). Capture a security-scoped bookmark at pick
time and re-acquire access across shareFiles() — during which the core imports the
bytes — mirroring the receive-folder scoped-access pattern.
2026-07-27 15:01:01 +02:00
cc194f6a7b feat(apple): add direct-download macOS channel (notarized DMG + Sparkle + Homebrew)
Add a second macOS shipping channel alongside the App Store build:

- New VniDropDirect target (Release-Direct config) sharing VniDrop's sources via
  an AppBase target template; links Sparkle behind the DIRECT_DISTRIBUTION flag so
  the App Store binary never bundles a self-updater. arm64-only (core is arm64).
- Sparkle updater (SparkleUpdater.swift) + "Check for Updates" menu, compiled only
  under DIRECT_DISTRIBUTION; Info.plist SUFeedURL points at the GitHub Release
  /latest/download/appcast.xml, non-sandboxed entitlements for Developer ID.
- build-dmg.sh (archive → Developer ID export → DMG → sign → notarize → staple),
  generate-appcast.sh, and ExportOptions-DeveloperID.plist.
- apple-release.yml: on tag v*.*.*, build/notarize the DMG, publish the GitHub
  Release with appcast, and push the Homebrew cask to sudosylabs/homebrew-vnidrop.
  apple.yml gains a PR compile-check of the direct target.
- CFBundleVersion is stamped at build time as a UTC YYMMDD.HHMM timestamp for both
  channels, replacing the hand-maintained build number.
- Docs (RELEASE-MACOS.md, README), cask template + tap README, localized
  updates_check string, Makefile targets, gitignore for dist/ artifacts.
2026-07-27 14:31:36 +02:00
8de190a36e chore(apple): update Icon Composer app icon definition 2026-07-27 12:19:13 +02:00
73bc87d3d1 fix(apple): use TAG NFC reader format for iOS 26 SDK
App Store upload with the iOS 26 SDK rejects the NDEF value (error 90778
"NDEF is disallowed") and requires TAG. NFCNDEFReaderSession keeps working
under the TAG entitlement, so no code changes are needed.
2026-07-27 10:27:22 +02:00
2166aa9ce4 build(apple): ship TestFlight build 7 as Release
Set CURRENT_PROJECT_VERSION to 7 for the next TestFlight upload, and pin the
scheme's Archive/Profile actions to the Release configuration so Product →
Archive can't pick up Debug.
2026-07-27 10:21:54 +02:00
22b93ce94e feat(apple): keep iOS transfers alive in the background
iOS suspends the process on backgrounding, freezing the core's network
threads so in-flight transfers stall and never fire notifications. Hold a
UIApplication background-task assertion (BackgroundActivityController) while
transfers/shares are active so iOS grants its grace window — long enough to
finish and notify. Released on foreground, on completion, or on expiration.
No UIBackgroundModes added (keeps App Store validation clean); macOS is a
no-op since it already runs unfocused.

Add a localized iOS-only Settings notice explaining the platform limit so it
doesn't read as a bug.
2026-07-27 10:02:31 +02:00
31ba3f40b2 test(shared): resolve UI copy from resources 2026-07-25 19:46:34 +02:00
f3124371ee fix(apple): resolve App Store validation errors
- Info.plist: drop unused `fetch`/`processing` background modes (no
  BGTaskScheduler implementation exists, which they would require); keep
  remote-notification.
- Info.plist: set ITSAppUsesNonExemptEncryption=false — the app's standard
  end-to-end encryption qualifies for the mass-market export exemption, so no
  compliance code is required.
- project.yml: emit dwarf-with-dsym for Release so archive symbol upload works.

The remaining upload errors (NFC "NDEF is disallowed", Unsupported SDK) are
artifacts of building with a beta Xcode/SDK 27 and clear when archiving with a
release/RC Xcode; NFCNDEFReaderSession legitimately requires the NDEF format
entitlement, so it is kept as-is.
2026-07-25 19:35:59 +02:00
ea2f8b1cc7 feat(apple): adopt Icon Composer app icon
Replace the legacy AppIcon.appiconset with an Icon Composer AppIcon.icon
bundle in the target's resources. ASSETCATALOG_COMPILER_APPICON_NAME already
points at "AppIcon"; the .icon back-deploys to the iOS 18.2 target.
2026-07-25 16:24:04 +02:00
9b8d66f97d chore(packaging): add Apple App Store design source via Git LFS
Track the Affinity design master (AppStore.af) with Git LFS to keep repo
history lean, and ignore the large exported JPG screenshots (regenerated from
the source) plus macOS/editor junk.
2026-07-25 16:06:44 +02:00
a8a168ffde chore(apple): declare non-exempt encryption use
Add ITSAppUsesNonExemptEncryption=YES to Info.plist so the export-compliance
question is answered once (the app uses standard end-to-end encryption via
iroh). Avoids being re-prompted on every TestFlight/App Store upload.
2026-07-25 16:06:44 +02:00
516c4ace84 docs: set Apache license copyright to VniDrop
Fill in the Apache 2.0 copyright placeholder with "Copyright 2026 VniDrop",
matching the App Store copyright field.
2026-07-25 16:06:43 +02:00
Hammed Abass
c7657c4b37 Merge pull request #31 from sudosylabs/feat/apple-typed-resources-and-fixes
Apple: typed resources and UX fixes
2026-07-24 21:16:56 +02:00
b8e8dd8644 test(core): wait for delivery event visibility 2026-07-24 21:05:37 +02:00
83b66c5eb7 Merge remote-tracking branch 'origin/feat/apple-typed-resources-and-fixes' 2026-07-24 20:52:36 +02:00
81e84b14f8 fix(shared): align action icons and storage refresh 2026-07-24 20:24:23 +02:00
c81cb7c8b6 feat(shared): align non-Apple UX with Apple 2026-07-24 19:58:33 +02:00
319af6f2de fix(l10n): align notification/storage descriptions with KMP behavior
The merge kept this branch's reworded notifications_description and
storage_delete_transfers_description over master's, but the merged KMP code is
master's, so its FoundationComposeTest assertions (and the shipped KMP copy) expect
master's wording. Restore both to master's committed text (pulling the storage one
from master's XML, since master's own strings.json was stale for it). Verified the
two failing KMP Compose tests pass locally.
2026-07-24 19:58:29 +02:00
3abd4d0cfd build(apple): flag raw string literals in SwiftUI initializers
The typed-resource rules missed a bare string literal passed as the leading arg of
a view initializer (e.g. Label("send_stop_sharing", …)), which is an implicit
LocalizedStringKey. Add a rule covering Text/Label/Button/Section/Picker/etc.
(empty labels allowed). Fixes the two dynamic-content Text sites it surfaced by
switching them to Text(verbatim:).
2026-07-24 19:42:40 +02:00
b66cb8c1f1 fix(l10n): restore storage_clearing_transfer_cache dropped in merge
Another key master referenced from Kotlin but kept only in the generated Compose
XML, so regeneration dropped it. Verified exhaustively this time: every
Res.string.* reference in shared/src/commonMain/kotlin now resolves against the
regenerated values/strings.xml, so no further keys are missing.
2026-07-24 19:28:52 +02:00
98da43b122 docs: make strings.json the documented source of truth for l10n
Record in AGENTS.md that localization/strings.json is the single source of truth
and the KMP XML + Apple xcstrings/L10n.swift are generated by the loc CLI and must
never be hand-edited — a key present only in a generated file is dropped on the
next regeneration (which is how the transfer-cache strings were lost in the merge).
2026-07-24 19:02:02 +02:00
7d3f1b9862 fix(l10n): add the transfer-cache-clear strings dropped in merge
Master referenced storage_clear_transfer_cache(_description) and
storage_transfer_cache_cleared from Kotlin but never added them to strings.json —
they lived only in the committed Compose XML. Regenerating l10n from the merged
strings.json dropped them, breaking the shared-kmp build. Add them (kmp target,
all 9 languages, text carried over from master) so generation restores them.
2026-07-24 19:02:02 +02:00
83ddf9f059 ci(apple): install SwiftLint for the required lint build phase
The VniDrop target's SwiftLint pre-build phase is required (fails if missing), so
the Apple CI job must have SwiftLint available. Add a brew install step.
2026-07-24 18:52:26 +02:00
f513a6118e feat(apple): notify the sender when a receiver's delivery fails
plannedReceiverNotifications only fired for completed receivers, so a failed
delivery produced no notification. Add a receiverFailed kind wired through the
planner, id, and deliver paths, with localized notifications_receiver_failed_*
strings and a unit test.
2026-07-24 18:45:02 +02:00
35f06a0b6b fix(apple): localize receiver failure reasons
The receiver row showed the core's raw reason code (e.g. destination_exists),
breaking the never-expose-raw-reason-blobs rule. Map the core reason codes to the
existing L10n.Error.* messages via receiverReasonUiText, with a generic fallback so
a raw code is never surfaced.
2026-07-24 18:45:02 +02:00
b65bac021f build(apple): enforce typed resources with SwiftLint
Add a focused .swiftlint.yml (custom rules only, no default style noise) flagging
raw String(localized:) / LocalizedStringKey / systemName|systemImage literals, and
wire it as a required pre-build phase that fails the build if SwiftLint is missing
(brew install swiftlint). The phase prepends the Homebrew bin dirs since Xcode runs
scripts with a minimal PATH. Runs clean on the current tree (0 violations).
2026-07-24 18:32:15 +02:00
d2924f7ce6 fix(apple): focus the running instance on notification tap
Add a UNUserNotificationCenterDelegate didReceive handler so tapping a notification
is handled inside the running app — activating and bringing the existing window
forward — instead of falling through to default launch behavior, which on macOS
can surface a second process. The approval/transfer UI is driven by core state, so
activating the window reveals any pending approval.
2026-07-24 18:32:15 +02:00
3042005226 refactor(apple): type the merged relay/network resources
Convert master's raw-string localization keys and SF Symbols in the new
relay/network code to typed accessors, matching this branch's typed-resources
convention: relay mode labels/descriptions, NetworkSettings strings, the endpoint
id and relay-validation messages (now typed L10n functions), and SF Symbols via
SFSafeSymbols. Retype the model's relayApplyErrorKey from a raw String key to
String.LocalizationValue so no loose key literals remain in the settings layer.
2026-07-24 18:15:36 +02:00
9b15a388d8 fix(apple): polish the merged Network settings
Move the relay-mode picker's Network title into a Section header (the inline
picker label rendered as a stray row on iOS) and hide the picker label. Use a
verbatim prompt for the relay URL placeholder so macOS stops markdown-linkifying
the URL-shaped text into a purple link.
2026-07-24 17:54:59 +02:00
c23f7916bb Merge origin/master into feat/apple-typed-resources-and-fixes
Brings in custom relays, relay connection policies, storage cache clearing, and
receiver-failure reporting. Apple-side conflict resolutions:
- CoreRepository: keep CoreDispatcher, adopt master's relay factory + network
  transition guard, drop the now-unused serial queue.
- TransferDetailsView: keep the toolbar-share layout; adopt master's
  invitationPresentation-based QR panel and the new .failed receiver case (typed).
- SettingsModel/SettingsScreen: typed L10n titleKey with master's .network case;
  relay controls and the Free up space / storage redesign coexist.
- Add the missing transfer_receiver_failed localization key.
- Regenerate l10n from the merged strings.json; keep the Apple catalog untracked.
- Drop the notificationsEnabled test assertion (notifications preference was
  intentionally removed on this branch).
2026-07-24 17:48:10 +02:00
Hammed Abass
0f8f89641a Merge pull request #32 from sudosylabs/feat/custom-relays
feat(network): add custom relay configuration and transfer controls
2026-07-24 17:09:16 +02:00
b724c1540f fix(shared): derive path names in Rust 2026-07-24 16:52:10 +02:00
1d049d08f2 fix(storage): release core before clearing cache 2026-07-24 16:02:46 +02:00
aab5f243ca fix(apple): treat a completed receiver event as terminal
progressForReceiver only labelled a receiver Completed when no progress/started
events preceded the completion, so the normal progress→completed sequence fell
through and rendered as Sending despite a .completed kind. Events are newest-first,
so a completed latest event is always terminal — label it Completed. Fixes the
failing ProgressDerivationTests.testReceiverCompletionAfterProgressIsTerminal.
2026-07-24 16:02:06 +02:00
065d57e896 refactor(apple): redesign the composer source buttons
Replace the bare text links under Start sharing with an even row of bordered,
icon-led buttons (Change files, Choose folder, plus Clear on wider layouts).
Single-line labels keep them equal height, and a neutral tint keeps them quiet so
the purple Start sharing reads as the primary action.
2026-07-24 15:14:21 +02:00
c7ebaee15b feat(apple): add context menus to Send and Receive rows
Send rows get a context menu that acts inline without navigating: Share opens the
share panel (QR + delivery) over the list via a dedicated sheet host, Stop sharing
(active shares) and Delete transfer run in place, the latter through a new id-based
SendModel.deleteTransfer and a list-level confirmation alert. Receive rows get a
Delete action mirroring swipe-to-delete (handy on macOS).
2026-07-24 15:08:37 +02:00
425500ecf2 fix(core): report receiver failures to sender 2026-07-24 14:52:09 +02:00
30dfabf8e7 refactor(apple): move share to the toolbar and delete to the bottom
Put a share icon in the transfer-details toolbar (opening the QR/share panel) in
place of the delete button, drop the now-redundant Share row from the list, and
move Delete transfer into the bottom section alongside Stop sharing.
2026-07-24 14:46:44 +02:00
4bd51106e8 feat(apple): cover the window while the core boots
The core initializes asynchronously at launch, so for a moment the transfer lists
look empty and the app feels stalled. Show a full-window overlay (centered spinner
+ "Starting…") as the top layer of the root stack while coreState.isInitialized is
false; it fades out once the core is ready.
2026-07-24 14:41:37 +02:00
6e2c8b4b2d feat(apple): open the share panel right after creating a transfer
After Start sharing succeeds, jump straight to the new transfer's share panel
(QR code + delivery actions) instead of returning to the list and making the user
drill in via the row and the share row. Refresh first so the transfer exists in
state before selecting it; the share panel already handles the brief window before
the ticket is ready.
2026-07-24 14:18:22 +02:00
e22e395efc feat(apple): streamline the Storage screen and fix stuck usage
Redesign the Storage screen for clarity: an "On this device" usage header with a
manual Refresh control, symbol-led action buttons, and a caption under each action
spelling out exactly what it does (Free up space = temp + trash, non-destructive;
Delete all transfers = clears history + cached share content, keeps received
files).

Fix the summary sticking on "Calculating…": it loaded only on .onAppear and bailed
when opened before the core finished its async launch, leaving the loading branch
showing with nothing running (only a manual refresh recovered it). loadStorageUsage
now waits for the core to become ready before reading usage, distinguishes a real
failure (retry) from loading, loads via .task, and can be refreshed on demand. Use
plain button styling with explicit tints so pressing an action no longer flips the
label to the white selection highlight.
2026-07-24 14:07:31 +02:00
677c3ce47f feat(apple): add a Free up space action to reclaim leaked storage
Delete all transfers only clears core records, and the blob-store cache is
reclaimed by the core's own timer. Neither touches the app's temporary directory
(leftover picker/staging copies — hundreds of MB on macOS) or the stray .Trash
folders that accumulate in app-owned directories and can't be removed via
Files/Finder. Add a non-destructive Free up space button that empties the temp
directory and removes .Trash folders under the core data dir (and, on iOS, the
fixed Documents receive folder), reporting the bytes reclaimed. Guarded against
running while a transfer is in flight; never touches received files, the core
database, or user-chosen macOS receive folders.
2026-07-24 13:46:27 +02:00
20597e6e88 fix(apple): enforce a single window on macOS and iPadOS
macOS uses a single-instance `Window` scene instead of `WindowGroup`, which
otherwise lets the app open multiple windows (via ⌘N). iPadOS sets
`UIApplicationSupportsMultipleScenes = false` to block a second scene via Stage
Manager / split view. (`LSMultipleInstancesProhibited` only blocks a second
process, not a second window.)
2026-07-24 13:19:26 +02:00
42f569dcf0 feat(apple): allow only one macOS app instance
Set LSMultipleInstancesProhibited so re-launching VniDrop (or opening a
vnidrop URL) activates the running instance instead of spawning a second
copy. iOS ignores the key — it's single-instance already.
2026-07-24 12:27:09 +02:00
8b31c0fe78 feat(apple): use the brand purple as the app-wide accent color
The macOS sidebar selection and item icons rendered in the system default
blue because the app had no global accent color — SwiftUI's `.tint` doesn't
reach the AppKit-backed sidebar. Add an AccentColor asset (the exact sRGB of
VniDropColors.brandPurple) and wire ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME
so the accent applies at the OS level everywhere, including the sidebar.
2026-07-24 00:37:49 +02:00
c9ef40f00f refactor(apple): tidy the receive-folder preference row
The "Save received transfers to" section was a gray folder label that read
like a disabled field, stacked above two full-width buttons. Replace it with
the standard macOS "label · value · inline action" row: a folder icon + the
current folder name with a trailing "Choose folder" button, long names
truncated in the middle. "Use default" now shows only when a custom folder is
actually set (hidden when already on the default, where it'd be a no-op).
2026-07-24 00:28:34 +02:00
0448137d84 fix(core): abort send when provider stream closes 2026-07-24 00:11:58 +02:00
6180cb95ce fix(apple): stop delete confirmation re-presenting on macOS
Confirming a transfer/history deletion flashed the same confirmation alert a
second time before it went away. The destructive button runs confirmDelete
synchronously (setting isDeleting = true), while the alert's isPresented
dismiss binding fires asynchronously and then no-ops because its
`if !isDeleting` guard is already false — leaving the open flag set, so macOS
re-reads the binding as true and re-presents the alert until the async delete
finally clears it.

Close the confirmation flag synchronously in confirmDeleteTransfer /
confirmHistoryDelete so there's no window for re-presentation. Tests assert
the flag clears immediately, before the async delete completes.
2026-07-23 23:43:05 +02:00
2b2fe93293 build(apple): enable dead-code stripping and missing-localizability analyzer
Adds project-wide build settings (applied to every target) so they persist
in project.yml instead of the gitignored generated .xcodeproj:
  - DEAD_CODE_STRIPPING: strip unreachable code from release binaries
  - CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED: flag user-facing strings that
    aren't localized (the app ships 9 languages), surfaced during Analyze
2026-07-23 23:30:03 +02:00
3469b122c2 feat(apple): local notifications for transfer lifecycle events
Adds background notifications for the "the thing you were waiting for is
done" moments, alongside the existing incoming-approval-request one:

  - a receive finished downloading            (receive -> done)
  - a receive failed / was interrupted         (receive -> failed)
  - a share you own failed                      (send -> failed)
  - a receiver finished downloading your share  (receiver status completed)

A new TransferNotificationCoordinator observes core state + signals and
publishes these; the decision of which moments notify is a pure function
(plannedTransferNotifications / plannedReceiverNotifications), unit-tested
independently. The first state snapshot only primes existing history as seen
so launch doesn't spam.

Notification permission is now the single source of truth. The in-app
notifications toggle and its decoupled UserDefaults preference are gone;
the Settings section shows an "Allow notifications" button that requests the
OS permission (or deep-links to Settings once decided), and notifications
gate purely on `permission == .granted`.

macOS delivery fixes:
  - add a UNUserNotificationCenterDelegate so banners present even while the
    app is active (the app window is usually open on macOS)
  - present-when-active on macOS, suppress-when-foregrounded on iOS
  - reserve the notification id before awaiting publish: the CombineLatest
    fired several times and re-added the same identifier, which macOS
    coalesces into a silent update with no banner
  - LocalNotificationService seeds its permission at init so gating can't
    race a not-yet-refreshed .notDetermined

Eight localized title/body strings added (apple-only); the shared
notifications_description copy is generalized from "receive requests" to
"transfer activity".
2026-07-23 23:29:40 +02:00
4074f4bee8 feat(storage): clear inactive transfer cache 2026-07-23 22:22:30 +02:00
7cc0e825f6 fix(settings): confirm deleting all transfers 2026-07-23 19:16:11 +02:00
efb3c474d1 feat(settings): align relay and storage controls 2026-07-23 19:03:19 +02:00
0e0d43bc4d build(apple): stop tracking generated localization outputs
Localizable.xcstrings and Generated/L10n.swift are generated from
localization/strings.json — the single source of truth — yet were committed,
which caused redundant tracking and a spurious ~10k-line diff every time
Xcode reformatted the catalog on build.

Treat them like the (already gitignored) Rust bindings: generate at build
time instead of tracking them. gitignore both; make the `apple-project`
target depend on `localization` so `bun run generate` recreates them before
xcodegen; install Bun in the Apple CI job and trigger it on localization/**.
Android strings.xml stays tracked — it has no reformatting churn and its
build doesn't run the generator.
2026-07-23 18:48:03 +02:00
081b59815c fix(apple): make receive-cancel actually cancel the transfer
The Cancel button on an in-progress receive did nothing. CoreRepository
funnelled every core call through one serial DispatchQueue, but `receive`
is a blocking core call that occupies that queue for the whole transfer.
The tapped `cancelTransfer` was enqueued behind the in-flight `receive` on
the same serial queue, so it could never run until `receive` returned —
which it never would, because it was waiting to be cancelled. A deadlock
the button couldn't escape.

The Rust core is explicitly designed for cancel to arrive from another
thread mid-receive (VnidropCore.block_on uses a shared runtime handle for
exactly this). Extracts the two-lane dispatch into a CoreDispatcher: a
serial lane for ordered calls and a separate concurrent lane for
interrupt-style calls, and routes cancel through the latter so the signal
reaches the core and unblocks the receive.

Adds CoreDispatcherTests, including a regression guard that an interrupt
completes while the serial lane is blocked.
2026-07-23 18:30:55 +02:00
3c8267adc5 refactor(apple): type core event phase/kind/direction as enums
Replaces the stringly-typed transfer-event phase/kind/direction values
throughout the progress-derivation logic with EventPhase, EventKind and
EventDirection enums (String-backed to match the core's wire values).

CoreEventModel keeps the raw wire strings as a faithful boundary DTO but
exposes typed eventPhase/eventKind/eventDirection accessors; all logic —
progressForTransfer/Receiver, humanProgressLabel, aggregateReceiverProgress,
the refresh trigger, and the SendScreen snapshots — now compares enum cases
instead of literals. TransferProgress.phase/kind are the enums directly, so
constructions read `phase: .transfer, kind: .progress`. The two ad-hoc
phase/kind Sets collapse into "is a recognized case" (non-nil) checks.
2026-07-23 18:00:29 +02:00
08e61c57af feat(l10n): replace legacy %@ format keys with semantic template keys
The Apple catalog carried four stringly-named passthrough keys ("%@",
"%@ · %@", "%@ %@ · %@", "%@%%") left over from the KMP port. They were
never referenced as keys — the composite strings were built inline with
hardcoded separators, so the middot/percent formatting wasn't localizable.

Renames them to proper semantic keys in strings.json:
  - format_separated_pair(first:second:)     "{first} · {second}"
  - format_separated_triple(first:second:third:)
  - battery_level_value(level:)              "{level}%"
and drops the pure-identity "%@". Wires the inline compositions (size ·
status, count files · size, receivers pending · completed, battery level)
to the generated typed accessors. Catalog now validates with zero warnings.
2026-07-23 17:50:57 +02:00
1563bf80d6 feat(apple): type-safe SF Symbols via SFSafeSymbols
Replaces every stringly-typed SF Symbol name with a compile-time-checked
SFSymbol case, mirroring the L10n accessor approach. A mistyped or
OS-unavailable symbol is now a build error instead of a silently blank
glyph at runtime.

Adds the SFSafeSymbols SPM package (project.yml) and migrates all call
sites: Image(systemName:)/Label(systemImage:) -> systemSymbol, and the
five symbol-carrying view properties (AppDestination.systemSymbol,
SettingsRow.icon, AboutPoint.symbol, MethodRow.icon, PolicyOption.icon)
flipped from String to SFSymbol end to end.
2026-07-23 17:40:07 +02:00
ef42875ddb feat(apple): generate type-safe L10n accessors and migrate all key literals
Replaces every stringly-typed localization key in the Apple app with
compile-time-checked accessors generated from localization/strings.json.
A mistyped key is now a build error instead of a silent fallback to the
raw key at runtime. The runtime path is unchanged: plain keys are
String.LocalizationValue constants resolved with String(localized:) and
Apple's String Catalog still does the lookup; keys with arguments become
typed, named functions applying args through String(format:).

Generator: new renderSwiftAccessors emits apple/VniDrop/Generated/L10n.swift,
wired into generate. Renamed generic arg1/arg2 tokens on four keys to
semantic names (receiver, transferName, deviceId) and updated their context
notes; positional output is unchanged so .xcstrings (bar the 4 comments)
and the Android XML regenerate identical.

Migration: every key-carrying value flipped to String.LocalizationValue
end to end, resolved only at the leaf. Zero key literals and zero
LocalizedStringKey remain in app or test code. macOS build passes; iOS
test run pending.
2026-07-23 17:12:43 +02:00
7592d49a59 fix(deps): update iroh to 1.0.3 2026-07-23 16:50:27 +02:00
a0bcc5dbff feat(network): add relay connection policies 2026-07-23 15:14:03 +02:00
cbace73908 feat(network): support custom relay servers
Add strict custom Iroh relay profiles with safe restart and rollback across the Rust core, Compose apps, and Apple apps. Preserve multi-relay invitations and fail closed on configuration or recovery mismatches.
2026-07-23 14:27:40 +02:00
Hammed Abass
5939489432 Merge pull request #29 from sudosylabs/feat/win32-screenshots
fix(desktop): use native Windows file and folder dialogs
2026-07-22 22:06:20 +02:00
Hammed Abass
90a06e9151 Merge pull request #28 from sudosylabs/fix/transfer-completion-progress
fix(transfer): finalize delivery completion
2026-07-22 21:54:57 +02:00
6cf6644c09 fix(desktop): use native Windows file dialogs 2026-07-22 21:49:49 +02:00
f0b06ad1cf fix(transfer): finalize delivery completion 2026-07-22 21:34:33 +02:00
e7c70c9314 docs: add Microsoft Store screenshots 2026-07-22 21:21:58 +02:00
Hammed Abass
ad9ffd63f5 Merge pull request #27 from sudosylabs/fix/typed-error-propagation
fix(core): preserve typed transfer failures
2026-07-22 21:00:06 +02:00
82fb549576 fix(core): preserve typed transfer failures 2026-07-22 20:43:15 +02:00
Hammed Abass
c6e59bed26 Merge pull request #26 from sudosylabs/feat/native-platform-icons
feat(ui): add native platform icon sets
2026-07-22 19:58:36 +02:00
58c470b279 fix(ci): isolate Linux Gradle caches 2026-07-22 19:02:01 +02:00
4e2777b917 feat(ui): add native platform icon sets 2026-07-22 18:47:23 +02:00
Hammed Abass
b027177ad3 Merge pull request #25 from sudosylabs/feat/storage-accounting
fix(storage): reclaim transfer cache and track received files
2026-07-22 17:33:25 +02:00
Hammed Abass
6388d42ec1 Merge pull request #24 from sudosylabs/feat/native-platform-ui
feat(ui): add native platform experiences
2026-07-22 16:35:46 +02:00
b46c5e7d72 fix(storage): reclaim transfer cache and track received files 2026-07-22 16:03:37 +02:00
be7a61e948 feat(desktop): integrate native Windows titlebar 2026-07-22 15:49:41 +02:00
0e97c014a1 feat(desktop): add native Windows window controller 2026-07-22 15:49:11 +02:00
0ab076e5b2 feat(ui): support native desktop backdrops 2026-07-22 15:48:16 +02:00
a0d352895e feat(ui): align Android empty states 2026-07-22 12:26:35 +02:00
cf7734fb42 feat(desktop): polish Linux native experience 2026-07-22 11:23:10 +02:00
627c205853 feat(ui): adapt presentation to each platform 2026-07-22 10:04:32 +02:00
Hammed Abass
4feff29c74 Merge pull request #23 from sudosylabs/feat/remove-apple-kmp-targets
refactor(platform): move Apple apps out of KMP
2026-07-21 18:46:21 +02:00
873ec6fd94 fix(diagnostics): stabilize pending crash ordering 2026-07-21 18:23:50 +02:00
a8a873c83d build: unify project development commands 2026-07-21 18:07:06 +02:00
9e8564e013 refactor(platform): remove Apple targets from KMP 2026-07-21 17:09:59 +02:00
Hammed Abass
d8eaf78997 Merge pull request #21 from sudosylabs/feat/localize
Single-source localization + 8 new languages
2026-07-21 16:40:11 +02:00
8c1ce16c0e Merge branch 'master' into feat/localize
merge(master): Fix for fileImporter before iOS/macOS 27
2026-07-20 18:25:06 +02:00
Hammed Abass
419c35d6b5 Merge pull request #22 from sudosylabs/feat/fix-file-importer
fix(apple): make "Choose files" work on iOS/macOS < 27
2026-07-20 18:21:57 +02:00
0011dfa175 fix(apple): make "Choose files" work on iOS/macOS < 27
The send flow stacked two .fileImporter modifiers on the same view (one
for files, one for folders). On iOS/macOS before 27, SwiftUI can't have
two presentation modifiers of the same kind on one view — the second
shadows the first, so toggling the files importer presented nothing and
"Choose files" appeared to do nothing. macOS/iOS 27 changed presentation
handling, which is why it worked there.

Collapse the two importers into a single .fileImporter that switches its
allowedContentTypes and allowsMultipleSelection based on whether a file
or folder pick is pending. Behavior is unchanged on 27 and now works on
26 and earlier.
2026-07-20 18:11:35 +02:00
276ee9f974 i18n(ru): avoid seeding terminology
Replace "раздача"/"раздаваться" (literally "seeding") with neutral
"общий доступ"/"отправка" phrasing across the Russian strings, to avoid
BitTorrent connotations for App Store review.
2026-07-20 12:40:57 +02:00
8178296d92 feat(l10n): add Russian translations
Translate all 253 user-facing strings to Russian (professional UI register;
count strings use number-neutral "label: {count}" form). transfer_file_count
uses full CLDR plural categories (one/few/many/other). Generates
values-ru/strings.xml for KMP, ru localizations in the Apple catalog, and adds
ru to CFBundleLocalizations. Completes the initial 8-language set.
2026-07-20 11:38:12 +02:00
0efde72414 feat(l10n): add Dutch translations
Translate all 253 user-facing strings to Dutch (formal "u"), including the
transfer_file_count plural. Generates values-nl/strings.xml for KMP, nl
localizations in the Apple catalog, and adds nl to CFBundleLocalizations.
2026-07-20 11:34:21 +02:00
48a523371c feat(l10n): add Polish translations
Translate all 253 user-facing strings to Polish (standard professional UI
register; gender-neutral phrasing; count strings use number-neutral "label:
{count}" form). transfer_file_count uses full CLDR plural categories
(one/few/many/other). Generates values-pl/strings.xml for KMP, pl localizations
in the Apple catalog, and adds pl to CFBundleLocalizations.
2026-07-20 11:29:58 +02:00
674e59f8cc feat(l10n): add European Portuguese translations
Translate all 253 user-facing strings to European Portuguese (pt-PT, formal;
EP vocabulary — ficheiro, guardar, Definições, partilhar). Generates
values-pt/strings.xml for KMP, pt localizations in the Apple catalog, and adds
pt to CFBundleLocalizations.
2026-07-20 11:25:11 +02:00
0309cfd649 feat(l10n): add German translations
Translate all 253 user-facing strings to German (formal "Sie"; standard iOS
term conventions). Generates values-de/strings.xml for KMP, de localizations in
the Apple catalog, and adds de to CFBundleLocalizations.
2026-07-20 11:20:32 +02:00
ea07f5661d feat(l10n): add Italian translations
Translate all 253 user-facing strings to Italian (formal "Lei"; standard iOS
button terms follow Apple conventions). Generates values-it/strings.xml for
KMP, it localizations in the Apple catalog, and adds it to CFBundleLocalizations.
2026-07-20 11:15:54 +02:00
141be2e55a feat(l10n): add Spanish translations
Translate all 253 user-facing strings to Spanish (formal "usted"), including
the transfer_file_count plural. Generates values-es/strings.xml for KMP, es
localizations in the Apple catalog, and adds es to CFBundleLocalizations.
2026-07-20 11:11:12 +02:00
c71e1b97d9 feat(l10n): advertise app localizations to iOS
The per-app language picker in iOS Settings only appears when the built app
declares multiple localizations. Add the planned languages to the Xcode
project's knownRegions (so Xcode compiles their .lproj from the String
Catalog), and have `generate` keep CFBundleLocalizations in Info.plist in sync
with supportedLanguages (currently en, fr).
2026-07-20 10:41:41 +02:00
91573f381a feat(l10n): add French translations
Translate all 253 user-facing strings to French (formal register), including
the transfer_file_count plural. Generates values-fr/strings.xml for KMP and
fr localizations in the Apple catalog (marked needs_review).
2026-07-20 10:34:52 +02:00
4448822bfa refactor(l10n): default strings to all targets
Most strings were imported with a platform-specific `targets` (usually
apple-only) just because that's where they happen to be used today. Drop the
restriction so they default to all targets and are available to KMP too; only
the four literal `%@…` format-composition keys stay apple-only.

Also fix the placeholder parser to tolerate C length modifiers (`%lld`), and
give progress_sending_to_count a proper int arg so it emits `%1$d` on both
platforms instead of a literal `%lld`.
2026-07-20 10:24:54 +02:00
3c0f29adb6 docs(l10n): describe context for every string key
Replace the placeholder `context` on all 255 keys in strings.json with a
description of where each string appears and its purpose, and regenerate the
xcstrings so the comments flow through to the Apple catalog.
2026-07-20 10:20:34 +02:00
cd6a2d66aa feat(l10n): single-source localization pipeline
Add localization/ — one strings.json is the source of truth for every
user-facing string, and a Bun CLI generates the platform files:

  loc migrate   rebuild strings.json from existing platform files (one-time)
  loc validate  structural checks (plural `other`, arg refs, coverage)
  loc generate  emit .xcstrings (Apple) + per-language strings.xml (KMP)

Canonical `{name}` placeholders are converted to each platform's positional
printf tokens using declared arg types. Plurals use CLDR categories and emit
native forms: xcstrings plural variations and Compose <plurals> blocks.

Migration folds the KMP `transfer_file_count_one`/`_other` pair into a single
plural key, so `transferFileCountResource` now returns the plural resource and
callers resolve it with pluralStringResource.
2026-07-20 10:15:28 +02:00
Hammed Abass
9a3cfb55d0 Merge pull request #20 from sudosylabs/feat/port-swift
feat(apple): native SwiftUI app for iOS, iPadOS, and macOS
2026-07-20 00:22:21 +02:00
17099d32f2 test(apple): CoreGateway seam, XCTest suite, and CI
- Introduce a CoreGateway protocol so feature models depend on a seam
  (CoreRepository conforms); enables faking the core in tests
- Add a VniDropTests target with 42 tests mirroring the KMP suites:
  approval coordinator, send/receive/settings/app models, preferences,
  file previews, invitation decode, message queue, error mapping
- Add a fake gateway/file-system/device-info and fixtures
- Add .github/workflows/apple.yml: build the Rust core, generate the
  project, and run the tests on an iOS Simulator
2026-07-19 23:48:11 +02:00
e35c8c840a feat(apple): replace About source-code link with privacy policy 2026-07-19 23:20:45 +02:00
b1b8fa202c refactor(apple): adopt Swift 6 language mode
Enable complete strict concurrency and switch the app target to Swift 6.

- Isolate model dependency protocols to @MainActor
- Make the CoreRepository blocking-FFI bridge race-free: nonisolated(unsafe)
  core handle, nonisolated runCore/readSnapshot, @Sendable work block,
  Sendable domain models
- Fix Binding method-reference captures; @preconcurrency imports for
  CoreNFC/AVFoundation/VnidropCore; isolate the NFC/QR delegate helpers
2026-07-19 18:39:34 +02:00
bfa489def1 feat(apple): storage screen, About content, and fixes
- Settings: add Storage screen (size breakdown + delete-all-transfers) and
  expand About (what it is/isn't, privacy & security, license/source)
- Move Report a bug to a toolbar sheet (cancel-only unless empty)
- Send progress: derive the list-row bar from receiver delivery status so it
  clears once every receiver completes
- Fixes: iPad orientations, onChange(of:) iOS 17 API, weak-self captures,
  invalid SF Symbol, macOS bug-report form labels; bump core build target to
  match the app (18.2/15.0)
2026-07-19 13:58:20 +02:00
ceccfcda71 feat(apple): transfer controls, progress fixes, and project config
- Sender progress: aggregate only in-flight receivers so the bar clears on
  completion and is order-independent ("Sending to N")
- Add Stop sharing and per-receiver Refuse (pending requests) on the sender
- Fix macOS: raise approval modal above the Share sheet; drive foreground
  state off NSApplication so background notifications fire
- Persist app identity (display name, category) and signing team via
  Info.plist / project.yml / gitignored Local.xcconfig
2026-07-19 12:33:58 +02:00
ea376a382b feat(apple): native SwiftUI app for iOS and macOS
Add a native SwiftUI VniDrop app (Send/Receive/Settings) talking to the
Rust core via generated UniFFI Swift bindings, plus the uniffi-bindgen
helper crate. iOS uses a TabView, macOS a NavigationSplitView sidebar.
2026-07-18 22:06:30 +02:00
601cbc486e Merge with master 2026-07-18 11:57:55 +02:00
c14a2397ae ios build 2026-07-18 11:39:35 +02:00
Hammed Abass
6973cc7351 Merge pull request #19 from sudosylabs/feat/linux-release
ci: build Linux release packages
2026-07-17 15:17:25 +02:00
ec03f210dc fix(ci): expose Android SDK to RPM container 2026-07-17 14:51:16 +02:00
d0844068bb ci: build Linux release packages 2026-07-17 02:08:57 +02:00
669 changed files with 66338 additions and 16471 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(swift test *)"
]
}
}

View File

@@ -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)

View File

@@ -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.

View File

@@ -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."

View File

@@ -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

View File

@@ -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 })
```

View File

@@ -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).

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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.

View File

@@ -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).

View File

@@ -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).

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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).

View File

@@ -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 |

View File

@@ -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` |

View File

@@ -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.

View File

@@ -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

View File

@@ -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 |

View File

@@ -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).

View File

@@ -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.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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) }
}
}
```

View File

@@ -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 3specific 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 3specific 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(),
)
```

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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)

View File

@@ -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 |

View File

@@ -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.

View File

@@ -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() }` |

View File

@@ -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))
```

View File

@@ -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 |

View File

@@ -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)
}
```

View File

@@ -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)

View File

@@ -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 **
```

View 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?

View File

@@ -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

View File

@@ -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 |

View File

@@ -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 |

View File

@@ -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)))
}
}
```

1
.gitattributes vendored Normal file
View File

@@ -0,0 +1 @@
*.af filter=lfs diff=lfs merge=lfs -text

141
.github/workflows/android-release.yml vendored Normal file
View File

@@ -0,0 +1,141 @@
name: Android release package
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: android-release-${{ github.ref }}
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
name: Build signed Android APK and AAB
runs-on: ubuntu-24.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: "21.0.11+10.0.LTS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-home-cache-strict-match: true
- name: Install Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: "1.91.0"
targets: aarch64-linux-android,x86_64-linux-android
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: android-release-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
restore-keys: |
android-release-cargo-1.91.0-
- name: Set up Android SDK
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: "platform-tools platforms;android-36 build-tools;36.0.0"
- name: Set up Android NDK
id: setup-ndk
uses: nttld/setup-ndk@ed92fe6cadad69be94a966a7ee3271275e62f779 # v1
with:
ndk-version: r27c
link-to-sdk: true
add-to-path: false
- name: Export Android NDK location
run: |
echo "ANDROID_NDK_HOME=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
- name: Resolve canonical version
id: version
run: |
packaging/version/resolve-version.sh verify >/dev/null
echo "app=$(packaging/version/resolve-version.sh product)" >> "$GITHUB_OUTPUT"
echo "code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT"
- name: Validate signing configuration
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }}
KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }}
KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }}
UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }}
run: |
for name in \
KEYSTORE_BASE64 \
KEYSTORE_PASSWORD \
KEY_ALIAS \
KEY_PASSWORD \
UPLOAD_CERT_SHA256; do
if [ -z "${!name:-}" ]; then
echo "Missing Android release signing configuration: $name" >&2
exit 1
fi
done
- name: Decode upload keystore
env:
KEYSTORE_BASE64: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_BASE64 }}
run: |
keystore="$RUNNER_TEMP/vnidrop-upload.jks"
printf '%s' "$KEYSTORE_BASE64" | base64 --decode > "$keystore"
chmod 600 "$keystore"
test -s "$keystore"
echo "VNIDROP_ANDROID_KEYSTORE_PATH=$keystore" >> "$GITHUB_ENV"
- name: Build and verify signed release
env:
VNIDROP_ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEYSTORE_PASSWORD }}
VNIDROP_ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_UPLOAD_KEY_ALIAS }}
VNIDROP_ANDROID_KEY_PASSWORD: ${{ secrets.ANDROID_UPLOAD_KEY_PASSWORD }}
VNIDROP_ANDROID_UPLOAD_CERT_SHA256: ${{ vars.ANDROID_UPLOAD_CERT_SHA256 }}
run: packaging/android/build-release.sh
- name: Remove upload keystore
if: always()
run: rm -f "$RUNNER_TEMP/vnidrop-upload.jks"
- name: Upload Android artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-android-release
path: build/release/android/
if-no-files-found: error
retention-days: 90
compression-level: 0
- name: Summarize Android package
run: |
echo "### Android release package" >> "$GITHUB_STEP_SUMMARY"
echo "- Version: ${{ steps.version.outputs.app }}" >> "$GITHUB_STEP_SUMMARY"
echo "- Version code: ${{ steps.version.outputs.code }}" >> "$GITHUB_STEP_SUMMARY"
echo "- Signing: upload certificate verified" >> "$GITHUB_STEP_SUMMARY"

168
.github/workflows/apple-release.yml vendored Normal file
View File

@@ -0,0 +1,168 @@
name: Apple release (macOS DMG)
# Builds, signs, notarizes, and uploads the direct-download macOS build:
# - a Developer IDsigned, notarized VniDrop-<version>.dmg,
# - a Sparkle appcast.xml.
#
# The central release workflow publishes these artifacts and updates Homebrew.
#
# The App Store / TestFlight build is NOT produced here — that goes through Xcode
# Organizer / App Store Connect. This workflow only covers direct distribution.
#
# Called by the central tag-release workflow, or run manually to validate the
# signed/notarized direct-download artifact.
on:
workflow_call:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: apple-release-${{ github.ref }}
cancel-in-progress: false
defaults:
run:
shell: bash
jobs:
build:
name: Build & notarize DMG
runs-on: macos-latest
timeout-minutes: 90
outputs:
version: ${{ steps.version.outputs.app }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify tag is on master
if: github.event_name == 'push'
run: |
if ! git merge-base --is-ancestor "$GITHUB_SHA" origin/master; then
echo "Release tags must point to a commit on master" >&2
exit 1
fi
- name: Resolve canonical version
id: version
run: |
packaging/version/resolve-version.sh verify >/dev/null
version="$(packaging/version/resolve-version.sh product)"
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode.app
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: stable
targets: aarch64-apple-darwin
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: apple-release-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: apple-release-cargo-
- name: Install tooling
run: brew install xcodegen swiftlint create-dmg
- name: Install Bun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- name: Download Sparkle tools
# generate_appcast + sign_update ship in the Sparkle release tarball.
run: |
set -euo pipefail
ver="2.9.4"
curl -fsSL -o /tmp/sparkle.tar.xz \
"https://github.com/sparkle-project/Sparkle/releases/download/${ver}/Sparkle-${ver}.tar.xz"
mkdir -p /tmp/sparkle && tar -xJf /tmp/sparkle.tar.xz -C /tmp/sparkle
echo "SPARKLE_BIN=/tmp/sparkle/bin" >> "$GITHUB_ENV"
- name: Import Developer ID certificate
env:
CERT_P12_BASE64: ${{ secrets.DEVELOPER_ID_CERT_P12 }}
CERT_PASSWORD: ${{ secrets.DEVELOPER_ID_CERT_PASSWORD }}
run: |
set -euo pipefail
keychain="$RUNNER_TEMP/signing.keychain-db"
kpw="$(openssl rand -hex 20)"
security create-keychain -p "$kpw" "$keychain"
security set-keychain-settings -lut 21600 "$keychain"
security unlock-keychain -p "$kpw" "$keychain"
echo "$CERT_P12_BASE64" | base64 --decode > "$RUNNER_TEMP/cert.p12"
security import "$RUNNER_TEMP/cert.p12" -k "$keychain" -P "$CERT_PASSWORD" \
-T /usr/bin/codesign -T /usr/bin/security
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$kpw" "$keychain"
# Prepend our keychain so codesign/xcodebuild can find the identity.
security list-keychains -d user -s "$keychain" $(security list-keychains -d user | tr -d '"')
rm -f "$RUNNER_TEMP/cert.p12"
- name: Store notarytool credentials
env:
NOTARY_KEY_P8: ${{ secrets.NOTARY_API_KEY }}
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }}
run: |
set -euo pipefail
echo "$NOTARY_KEY_P8" | base64 --decode > "$RUNNER_TEMP/notary.p8"
xcrun notarytool store-credentials vnidrop-notary \
--key "$RUNNER_TEMP/notary.p8" \
--key-id "$NOTARY_KEY_ID" \
--issuer "$NOTARY_ISSUER"
echo "NOTARY_PROFILE=vnidrop-notary" >> "$GITHUB_ENV"
- name: Write Sparkle signing key
env:
SPARKLE_ED_PRIVATE_KEY: ${{ secrets.SPARKLE_ED_PRIVATE_KEY }}
run: |
printf '%s' "$SPARKLE_ED_PRIVATE_KEY" > "$RUNNER_TEMP/sparkle_ed_private_key"
echo "SPARKLE_ED_KEY_FILE=$RUNNER_TEMP/sparkle_ed_private_key" >> "$GITHUB_ENV"
- name: Build, sign & notarize DMG
run: make build-apple-dmg
- name: Package prebuilt core
# build-apple-dmg builds the release Rust core + Swift bindings; bundle them
# (xcframework + Vnidrop.swift + checksum) as a release asset so consumers can
# skip building the core. See apple/scripts/package-core.sh.
run: make package-apple-core
- name: Upload notarization diagnostics
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-notarization-diagnostics
path: apple/dist/*.notary-log.json
if-no-files-found: ignore
retention-days: 14
- name: Generate appcast
env:
RELEASE_REPO: ${{ github.repository }}
run: apple/scripts/generate-appcast.sh
- name: Upload artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-macos-dmg
path: |
apple/dist/VniDrop-*.dmg
apple/dist/VniDrop-*.build-info.json
apple/dist/appcast.xml
apple/dist/VnidropCore-*.zip
apple/dist/VnidropCore-*.zip.sha256
if-no-files-found: error
retention-days: 14

87
.github/workflows/apple.yml vendored Normal file
View File

@@ -0,0 +1,87 @@
name: Apple
on:
pull_request:
paths:
- "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "crates/uniffi-bindgen/**"
- "Cargo.toml"
- "Cargo.lock"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/apple.yml"
- "localization/**"
push:
branches:
- master
paths:
- "apple/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "crates/uniffi-bindgen/**"
- "Cargo.toml"
- "Cargo.lock"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/apple.yml"
- "localization/**"
permissions:
contents: read
concurrency:
group: apple-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-test:
runs-on: macos-latest
timeout-minutes: 75
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Select Xcode
# Pin so the simulator device name below stays predictable.
run: sudo xcode-select -s /Applications/Xcode.app
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-ios,aarch64-apple-ios-sim,x86_64-apple-ios,aarch64-apple-darwin
- name: Cache Cargo
uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: apple-cargo-${{ hashFiles('Cargo.lock') }}
restore-keys: apple-cargo-
- name: Install XcodeGen
run: brew install xcodegen
- name: Install SwiftLint
# Required by the VniDrop target's SwiftLint build phase (typed-resources rules).
run: brew install swiftlint
- name: Install Bun
# The Apple l10n catalog (Localizable.xcstrings) and L10n.swift are
# generated from localization/strings.json at build time, not tracked.
uses: oven-sh/setup-bun@v2
- name: Build and test Apple app
run: make check-apple
- name: Build direct-download macOS target (Sparkle, unsigned)
# Keeps the VniDropDirect (.dmg/Sparkle) target compiling; signing and
# notarization happen only in apple-release.yml on a tag.
run: make build-apple-macos-direct

View File

@@ -4,12 +4,18 @@ on:
pull_request:
paths:
- "services/diagnostics-api/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/diagnostics-api.yml"
push:
branches:
- master
paths:
- "services/diagnostics-api/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/diagnostics-api.yml"
permissions:
@@ -23,9 +29,6 @@ jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: services/diagnostics-api
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -37,17 +40,5 @@ jobs:
cache: npm
cache-dependency-path: services/diagnostics-api/package-lock.json
- name: Install dependencies
run: npm ci
- name: Verify generated Worker types
run: npm run types:check
- name: Type-check
run: npm run typecheck
- name: Test in the Workers runtime
run: npm test
- name: Validate the deployment bundle
run: npm run deploy:dry-run
- name: Check diagnostics API
run: make check-diagnostics

View File

@@ -4,12 +4,18 @@ on:
pull_request:
paths:
- "docs/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/docs.yml"
push:
branches:
- master
paths:
- "docs/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/docs.yml"
permissions:
@@ -23,9 +29,6 @@ jobs:
quality:
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: docs
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -37,11 +40,5 @@ jobs:
cache: npm
cache-dependency-path: docs/package-lock.json
- name: Install dependencies
run: npm ci
- name: Type-check
run: npm run typecheck
- name: Build
run: npm run build
- name: Check documentation website
run: make check-docs

212
.github/workflows/linux-packages.yml vendored Normal file
View File

@@ -0,0 +1,212 @@
name: Linux packages
on:
pull_request:
paths:
- ".github/workflows/linux-packages.yml"
- "packaging/linux/**"
- "packaging/version/**"
- "version.properties"
- "assets/linux/**"
- "desktopApp/**"
- "shared/**"
- "crates/vnidrop/**"
- "Cargo.toml"
- "Cargo.lock"
- "LICENSE"
- "build.gradle.kts"
- "settings.gradle.kts"
- "gradle.properties"
- "gradle/**"
- "gradlew"
- "Makefile"
- "config.mk"
- "make/**"
workflow_call:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: linux-packages-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
defaults:
run:
shell: bash
jobs:
build-deb:
name: Build Debian package (x64)
runs-on: ubuntu-22.04
timeout-minutes: 90
env:
CARGO_TERM_COLOR: always
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install --yes fakeroot unzip
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: "21.0.11+10.0.LTS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-home-cache-strict-match: true
- name: Set up Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: "1.91.0"
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-deb-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
restore-keys: |
linux-deb-x64-cargo-1.91.0-
- name: Resolve canonical version
id: version
run: |
version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Test and build Debian package
run: make package-deb
- name: Upload Debian artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-linux-deb-x64
path: build/release/linux/deb/
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Summarize Debian package
run: |
echo "### Debian package" >> "$GITHUB_STEP_SUMMARY"
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
echo "- Architecture: amd64" >> "$GITHUB_STEP_SUMMARY"
echo "- Build baseline: Ubuntu 22.04" >> "$GITHUB_STEP_SUMMARY"
build-rpm:
name: Build RPM package (x64)
runs-on: ubuntu-24.04
container:
image: registry.fedoraproject.org/fedora:43
volumes:
- /usr/local/lib/android/sdk:/usr/local/lib/android/sdk
timeout-minutes: 90
env:
ANDROID_HOME: /usr/local/lib/android/sdk
ANDROID_SDK_ROOT: /usr/local/lib/android/sdk
CARGO_TERM_COLOR: always
steps:
- name: Install build and packaging tools
run: |
dnf install --assumeyes \
alsa-lib \
cpio \
curl \
cups-libs \
desktop-file-utils \
findutils \
fontconfig \
freetype \
gcc \
gcc-c++ \
git \
gzip \
gtk3 \
libX11 \
libXext \
libXi \
libXrandr \
libXrender \
libXtst \
make \
mesa-libGL \
rpm-build \
tar \
unzip \
which \
xz \
zstd
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up JDK 21
uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: temurin
java-version: "21.0.11+10.0.LTS"
- name: Set up Gradle
uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0
with:
gradle-home-cache-strict-match: true
- name: Set up Rust 1.91
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # v1
with:
toolchain: "1.91.0"
- name: Cache Cargo
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: linux-rpm-x64-cargo-1.91.0-${{ hashFiles('Cargo.lock') }}
restore-keys: |
linux-rpm-x64-cargo-1.91.0-
- name: Resolve canonical version
id: version
run: |
version=$(packaging/linux/resolve-version.sh)
echo "app=$version" >> "$GITHUB_OUTPUT"
- name: Build RPM package
run: make package-rpm
- name: Upload RPM artifact
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ steps.version.outputs.app }}-linux-rpm-x64
path: build/release/linux/rpm/
if-no-files-found: error
retention-days: 14
compression-level: 0
- name: Summarize RPM package
run: |
echo "### RPM package" >> "$GITHUB_STEP_SUMMARY"
echo "- Version: ${{ steps.version.outputs.app }}-1" >> "$GITHUB_STEP_SUMMARY"
echo "- Architecture: x86_64" >> "$GITHUB_STEP_SUMMARY"
echo "- Build environment: Fedora 43" >> "$GITHUB_STEP_SUMMARY"

53
.github/workflows/release-checks.yml vendored Normal file
View File

@@ -0,0 +1,53 @@
name: Release pipeline checks
on:
pull_request:
paths:
- ".github/workflows/android-release.yml"
- ".github/workflows/apple-release.yml"
- ".github/workflows/linux-packages.yml"
- ".github/workflows/release-checks.yml"
- ".github/workflows/release.yml"
- ".github/workflows/windows-store.yml"
- "packaging/android/**"
- "packaging/release/**"
- "packaging/version/**"
- "version.properties"
- "Makefile"
push:
branches:
- master
paths:
- ".github/workflows/android-release.yml"
- ".github/workflows/apple-release.yml"
- ".github/workflows/linux-packages.yml"
- ".github/workflows/release-checks.yml"
- ".github/workflows/release.yml"
- ".github/workflows/windows-store.yml"
- "packaging/android/**"
- "packaging/release/**"
- "packaging/version/**"
- "version.properties"
- "Makefile"
permissions:
contents: read
concurrency:
group: release-checks-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
scripts:
name: Validate release scripts
runs-on: ubuntu-24.04
timeout-minutes: 5
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run release checks
run: make check-release

459
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,459 @@
name: Release
on:
push:
tags:
- "v*.*.*"
permissions:
contents: read
concurrency:
group: vnidrop-release
cancel-in-progress: false
jobs:
preflight:
name: Verify release tag
if: ${{ vars.RELEASE_PIPELINE_ENABLED == 'true' }}
runs-on: ubuntu-24.04
timeout-minutes: 10
outputs:
version: ${{ steps.version.outputs.app }}
android_code: ${{ steps.version.outputs.android_code }}
steps:
- name: Checkout release history
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
- name: Verify canonical beta tag on current master
id: version
run: |
set -euo pipefail
packaging/version/resolve-version.sh verify >/dev/null
version="$(packaging/version/resolve-version.sh product)"
channel="$(packaging/version/resolve-version.sh channel)"
master_sha="$(git rev-parse origin/master)"
if [ "$GITHUB_SHA" != "$master_sha" ]; then
echo "Release tags must point at the current master commit" >&2
exit 1
fi
if [ "$channel" != "beta" ]; then
echo "Only beta closed-testing releases are enabled" >&2
exit 1
fi
echo "app=$version" >> "$GITHUB_OUTPUT"
echo "android_code=$(packaging/version/resolve-version.sh android-code)" >> "$GITHUB_OUTPUT"
- name: Refuse an existing GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "GitHub Release $GITHUB_REF_NAME already exists" >&2
exit 1
fi
linux:
name: Linux packages
needs: preflight
uses: ./.github/workflows/linux-packages.yml
windows:
name: Windows Store package
needs: preflight
uses: ./.github/workflows/windows-store.yml
macos:
name: Signed and notarized macOS package
needs: preflight
uses: ./.github/workflows/apple-release.yml
secrets: inherit
android:
name: Signed Android package
needs: preflight
uses: ./.github/workflows/android-release.yml
secrets: inherit
play-closed-testing:
name: Stage Play closed-testing draft
needs:
- preflight
- linux
- windows
- macos
- android
runs-on: ubuntu-24.04
timeout-minutes: 20
environment: play-closed-testing
permissions:
contents: read
id-token: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download signed Android artifacts
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-release
path: build/release/android
- name: Validate closed-testing configuration
env:
WORKLOAD_IDENTITY_PROVIDER: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
PLAY_SERVICE_ACCOUNT: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }}
PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }}
PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
run: |
for name in \
WORKLOAD_IDENTITY_PROVIDER \
PLAY_SERVICE_ACCOUNT \
PLAY_PACKAGE_NAME \
PLAY_CLOSED_TRACK \
PLAY_APP_SIGNING_CERT_SHA256; do
if [ -z "${!name:-}" ]; then
echo "Missing Play closed-testing configuration: $name" >&2
exit 1
fi
done
case "${PLAY_CLOSED_TRACK,,}" in
production|*:production)
echo "Production Play tracks are forbidden" >&2
exit 1
;;
esac
if [ "$PLAY_PACKAGE_NAME" != "com.vnidrop.app" ]; then
echo "Unexpected Play package name: $PLAY_PACKAGE_NAME" >&2
exit 1
fi
- name: Authenticate to Google with GitHub OIDC
id: google-auth
uses: google-github-actions/auth@7c6bc770dae815cd3e89ee6cdf493a5fab2cc093 # v3
with:
workload_identity_provider: ${{ vars.GCP_WORKLOAD_IDENTITY_PROVIDER }}
service_account: ${{ vars.GCP_PLAY_SERVICE_ACCOUNT }}
token_format: access_token
access_token_scopes: https://www.googleapis.com/auth/androidpublisher
- name: Stage AAB and download Play-signed APK
env:
GOOGLE_PLAY_ACCESS_TOKEN: ${{ steps.google-auth.outputs.access_token }}
PLAY_PACKAGE_NAME: ${{ vars.PLAY_PACKAGE_NAME }}
PLAY_CLOSED_TRACK: ${{ vars.PLAY_CLOSED_TRACK }}
PLAY_APP_SIGNING_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
VERSION: ${{ needs.preflight.outputs.version }}
VERSION_CODE: ${{ needs.preflight.outputs.android_code }}
run: |
set -euo pipefail
shopt -s nullglob
bundles=(build/release/android/*.aab)
if [ "${#bundles[@]}" -ne 1 ]; then
echo "Expected exactly one signed AAB" >&2
exit 1
fi
mkdir -p build/release/play
python3 packaging/android/publish_play.py \
--bundle "${bundles[0]}" \
--package-name "$PLAY_PACKAGE_NAME" \
--track "$PLAY_CLOSED_TRACK" \
--version-code "$VERSION_CODE" \
--release-name "$VERSION" \
--expected-app-certificate "$PLAY_APP_SIGNING_CERT_SHA256" \
--apk-output "build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \
--metadata-output build/release/play/play-release.json
- name: Set up Android SDK verification tools
uses: android-actions/setup-android@9fc6c4e9069bf8d3d10b2204b1fb8f6ef7065407 # v3
with:
packages: "platform-tools build-tools;36.0.0"
- name: Verify Play-signed universal APK
env:
EXPECTED_CERT_SHA256: ${{ vars.PLAY_APP_SIGNING_CERT_SHA256 }}
VERSION: ${{ needs.preflight.outputs.version }}
VERSION_CODE: ${{ needs.preflight.outputs.android_code }}
run: |
set -euo pipefail
apk="build/release/play/VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk"
apkanalyzer_path="$(
find "$ANDROID_SDK_ROOT/cmdline-tools" -type f -name apkanalyzer -perm -111 |
sort -r |
head -1
)"
if [ -z "$apkanalyzer_path" ]; then
echo "apkanalyzer was not found" >&2
exit 1
fi
packaging/android/verify-apk-signature.sh \
"$apk" \
"$EXPECTED_CERT_SHA256" \
>/dev/null
if [ "$("$apkanalyzer_path" manifest application-id "$apk")" != "com.vnidrop.app" ]; then
echo "Play APK package name mismatch" >&2
exit 1
fi
if [ "$("$apkanalyzer_path" manifest version-name "$apk")" != "$VERSION" ]; then
echo "Play APK version name mismatch" >&2
exit 1
fi
if [ "$("$apkanalyzer_path" manifest version-code "$apk")" != "$VERSION_CODE" ]; then
echo "Play APK version code mismatch" >&2
exit 1
fi
(
cd build/release/play
sha256sum \
"VniDrop-${VERSION}-${VERSION_CODE}-play-universal.apk" \
play-release.json \
> SHA256SUMS
)
- name: Upload Play-signed APK
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-play
path: build/release/play/
if-no-files-found: error
retention-days: 90
compression-level: 0
publish-microsoft-store:
name: Submit Microsoft Store update
needs:
- preflight
- linux
- windows
- macos
- play-closed-testing
runs-on: windows-2025
timeout-minutes: 30
environment: microsoft-store
permissions:
contents: read
steps:
- name: Download Windows Store package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
path: build/release/windows
- name: Validate Microsoft Store configuration
id: store-package
shell: pwsh
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
$configuration = @{
AZURE_AD_TENANT_ID = $env:AZURE_AD_TENANT_ID
AZURE_AD_APPLICATION_CLIENT_ID = $env:AZURE_AD_APPLICATION_CLIENT_ID
AZURE_AD_APPLICATION_SECRET = $env:AZURE_AD_APPLICATION_SECRET
SELLER_ID = $env:SELLER_ID
MICROSOFT_STORE_PRODUCT_ID = $env:MICROSOFT_STORE_PRODUCT_ID
}
foreach ($entry in $configuration.GetEnumerator()) {
if ([string]::IsNullOrWhiteSpace($entry.Value) -or $entry.Value -eq "REPLACE_ME") {
throw "Missing Microsoft Store configuration: $($entry.Key)"
}
}
if ($env:MICROSOFT_STORE_PRODUCT_ID -ne "9NJ5Q0FG7TGL") {
throw "Unexpected Microsoft Store product ID: $env:MICROSOFT_STORE_PRODUCT_ID"
}
$packages = @(
Get-ChildItem build/release/windows -File -Filter *.msixupload -Recurse
)
if ($packages.Count -ne 1) {
throw "Expected exactly one msixupload package, found $($packages.Count)"
}
"path=$($packages[0].FullName)" >> $env:GITHUB_OUTPUT
- name: Set up Microsoft Store Developer CLI
uses: microsoft/microsoft-store-apppublisher@15abd1c50fcc164b19cb240fb04ef3c49bf715a2 # v1.1
with:
version: v0.3.9
- name: Authenticate and verify Store access
shell: pwsh
env:
AZURE_AD_TENANT_ID: ${{ secrets.AZURE_AD_TENANT_ID }}
AZURE_AD_APPLICATION_CLIENT_ID: ${{ secrets.AZURE_AD_APPLICATION_CLIENT_ID }}
AZURE_AD_APPLICATION_SECRET: ${{ secrets.AZURE_AD_APPLICATION_SECRET }}
SELLER_ID: ${{ secrets.SELLER_ID }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
msstore reconfigure `
--tenantId "$env:AZURE_AD_TENANT_ID" `
--sellerId "$env:SELLER_ID" `
--clientId "$env:AZURE_AD_APPLICATION_CLIENT_ID" `
--clientSecret "$env:AZURE_AD_APPLICATION_SECRET"
if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store authentication failed"
}
msstore settings --enableTelemetry false
if ($LASTEXITCODE -ne 0) {
throw "Failed to disable Microsoft Store CLI telemetry"
}
msstore apps get "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) {
throw "The Microsoft Store application is not accessible"
}
- name: Publish package to Microsoft Store
shell: pwsh
env:
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
STORE_PACKAGE: ${{ steps.store-package.outputs.path }}
run: |
msstore publish "$env:STORE_PACKAGE" `
--appId "$env:MICROSOFT_STORE_PRODUCT_ID"
if ($LASTEXITCODE -ne 0) {
throw "Microsoft Store package publication failed"
}
- name: Summarize Store submission
shell: pwsh
env:
VERSION: ${{ needs.preflight.outputs.version }}
MICROSOFT_STORE_PRODUCT_ID: ${{ vars.MICROSOFT_STORE_PRODUCT_ID }}
run: |
"### Microsoft Store submission" >> $env:GITHUB_STEP_SUMMARY
"- App version: $env:VERSION" >> $env:GITHUB_STEP_SUMMARY
"- Product ID: $env:MICROSOFT_STORE_PRODUCT_ID" >> $env:GITHUB_STEP_SUMMARY
"- Package submitted for certification" >> $env:GITHUB_STEP_SUMMARY
publish-github:
name: Publish coordinated GitHub Release
needs:
- preflight
- linux
- windows
- macos
- play-closed-testing
- publish-microsoft-store
runs-on: ubuntu-24.04
timeout-minutes: 20
permissions:
contents: write
id-token: write
attestations: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download Debian package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-linux-deb-x64
path: build/release/downloads/deb
- name: Download RPM package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-linux-rpm-x64
path: build/release/downloads/rpm
- name: Download macOS package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg
path: build/release/downloads/macos
- name: Download Windows Store package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-windows-store-x64
path: build/release/downloads/windows
- name: Download Play-signed APK
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-android-play
path: build/release/downloads/play
- name: Verify and assemble public release assets
run: packaging/release/assemble-release.sh
- name: Attest release provenance
uses: actions/attest@f7c74d28b9d84cb8768d0b8ca14a4bac6ef463e6 # v4
with:
subject-path: build/release/final/*
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
run: |
gh release create "$GITHUB_REF_NAME" \
build/release/final/* \
--repo "$GITHUB_REPOSITORY" \
--verify-tag \
--title "VniDrop ${{ needs.preflight.outputs.version }}" \
--generate-notes
update-homebrew:
name: Update Homebrew cask
needs:
- preflight
- macos
- publish-github
runs-on: ubuntu-24.04
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Download macOS package
uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 # v8.0.0
with:
name: vnidrop-${{ needs.preflight.outputs.version }}-macos-dmg
path: dist
- name: Render Homebrew cask
env:
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
sha="$(sha256sum "dist/VniDrop-${VERSION}.dmg" | cut -d' ' -f1)"
sed -e "s/^ version \".*\"/ version \"${VERSION}\"/" \
-e "s/^ sha256 \".*\"/ sha256 \"${sha}\"/" \
packaging/homebrew/vnidrop.rb > /tmp/vnidrop.rb
- name: Push cask to tap
env:
TAP_TOKEN: ${{ secrets.HOMEBREW_TAP_TOKEN }}
VERSION: ${{ needs.preflight.outputs.version }}
run: |
set -euo pipefail
git clone \
"https://x-access-token:${TAP_TOKEN}@github.com/sudosylabs/homebrew-vnidrop.git" \
tap
mkdir -p tap/Casks
cp /tmp/vnidrop.rb tap/Casks/vnidrop.rb
cd tap
git config user.name "vnidrop-release-bot"
git config user.email "release-bot@users.noreply.github.com"
git add Casks/vnidrop.rb
git commit -m "vnidrop ${VERSION}" || {
echo "Homebrew cask already matches ${VERSION}"
exit 0
}
git push

View File

@@ -6,6 +6,11 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/rust-core.yml"
push:
# Only after merge (or direct master pushes). Feature-branch work is covered
@@ -16,6 +21,11 @@ on:
- "Cargo.toml"
- "Cargo.lock"
- "crates/vnidrop/**"
- "version.properties"
- "packaging/version/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/rust-core.yml"
permissions:
@@ -33,18 +43,12 @@ jobs:
- uses: actions/checkout@v4
- name: Install Rust quality components
run: rustup component add clippy rustfmt
- name: Check formatting
run: cargo fmt --all -- --check
- name: Run strict Clippy
run: cargo clippy --workspace --all-targets -- -D warnings
- name: Run unit and integration tests
run: cargo test --workspace --all-targets
- name: Check documentation
env:
RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps
- 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
# Ignores are listed in .cargo/audit.toml for known transitive issues.
run: cargo audit
run: make audit-rust

View File

@@ -4,6 +4,8 @@ on:
pull_request:
paths:
- "shared/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "Cargo.toml"
- "Cargo.lock"
@@ -15,12 +17,17 @@ on:
- "gradle.properties"
- "androidApp/**"
- "desktopApp/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/shared-kmp.yml"
push:
branches:
- master
paths:
- "shared/**"
- "version.properties"
- "packaging/version/**"
- "crates/vnidrop/**"
- "Cargo.toml"
- "Cargo.lock"
@@ -32,6 +39,9 @@ on:
- "gradle.properties"
- "androidApp/**"
- "desktopApp/**"
- "Makefile"
- "config.mk"
- "make/**"
- ".github/workflows/shared-kmp.yml"
permissions:
@@ -44,8 +54,7 @@ concurrency:
jobs:
jvm-test:
# Host Rust embedding is enabled only for the current Gobley host target.
# This job stays on macOS to cover the Apple targets as well as JVM tests.
runs-on: macos-latest
runs-on: ubuntu-latest
timeout-minutes: 75
steps:
- name: Checkout
@@ -63,7 +72,7 @@ jobs:
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
targets: aarch64-apple-darwin,aarch64-linux-android,x86_64-linux-android
targets: aarch64-linux-android,x86_64-linux-android
- name: Cache Cargo
uses: actions/cache@v4
@@ -95,7 +104,7 @@ jobs:
echo "ANDROID_NDK_ROOT=${{ steps.setup-ndk.outputs.ndk-path }}" >> "$GITHUB_ENV"
- name: Run shared JVM tests
run: ./gradlew :shared:jvmTest --no-daemon --stacktrace
run: make check-shared
- name: Verify Android native libraries
run: ./gradlew :androidApp:verifyDebugVnidropLibraries --no-daemon --stacktrace
run: make verify-android-libs

View File

@@ -5,6 +5,8 @@ on:
paths:
- ".github/workflows/windows-store.yml"
- "packaging/windows/**"
- "packaging/version/**"
- "version.properties"
- "assets/windows/**"
- "desktopApp/**"
- "shared/**"
@@ -17,16 +19,8 @@ on:
- "gradle/**"
- "gradlew"
- "gradlew.bat"
push:
tags:
- "v*.*.*"
workflow_call:
workflow_dispatch:
inputs:
version:
description: Release version in MAJOR.MINOR.PATCH form
required: true
default: "1.0.0"
type: string
permissions:
contents: read
@@ -77,37 +71,13 @@ jobs:
restore-keys: |
windows-x64-cargo-1.91.0-
- name: Resolve Store version
- name: Resolve canonical version
id: version
shell: pwsh
env:
REQUESTED_VERSION: ${{ inputs.version || '1.0.0' }}
run: |
$version = $env:REQUESTED_VERSION
if ($env:GITHUB_REF_TYPE -eq "tag") {
if ($env:GITHUB_REF_NAME -notmatch "^v[0-9]+\.[0-9]+\.[0-9]+$") {
throw "Store release tags must use vMAJOR.MINOR.PATCH"
}
$version = $env:GITHUB_REF_NAME.Substring(1)
}
if ($version -notmatch "^[0-9]+\.[0-9]+\.[0-9]+$") {
throw "Version must use MAJOR.MINOR.PATCH"
}
$parts = $version.Split(".")
for ($index = 0; $index -lt $parts.Count; $index++) {
$part = $parts[$index]
$number = 0
if (-not [int]::TryParse($part, [ref] $number) -or $number.ToString() -ne $part) {
throw "Version components must be canonical integers"
}
if ($number -lt $(if ($index -eq 0) { 1 } else { 0 }) -or $number -gt 65535) {
throw "Version components must be between 0 and 65535, with a non-zero major"
}
}
"app=$version" >> $env:GITHUB_OUTPUT
"package=$version.0" >> $env:GITHUB_OUTPUT
$version = .\packaging\version\resolve-version.ps1 -Field Json -VerifyTag | ConvertFrom-Json
"app=$($version.productVersion)" >> $env:GITHUB_OUTPUT
"package=$($version.windowsPackageVersion)" >> $env:GITHUB_OUTPUT
- name: Test and build release app image
shell: pwsh
@@ -115,7 +85,6 @@ jobs:
$arguments = @(
":shared:jvmTest"
":desktopApp:createReleaseDistributable"
"-Pvnidrop.version=${{ steps.version.outputs.app }}"
"-Pvnidrop.desktop.rustVariant=release"
"-Pvnidrop.diagnostics.included=false"
"--no-daemon"
@@ -131,7 +100,6 @@ jobs:
shell: pwsh
run: |
$arguments = @{
Version = "${{ steps.version.outputs.app }}"
AppImage = ".\desktopApp\build\compose\binaries\main-release\app\VniDrop"
OutputDirectory = ".\build\release\windows"
}

9
.gitignore vendored
View File

@@ -19,7 +19,16 @@ captures
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

View File

@@ -13,13 +13,14 @@ Nested guides take precedence when editing under those trees:
## Project overview
VniDrop is a cross-platform **local P2P file transfer** app (Android, iOS, Desktop).
VniDrop is a cross-platform **local P2P file transfer** app.
| 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 |
| Shared KMP | `shared/` | Compose UI and platform bridges for Android, Windows, and Linux |
| Compose hosts | `androidApp/`, `desktopApp/` | Thin Android and Windows/Linux app shells |
| Apple app | `apple/` | Native SwiftUI UI using generated Rust/UniFFI Swift bindings |
**Invariant:** UI/platform opens files and handles pickers; **Rust streams bytes**.
Do not design features that move transfer payloads through Kotlin heap by default.
@@ -28,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
@@ -47,72 +54,76 @@ Domain docs (reference, do not paste into PRs):
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.
10. **`localization/strings.json` is the single source of truth for all localized
strings.** The KMP Compose resources (`shared/src/commonMain/composeResources/
values*/strings.xml`) and the Apple catalog + accessors
(`apple/VniDrop/Resources/Localizable.xcstrings`, `apple/VniDrop/Generated/
L10n.swift`) are **generated** by the loc CLI (`cd localization && bun run
src/cli.ts generate`) — never hand-edit them. To add/change a string: edit
`strings.json` (set `targets` to `kmp`, `apple`, or omit for both), then
regenerate. A key referenced in code but only present in a generated file will
be silently dropped the next time generation runs.
---
## Build and test
Install prerequisites when missing: Rust stable + rustfmt + clippy, JDK 17,
Android NDK/SDK only if building Android, Xcode only for iOS.
Install prerequisites when missing: GNU Make + Bash, Rust stable + rustfmt + clippy, JDK 17,
Android NDK/SDK only if building Android, Xcode only for the native Apple app.
### 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
make check-rust
```
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
make test-rust
make test-rust-output-sink
make test-rust-transfer
make test-rust-approval
make test-rust-lifecycle
```
After finishing Rust edits, format:
```bash
cargo fmt --all
make format
```
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.
`make check-rust` includes documentation with warnings denied, matching
`.github/workflows/rust-core.yml`.
### Shared KMP / Compose (`shared/`)
```bash
./gradlew :shared:jvmTest
./gradlew :shared:compileKotlinJvm
make check-shared
```
Other targets (slower / machine-dependent):
```bash
./gradlew :shared:testAndroidHostTest
./gradlew :shared:iosSimulatorArm64Test # macOS + Xcode
./gradlew :androidApp:assembleDebug
./gradlew :desktopApp:run
make test-android-host
make check-android
make run-desktop
```
**Note:** `jvmTest` CI currently runs on **macOS**. Gobley host cargo is enabled
for the current host and architecture, so local Linux and Windows builds embed
their matching desktop Rust library. Prefer macOS only when exact CI parity is
required.
**Note:** `jvmTest` CI runs on **Linux**. Gobley host cargo is enabled for the
current host and architecture, so local desktop builds embed their matching
Rust library.
### 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` |
| `crates/vnidrop/**` only | `make check-rust` |
| Cancel / export / sinks | Above + `make test-rust-output-sink` |
| `shared/**` only | `make test-shared` |
| Both | `make test-rust test-shared` |
| Docs only | No suite required; verify links/paths |
Do not kill long `cargo` / Gradle runs mid-flight unless they hang past several
@@ -134,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
@@ -144,12 +155,12 @@ 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
androidMain|jvmMain/ # expect/actual implementations
```
### Platform file rules (do not violate)
- Desktop / path-based iOS: paths; directory walk in Rust when `is_directory`.
- Windows/Linux desktop: 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.
@@ -194,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 skills 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.
@@ -274,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` |
@@ -295,6 +306,8 @@ branch from updated `master`.
- Flaky multi-minute sleeps in tests
- Unsigned commits when signing is required
- Force-push or secret commits without explicit user direction
- Hand-editing generated localization files (`values*/strings.xml`,
`Localizable.xcstrings`, `L10n.swift`) instead of `localization/strings.json`
---

43
CONTEXT.md Normal file
View 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`; todays 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 profiles 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

View File

@@ -35,24 +35,44 @@ Use a branch name that describes the outcome, such as
Install the tools needed for the area you plan to change:
- GNU Make and Bash for the root command interface
- JDK 17 or newer for Gradle and application builds
- Rust stable with `rustfmt` and Clippy for the transfer core
- Android SDK and NDK for Android builds
- Xcode on macOS for iOS builds and simulator tests
- Xcode and XcodeGen on macOS for native Apple builds and simulator tests
- Node.js 22.12 or newer for the optional diagnostics service
The first Rust and Gradle builds may take several minutes while dependencies are
downloaded and native components are compiled.
## Command Interface
Run development commands through the root `Makefile`. It keeps local and CI
commands aligned while continuing to delegate builds to Cargo, Gradle, Xcode,
Bun, and npm:
```bash
make help # list commands
make doctor # report missing host tools
make setup # install repository-local JavaScript dependencies
make check # portable Rust, shared, localization, docs, and service checks
```
Configuration can be passed on the command line, for example
`make package-deb VERSION=1.2.0`, or placed in an ignored
`config.override.mk`. Windows use requires GNU Make in a Bash environment; the
underlying Gradle and PowerShell entry points remain available when Make is not
installed.
## Repository Structure
| Path | Purpose |
|------|---------|
| `crates/vnidrop/` | Rust transfer core, persistence, approval, and streaming |
| `shared/` | Shared Kotlin Multiplatform UI and platform bridges |
| `shared/` | Compose Multiplatform UI and bridges for Android, Windows, and Linux |
| `androidApp/` | Android application shell |
| `iosApp/` | iOS application shell |
| `desktopApp/` | Desktop JVM application shell |
| `desktopApp/` | Windows/Linux JVM application shell |
| `apple/` | Native SwiftUI application and Rust/UniFFI integration for Apple platforms |
| `services/diagnostics-api/` | Optional Cloudflare diagnostics service |
Read the nearest contributor guidance before editing:
@@ -87,43 +107,48 @@ Run checks from the repository root. Choose the suite for the files you changed.
### Rust Core
```bash
cargo fmt --all
cargo clippy --workspace --all-targets -- -D warnings
cargo test -p vnidrop
make format
make test-rust
```
For cancel, export, or output-sink changes, also run:
```bash
cargo test -p vnidrop --test output_sink
make test-rust-output-sink
```
For broader core changes, run the complete workspace suite:
```bash
cargo test --workspace --all-targets
make check-rust
```
### Shared Kotlin and Compose
```bash
./gradlew :shared:jvmTest
make test-shared
```
Platform-specific checks may also be appropriate:
```bash
./gradlew :shared:testAndroidHostTest
./gradlew :shared:iosSimulatorArm64Test
./gradlew :androidApp:assembleDebug
make test-android-host
make check-android
```
### Native Apple App
```bash
make check-apple
```
Override the selected simulator when needed with
`make check-apple APPLE_DESTINATION='platform=iOS Simulator,name=iPhone 16'`.
### Diagnostics Service
```bash
cd services/diagnostics-api
npm ci
npm run check
make check-diagnostics
```
If a required check cannot run on your machine, explain why in the pull request

714
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
[workspace]
members = ["crates/vnidrop"]
members = ["crates/vnidrop", "crates/uniffi-bindgen"]
resolver = "2"
[profile.dev]

561
DESIGN-DEVICE-HISTORY.md Normal file
View 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.

View 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.

View File

@@ -187,7 +187,8 @@
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Copyright 2026 VniDrop
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.

221
Makefile Normal file
View File

@@ -0,0 +1,221 @@
ROOT := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST)))))
SHELL := bash
.SHELLFLAGS := -eu -o pipefail -c
.DEFAULT_GOAL := help
include $(ROOT)/config.mk
-include $(ROOT)/config.override.mk
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 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
.PHONY: check-docs run-docs check-diagnostics run-diagnostics diagnostics-db-local diagnostics-db-remote diagnostics-typegen deploy-diagnostics
help: ## Show available commands and common configuration variables.
@grep -hE '^[A-Za-z0-9_.-]+:.*## ' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*## "} {printf " %-28s %s\n", $$1, $$2}'
@printf '\nCommon variables:\n'
@printf ' %-28s %s\n' 'version.properties' 'Canonical application version ($(VERSION))'
@printf ' %-28s %s\n' 'APPLE_PROFILE=debug|release' 'Rust profile for the Apple XCFramework'
@printf ' %-28s %s\n' 'APPLE_CONFIGURATION=...' 'Xcode configuration (default: $(APPLE_CONFIGURATION))'
@printf ' %-28s %s\n' 'APPLE_DESTINATION=...' 'Optional xcodebuild destination override'
@printf ' %-28s %s\n' 'APPLE_CODE_SIGNING=NO|YES' 'Enable Apple code signing (default: $(APPLE_CODE_SIGNING))'
doctor: ## Check that tools required by the current host are available.
@missing=0; \
for tool in "$(firstword $(CARGO))" java "$(firstword $(NPM))" "$(firstword $(BUN))"; do \
if command -v "$$tool" >/dev/null 2>&1; then \
printf 'ok %s\n' "$$tool"; \
else \
printf 'missing %s\n' "$$tool"; \
missing=1; \
fi; \
done; \
if [[ ! -f "$(GRADLE)" ]]; then printf 'missing %s\n' "$(GRADLE)"; missing=1; else printf 'ok %s\n' "$(GRADLE)"; fi; \
if [[ "$(HOST_OS)" == macos ]]; then \
for tool in "$(firstword $(XCODEBUILD))" "$(firstword $(XCODEGEN))"; do \
if command -v "$$tool" >/dev/null 2>&1; then printf 'ok %s\n' "$$tool"; else printf 'missing %s\n' "$$tool"; missing=1; fi; \
done; \
fi; \
exit $$missing
setup: setup-localization setup-docs setup-diagnostics ## Install repository-local JavaScript dependencies.
setup-localization: ## Install localization CLI dependencies with Bun.
cd $(ROOT)/localization && $(BUN) install --frozen-lockfile
setup-docs: ## Install documentation website dependencies.
cd $(ROOT)/docs && $(NPM) ci
setup-diagnostics: ## Install diagnostics Worker dependencies.
cd $(ROOT)/services/diagnostics-api && $(NPM) ci
format: ## Format Rust sources.
cd $(ROOT) && $(CARGO) fmt --all
test: test-rust test-shared ## Run the main Rust and shared JVM test suites.
check: check-version check-rust check-shared check-localization check-docs check-diagnostics ## Run portable pre-PR verification.
prepare-release: ## Update PRODUCT_VERSION and show its derived store versions (RELEASE_VERSION=x.y.z).
@test -n "$(RELEASE_VERSION)" || { printf 'Usage: make prepare-release RELEASE_VERSION=x.y.z\n' >&2; exit 1; }
cd $(ROOT) && packaging/version/prepare-release.sh "$(RELEASE_VERSION)"
check-version: ## Validate the canonical version and its platform mappings.
cd $(ROOT) && packaging/version/test-version.sh
cd $(ROOT) && packaging/version/resolve-version.sh verify
cd $(ROOT) && $(GRADLE) verifyVersion $(GRADLE_FLAGS)
check-release: ## Validate coordinated release scripts and workflow YAML.
cd $(ROOT) && bash -n apple/scripts/notarize.sh apple/scripts/sign-exported-app.sh apple/scripts/tests/test-notarize.sh apple/scripts/tests/test-sign-exported-app.sh apple/scripts/generate-appconfig.sh apple/scripts/tests/test-generate-appconfig.sh packaging/android/build-release.sh packaging/android/verify-apk-signature.sh packaging/android/tests/test_verify_apk_signature.sh packaging/release/assemble-release.sh packaging/release/test-assemble-release.sh packaging/release/test-release-config.sh
cd $(ROOT) && apple/scripts/tests/test-notarize.sh
cd $(ROOT) && apple/scripts/tests/test-generate-appconfig.sh
cd $(ROOT) && apple/scripts/tests/test-sign-exported-app.sh
cd $(ROOT) && packaging/android/tests/test_verify_apk_signature.sh
cd $(ROOT) && packaging/release/test-assemble-release.sh
cd $(ROOT) && packaging/release/test-release-config.sh
cd $(ROOT) && python3 -m unittest discover -s packaging/android/tests -v
cd $(ROOT) && ruby -e 'require "yaml"; ARGV.each { |file| YAML.load_file(file) }' .github/workflows/*.yml
check-rust: ## Run Rust formatting, lint, tests, and documentation checks.
cd $(ROOT) && $(CARGO) fmt --all -- --check
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 --features integration-test-store
test-rust-all: ## Run every Rust workspace test target.
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 --features integration-test-store --test transfer
test-rust-approval: ## Run Rust approval integration tests.
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 --features integration-test-store --test lifecycle
test-rust-output-sink: ## Run Rust output-sink integration tests.
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)
test-shared: ## Run shared JVM tests.
cd $(ROOT) && $(GRADLE) :shared:jvmTest $(GRADLE_FLAGS)
test-android-host: ## Run Android host-side shared tests.
cd $(ROOT) && $(GRADLE) :shared:testAndroidHostTest $(GRADLE_FLAGS)
check-android: ## Build Android debug and verify packaged Rust libraries.
cd $(ROOT) && $(GRADLE) :androidApp:assembleDebug :androidApp:verifyDebugVnidropLibraries $(GRADLE_FLAGS)
verify-android-libs: ## Verify the Rust libraries packaged in the Android debug app.
cd $(ROOT) && $(GRADLE) :androidApp:verifyDebugVnidropLibraries $(GRADLE_FLAGS)
build-android: ## Build the Android debug APK.
cd $(ROOT) && $(GRADLE) :androidApp:assembleDebug $(GRADLE_FLAGS)
run-desktop: ## Run the Windows/Linux Compose desktop app.
cd $(ROOT) && $(GRADLE) :desktopApp:run $(GRADLE_FLAGS)
apple-core: ## Build the Rust XCFramework and generated Swift bindings.
@test "$(HOST_OS)" = macos || { printf 'Apple builds require macOS.\n' >&2; exit 1; }
cd $(ROOT) && apple/scripts/build-core.sh $(APPLE_PROFILE)
apple-version-config: ## Generate derived Store and Direct Apple build settings.
cd $(ROOT) && packaging/version/generate-apple-xcconfig.sh all
apple-app-config: ## Generate AppConfig.swift from the shared app.properties.
cd $(ROOT) && apple/scripts/generate-appconfig.sh
apple-project: apple-core localization apple-version-config apple-app-config ## Generate the native Apple Xcode project.
cd $(ROOT)/apple && $(XCODEGEN) generate
open-apple-project: apple-project ## Generate and open the native Apple Xcode project.
cd $(ROOT)/apple && $(OPEN) VniDrop.xcodeproj
build-apple-macos: apple-project ## Build the native macOS app (unsigned by default).
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
build-apple-macos-direct: apple-project ## Build the direct-download macOS target (Sparkle, unsigned) — CI compile check.
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDropDirect -configuration Release-Direct -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO build
build-apple-dmg: localization ## Build the signed/notarized direct-download .dmg (see apple/RELEASE-MACOS.md for required env).
cd $(ROOT) && apple/scripts/build-dmg.sh
package-apple-core: ## Zip the prebuilt core (xcframework + bindings) + checksum into apple/dist (build the core first).
cd $(ROOT) && apple/scripts/package-core.sh
open-apple: build-apple-macos ## Build and launch the native macOS app.
@test -d "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app" || { printf 'Built macOS app was not found.\n' >&2; exit 1; }
$(OPEN) "$(APPLE_DERIVED_DATA)/Build/Products/$(APPLE_CONFIGURATION)/VniDrop.app"
build-apple-ios: apple-project ## Build the native iOS simulator app (unsigned by default).
@destination="$(APPLE_DESTINATION)"; \
if [[ -z "$$destination" ]]; then \
device_id="$$(xcrun simctl list devices available | sed -nE '/iPhone/ s/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -1 || true)"; \
[[ -n "$$device_id" ]] || { printf 'No available iPhone simulator found. Set APPLE_DESTINATION explicitly.\n' >&2; exit 1; }; \
destination="platform=iOS Simulator,id=$$device_id"; \
fi; \
cd $(ROOT)/apple && $(XCODEBUILD) -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination "$$destination" CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING) build
check-apple: apple-project ## Build the Apple core and run iOS simulator tests.
@destination="$(APPLE_DESTINATION)"; \
if [[ -z "$$destination" ]]; then \
device_id="$$(xcrun simctl list devices available | sed -nE '/iPhone/ s/.*\(([0-9A-F-]{36})\) \((Booted|Shutdown)\).*/\1/p' | head -1 || true)"; \
[[ -n "$$device_id" ]] || { printf 'No available iPhone simulator found. Set APPLE_DESTINATION explicitly.\n' >&2; exit 1; }; \
destination="platform=iOS Simulator,id=$$device_id"; \
fi; \
printf 'Testing on: %s\n' "$$destination"; \
cd $(ROOT)/apple && $(XCODEBUILD) test -project VniDrop.xcodeproj -scheme VniDrop -configuration $(APPLE_CONFIGURATION) -derivedDataPath "$(APPLE_DERIVED_DATA)" -destination "$$destination" CODE_SIGNING_ALLOWED=$(APPLE_CODE_SIGNING) CODE_SIGNING_REQUIRED=$(APPLE_CODE_SIGNING)
check-localization: setup-localization ## Validate the localization source catalog.
cd $(ROOT)/localization && $(BUN) run validate
localization: setup-localization ## Regenerate Apple and KMP localization resources.
cd $(ROOT)/localization && $(BUN) run generate
localization-migrate: setup-localization ## Rebuild strings.json from platform resources.
cd $(ROOT)/localization && $(BUN) run migrate
check-docs: setup-docs ## Lint, type-check, and build the documentation website.
cd $(ROOT)/docs && $(NPM) run lint
cd $(ROOT)/docs && $(NPM) run typecheck
cd $(ROOT)/docs && $(NPM) run build
run-docs: setup-docs ## Run the documentation development server.
cd $(ROOT)/docs && $(NPM) run dev
check-diagnostics: setup-diagnostics ## Run diagnostics types, tests, and deployment dry-run.
cd $(ROOT)/services/diagnostics-api && $(NPM) run check
run-diagnostics: setup-diagnostics ## Run the diagnostics Worker locally.
cd $(ROOT)/services/diagnostics-api && $(NPM) run dev
diagnostics-db-local: setup-diagnostics ## Apply diagnostics database migrations locally.
cd $(ROOT)/services/diagnostics-api && $(NPM) run db:migrate:local
diagnostics-db-remote: setup-diagnostics ## Apply diagnostics database migrations to the configured remote D1 database.
cd $(ROOT)/services/diagnostics-api && $(NPM) run db:migrate:remote
diagnostics-typegen: setup-diagnostics ## Regenerate diagnostics Worker binding types.
cd $(ROOT)/services/diagnostics-api && $(NPM) run typegen
deploy-diagnostics: setup-diagnostics ## Check and deploy the diagnostics Worker to Cloudflare.
cd $(ROOT)/services/diagnostics-api && $(NPM) run deploy

View File

@@ -50,6 +50,42 @@ mobile networks. If a direct path cannot be established, it can forward the
same end-to-end encrypted connection through a relay. The relay forwards
encrypted packets; it is not a VniDrop file store.
### Custom relay servers
VniDrop uses Iroh's public relay and discovery infrastructure by default. In
**Settings → Network**, users can select one of four policies:
- **Automatic (recommended):** use Iroh's public relays, with direct P2P/LAN
connections whenever possible.
- **Strict custom:** use only up to eight configured custom HTTPS relays or
direct connections. Startup reports an error if none of the custom relays can
be established.
- **Custom with direct fallback:** prefer the configured custom relays, but
continue with direct connections if they are unavailable.
- **Local only:** disable every relay and allow direct connections only,
primarily for devices on the same network.
Strict custom, custom with direct fallback, and local only never use public
relays or public discovery, including relay addresses advertised by incoming
invitations.
Applying a relay change restarts VniDrop's network engine, so active transfers
and shares must be stopped first. The app tests the new configuration and
restores the previous one if it cannot connect. Invitations created for an old
relay configuration may need to be shared again; stopped shares never expose
their stale invitations. If a long relay profile makes an invitation too large
for a QR code, use the native share action or export the invitation file.
Relay credentials embedded in URLs are deliberately rejected and bearer-token
authentication is not currently supported. A self-hosted relay must either
accept the connecting endpoints or authorize their endpoint IDs independently;
the current device ID is shown in **Settings → Network** for this purpose.
Configure the same relay profile on participating devices. A custom relay needs
a TLS certificate issued by a publicly trusted WebPKI certificate authority;
private or enterprise CAs installed only in the operating system are not used
in this version. For resilient deployments, configure at least two relays in
different failure domains.
## Why Iroh and `iroh-blobs`?
VniDrop combines a networking layer with its own sharing rules:
@@ -88,19 +124,21 @@ people, especially when using **Anyone with this transfer**.
- Per-receiver requests, approvals, progress, and delivery status
- Cancel, stop sharing, and local transfer history
- Safe receive destinations that do not silently overwrite existing files
- Android, iOS, and desktop apps built from a shared Compose Multiplatform UI
- Opt-in diagnostics with transfer contents, invitations, and file paths
excluded
- Native SwiftUI apps on iOS, iPadOS, and macOS; Compose apps on Android,
Windows, and Linux
- Strict custom HTTPS relay profiles with safe apply and rollback
- Optional user-submitted bug reports with transfer contents, invitations, and
file paths excluded
## Privacy by design
- **No hosted transfer copy.** VniDrop does not upload file contents to its
diagnostics service or a VniDrop storage bucket.
- **No hosted transfer copy.** VniDrop does not upload file contents to a bug-report
service or a VniDrop storage bucket.
- **Encrypted in transit.** Iroh connections are authenticated and encrypted
end to end, including when a relay is needed.
- **Local control.** Transfer history and sharing state stay on the device.
- **Sensitive invitations.** An invitation can grant access, so it is
deliberately excluded from product logs and diagnostics.
deliberately excluded from product logs and bug reports.
- **Explicit access.** Approval is required by default, and stopping a share
removes access immediately.
@@ -117,14 +155,21 @@ if you want to try the current version.
git clone https://github.com/vnidrop/vnidrop.git
cd vnidrop
# Desktop
./gradlew :desktopApp:run
# List the supported development commands and check prerequisites
make help
make doctor
# Windows/Linux desktop
make run-desktop
# Android debug build
./gradlew :androidApp:assembleDebug
make build-android
# iOS
open iosApp/iosApp.xcodeproj
# Build and launch the macOS app
make open-apple
# Open the native project for iOS, iPadOS, or Xcode development
make open-apple-project
```
See [`CONTRIBUTING.md`](CONTRIBUTING.md) for prerequisites, development setup,

View File

@@ -24,7 +24,7 @@ abstract class VerifyVnidropLibrariesTask : DefaultTask() {
archive.getEntry(path)?.size?.takeIf { it > 0L } == null
}
check(missing.isEmpty()) {
"Debug APK has missing or empty VniDrop libraries: ${missing.joinToString()}"
"APK has missing or empty VniDrop libraries: ${missing.joinToString()}"
}
}
}
@@ -37,6 +37,22 @@ plugins {
alias(libs.plugins.composeCompiler)
}
val appVersion = rootProject.extra["vnidrop.productVersion"] as String
val androidVersionCode = rootProject.extra["vnidrop.androidVersionCode"] as Int
val releaseKeystorePath = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PATH").orNull
val releaseKeystorePassword = providers.environmentVariable("VNIDROP_ANDROID_KEYSTORE_PASSWORD").orNull
val releaseKeyAlias = providers.environmentVariable("VNIDROP_ANDROID_KEY_ALIAS").orNull
val releaseKeyPassword = providers.environmentVariable("VNIDROP_ANDROID_KEY_PASSWORD").orNull
val releaseSigningValues = listOf(
releaseKeystorePath,
releaseKeystorePassword,
releaseKeyAlias,
releaseKeyPassword,
)
require(releaseSigningValues.all { it == null } || releaseSigningValues.all { it != null }) {
"Android release signing requires the keystore path, keystore password, key alias, and key password together"
}
kotlin {
compilerOptions {
jvmTarget = JvmTarget.JVM_11
@@ -55,12 +71,26 @@ android {
namespace = "com.vnidrop.app"
compileSdk = libs.versions.android.compileSdk.get().toInt()
signingConfigs {
if (releaseKeystorePath != null) {
create("release") {
val keystoreFile = rootProject.file(releaseKeystorePath)
.also { require(it.isFile) { "Android release keystore was not found" } }
.also { require(it.canRead()) { "Android release keystore is not readable" } }
storeFile = keystoreFile
storePassword = releaseKeystorePassword
keyAlias = releaseKeyAlias
keyPassword = releaseKeyPassword
}
}
}
defaultConfig {
applicationId = "com.vnidrop.app"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
versionCode = androidVersionCode
versionName = appVersion
}
packaging {
resources {
@@ -76,6 +106,7 @@ android {
buildTypes {
getByName("release") {
isMinifyEnabled = false
signingConfig = signingConfigs.findByName("release")
}
}
compileOptions {
@@ -87,6 +118,10 @@ android {
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/debug"))
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/debug"))
}
getByName("release") {
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/aarch64-linux-android/release"))
jniLibs.srcDir(project(":shared").layout.buildDirectory.dir("intermediates/rust/x86_64-linux-android/release"))
}
}
}
@@ -97,6 +132,12 @@ tasks.configureEach {
":shared:copyAndroidAndroidX64Debug",
)
}
if (name == "mergeReleaseJniLibFolders" || name == "mergeReleaseNativeLibs") {
dependsOn(
":shared:copyAndroidAndroidArm64Release",
":shared:copyAndroidAndroidX64Release",
)
}
}
val verifyDebugVnidropLibraries = tasks.register<VerifyVnidropLibrariesTask>("verifyDebugVnidropLibraries") {

View File

@@ -1,5 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
@@ -38,7 +40,7 @@
<data android:mimeType="application/vnd.vnidrop.transfer"/>
</intent-filter>
<!-- Fallback: .vnd files often arrive as octet-stream / unknown MIME. -->
<intent-filter>
<intent-filter tools:ignore="AppLinkUrlError">
<action android:name="android.intent.action.VIEW"/>
<category android:name="android.intent.category.DEFAULT"/>
<category android:name="android.intent.category.BROWSABLE"/>

View File

@@ -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))
}

4
app.properties Normal file
View File

@@ -0,0 +1,4 @@
# Public, app-wide configuration shared by every platform (Apple + KMP).
# Plain KEY=VALUE so it is parsed identically by shell, Gradle, and codegen.
# Injected into the apps at build time — never hardcode these values in app code.
PRIVACY_POLICY_URL=https://vnidrop.sudosy.fr/privacy/

24
apple/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Generated by apple/scripts/build-core.sh
.build-core/
VnidropCore/vnidrop.xcframework/
VnidropCore/Sources/VnidropCore/Vnidrop.swift
# Generated from localization/strings.json (cd localization && bun run src/cli.ts generate)
VniDrop/Resources/Localizable.xcstrings
VniDrop/Generated/
# Generated by XcodeGen from project.yml
VniDrop.xcodeproj/
# Per-developer signing (team id); Signing.xcconfig optionally includes it
Local.xcconfig
# SwiftPM / Xcode
.build/
.swiftpm/
DerivedData/
*.xcuserstate
# Direct-download (.dmg) build outputs — apple/scripts/build-dmg.sh
.build-dmg/
dist/

49
apple/.swiftlint.yml Normal file
View File

@@ -0,0 +1,49 @@
# Focused lint for the native app: enforce the typed-resources convention only
# (no default style rules, so this stays signal, not noise).
only_rules:
- custom_rules
included:
- VniDrop
excluded:
- VniDrop/Generated
custom_rules:
raw_localized_string:
name: "Raw localized key"
regex: 'String\(localized:\s*"'
message: "Use a typed L10n.* accessor, not a raw key string."
severity: warning
raw_localized_string_key:
name: "Raw LocalizedStringKey"
regex: 'LocalizedStringKey\("'
message: "Use a typed L10n.* accessor instead of a raw key."
severity: warning
raw_sf_symbol:
name: "Raw SF Symbol"
regex: 'system(Name|Image):\s*"'
message: "Use SFSafeSymbols: Image(systemSymbol:) or systemSymbol:."
severity: warning
raw_swiftui_string_literal:
name: "Raw SwiftUI string"
# A non-empty string literal as the leading arg of a view initializer is an
# implicit LocalizedStringKey. Empty labels (e.g. Picker("", …)) are allowed.
regex: '\b(Text|Label|Button|Toggle|Link|NavigationLink|Section|Picker|Stepper|TextField|SecureField|DisclosureGroup|Menu|GroupBox)\("[^"]'
message: "Pass a typed L10n.* accessor (or Text(verbatim:)), not a raw string literal."
severity: warning
raw_alert_message:
name: "Raw NFC/alert message"
# User-facing UIKit/CoreNFC prompts (e.g. NFCReaderSession.alertMessage) must
# be localized, not hardcoded English.
regex: '\balertMessage\s*=\s*"'
message: "Assign a localized value (String(localized: L10n.*)), not a raw string literal."
severity: warning
raw_invitation_error:
name: "Raw InvitationError literal"
# InvitationError.raw is the escape hatch for genuinely dynamic system/core
# messages; a string literal here is a loose user-facing string that belongs
# in a typed InvitationError case mapped to L10n in UserFacingError.swift.
regex: 'InvitationError\.raw\("'
message: "Add a typed InvitationError case + L10n mapping instead of a literal .raw(\"…\")."
severity: warning

121
apple/README.md Normal file
View File

@@ -0,0 +1,121 @@
# VniDrop — native Apple app (iOS / iPadOS / macOS)
A native SwiftUI app for Apple platforms, sharing the existing Rust transfer core
(`crates/vnidrop`) through UniFFI-generated Swift bindings. The Rust crate is not
modified; the Kotlin/Compose app layer is ported to Swift and mirrors the Compose
UI screen-for-screen. Android, Windows, and Linux continue to use `shared/` + Compose.
## Layout
```
apple/
scripts/build-core.sh # builds the Rust core + generates Swift bindings + xcframework
VnidropCore/ # local SwiftPM package: xcframework + generated Vnidrop.swift
VniDrop/ # SwiftUI app sources
App/ # entry point, object graph, root view, environment
Core/ # repository, models, preferences, notifications, progress
Features/Send|Receive|Approvals|Settings/
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 bundle (VniDropTests target)
project.yml # XcodeGen spec for the iOS/macOS app and test targets
```
## Build & run
Prerequisites: Xcode, Rust with the Apple targets
(`aarch64-apple-ios`, `aarch64-apple-ios-sim`, `x86_64-apple-ios`,
`aarch64-apple-darwin`), and `xcodegen` (`brew install xcodegen`).
```bash
# From the repository root:
make apple-core # Rust core, Swift bindings, and XCFramework
make apple-project # generate apple/VniDrop.xcodeproj
make open-apple-project # generate and open the project in Xcode
make build-apple-macos # unsigned macOS build (App Store target)
make open-apple # build and launch the macOS app
make build-apple-ios # unsigned iOS simulator app
make check-apple # iOS simulator tests
```
`make apple-project` also generates ignored Store and Direct version xcconfig
files. Their `CURRENT_PROJECT_VERSION` values come from the central version
resolver as UTC `YYYYMMDD.HHMM.SS` build identifiers. Regenerate the project
before creating another App Store archive so it receives a fresh build number;
direct DMG builds refresh their own value automatically.
### macOS shipping channels
The macOS app ships through two targets that build identical sources:
- **`VniDrop`** (`Release`) — Mac App Store / TestFlight. Sandboxed, no
self-updater.
- **`VniDropDirect`** (`Release-Direct`) — direct-download `.dmg` on GitHub
Releases + Homebrew cask. Adds the **Sparkle** auto-updater behind the
`DIRECT_DISTRIBUTION` compile flag, so the App Store binary never links Sparkle.
```bash
make build-apple-macos-direct # unsigned compile-check of the direct target
make build-apple-dmg # signed (+ notarized) .dmg
```
Full signing, notarization, appcast, and cask flow: see
[`RELEASE-MACOS.md`](RELEASE-MACOS.md).
Use `APPLE_PROFILE=release` to request a release Rust core, or set
`APPLE_DESTINATION` to override the automatically selected iOS simulator.
Code signing is disabled for the app and test targets; local and CI builds do
not require an Apple Development team or provisioning profile. Make builds can
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.
## Typecheck & tests
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
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`):
`VnidropCore/vnidrop.xcframework/`, `VnidropCore/Sources/VnidropCore/Vnidrop.swift`,
and `.build-core/`. A clean checkout must run `build-core.sh` before generating or
opening the Xcode project. `VniDrop.xcodeproj` itself is generated by XcodeGen from
`project.yml` and does not need to be committed.
## Build profile note
The default is `debug`. The workspace `[profile.release]` uses thin LTO, which the
current macOS toolchain miscompiles into corrupt host proc-macro dylibs
("mis-aligned LINKEDIT string pool"). `build-core.sh` sets
`CARGO_PROFILE_DEV_STRIP=none` (matching the existing Gobley Xcode run-script) so
debug builds succeed. For a release core, disable LTO for proc-macros/build
scripts (e.g. add a `[profile.release.build-override] lto = false` locally) — the
Rust crate itself is never changed.
## System frameworks
The Rust core (iroh network stack) links `SystemConfiguration`, `Security`, and
`libresolv`. These are declared in `project.yml` for the app target.
## Parity & scope
Screens mirror the Compose UI in `shared/`. Two deliberate simplifications:
- Empty-state Lottie animations are rendered as SF Symbols (no `lottie-ios`
dependency); swap in `lottie-ios` if exact-parity animation is required.
- Bug reporting is stubbed behind `BugReportService` (`NoopBugReportService`) and
a real transport lands in a later phase. There is no telemetry or crash
auto-reporting.
```

6
apple/Signing.xcconfig Normal file
View File

@@ -0,0 +1,6 @@
// VniDrop development and CI builds are intentionally unsigned.
CODE_SIGNING_ALLOWED = NO
CODE_SIGNING_REQUIRED = NO
// Signed local builds can opt in through this ignored file.
#include? "Local.xcconfig"

View File

@@ -0,0 +1,39 @@
import XCTest
@testable import VniDrop
/// Verifies the build-time `AppConfig` (generated from the shared `app.properties`)
/// exposes the expected, well-formed values to the app.
final class AppConfigTests: XCTestCase {
func testPrivacyPolicyURLIsTheExpectedHTTPSEndpoint() {
let url = AppConfig.privacyPolicyURL
XCTAssertEqual(url.scheme, "https", "Privacy policy URL must be https")
XCTAssertEqual(url.absoluteString, "https://vnidrop.sudosy.fr/privacy/")
}
func testPrivacyPolicyURLMatchesTheSharedConfigFile() throws {
// Cross-check the generated constant against the single source of truth so a
// broken generator (or drift) is caught, not just a hardcoded copy.
let expected = try Self.privacyURLFromAppProperties()
XCTAssertEqual(AppConfig.privacyPolicyURL.absoluteString, expected)
}
/// Reads `PRIVACY_POLICY_URL` from the repo's `app.properties` by walking up
/// from this source file's location to the repository root.
private static func privacyURLFromAppProperties() throws -> String {
var dir = URL(fileURLWithPath: #filePath).deletingLastPathComponent()
for _ in 0..<8 {
let candidate = dir.appendingPathComponent("app.properties")
if FileManager.default.fileExists(atPath: candidate.path) {
let contents = try String(contentsOf: candidate, encoding: .utf8)
for line in contents.split(whereSeparator: \.isNewline) {
if line.hasPrefix("PRIVACY_POLICY_URL=") {
return String(line.dropFirst("PRIVACY_POLICY_URL=".count))
}
}
throw XCTSkip("PRIVACY_POLICY_URL missing in \(candidate.path)")
}
dir.deleteLastPathComponent()
}
throw XCTSkip("app.properties not found from \(#filePath)")
}
}

View File

@@ -0,0 +1,53 @@
import XCTest
@testable import VniDrop
/// Ports app-level assertions: core initialization on launch, destination
/// selection guard, and theme following preferences.
@MainActor
final class AppModelTests: XCTestCase {
private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> AppModel {
AppModel(
environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()),
repository: core,
preferences: preferences,
messages: UiMessageController()
)
}
func testInitializesCoreOnLaunch() async {
let core = FakeCoreGateway()
_ = makeModel(core, preferences: Fixtures.preferences())
await waitUntil { core.state.isInitialized }
XCTAssertTrue(core.state.isInitialized)
XCTAssertEqual(core.initializedNetworkConfigurations, [.automatic])
}
func testInitializesCoreWithSavedCustomRelayConfiguration() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let configuration = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
preferences.setRelayConfiguration(configuration)
_ = makeModel(core, preferences: preferences)
await waitUntil { core.state.isInitialized }
XCTAssertEqual(core.initializedNetworkConfigurations, [configuration])
}
func testSelectDestination() {
let model = makeModel(FakeCoreGateway(), preferences: Fixtures.preferences())
XCTAssertEqual(model.destination, .send)
model.selectDestination(.settings)
XCTAssertEqual(model.destination, .settings)
model.selectDestination(.settings) // no-op guard
XCTAssertEqual(model.destination, .settings)
}
func testThemeModeFollowsPreferences() async {
let prefs = Fixtures.preferences()
let model = makeModel(FakeCoreGateway(), preferences: prefs)
prefs.setThemeMode(.dark)
await waitUntil { model.themeMode == .dark }
XCTAssertEqual(model.themeMode, .dark)
}
}

View File

@@ -0,0 +1,87 @@
import XCTest
@testable import VniDrop
/// Ports `preferences/AppPreferencesRepositoryTest.kt` values persist to the
/// backing store and reload identically.
@MainActor
final class AppPreferencesRepositoryTests: XCTestCase {
private func defaults() -> UserDefaults { UserDefaults(suiteName: "vnidrop.prefs.\(UUID().uuidString)")! }
private func fallback() -> AppPreferencesDefaults {
AppPreferencesDefaults(
username: "Default",
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp", displayName: "Downloads"),
themeMode: .system
)
}
func testMissingRelayProfileDefaultsToAutomatic() {
let repo = AppPreferencesRepository(defaults: defaults(), fallback: fallback())
XCTAssertEqual(repo.preferences.username, "Default")
XCTAssertEqual(repo.preferences.themeMode, .system)
XCTAssertEqual(repo.preferences.relayConfiguration, .automatic)
}
func testValuesPersistAndReload() {
let store = defaults()
let fb = fallback()
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
repo.setUsername("Bob")
repo.setThemeMode(.dark)
repo.setReceiveFolder(ReceiveFolder(kind: .iosSecurityScopedUrl, value: "file:///x", displayName: "Custom"))
repo.setRelayConfiguration(RelayConfiguration(
mode: .strictCustom,
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
))
// A fresh repository over the same store reflects the persisted values.
let reloaded = AppPreferencesRepository(defaults: store, fallback: fb)
XCTAssertEqual(reloaded.preferences.username, "Bob")
XCTAssertEqual(reloaded.preferences.themeMode, .dark)
XCTAssertEqual(reloaded.preferences.receiveFolder.displayName, "Custom")
XCTAssertEqual(reloaded.preferences.receiveFolder.kind, .iosSecurityScopedUrl)
XCTAssertEqual(reloaded.preferences.relayConfiguration, RelayConfiguration(
mode: .strictCustom,
relayURLs: ["https://relay-one.example", "https://relay-two.example:443"]
))
XCTAssertNotNil(store.data(forKey: "relay_configuration"))
XCTAssertNil(store.object(forKey: "relay_mode"))
XCTAssertNil(store.object(forKey: "relay_urls"))
}
func testCorruptedRelayProfileFailsClosed() {
let store = defaults()
store.set(Data("{".utf8), forKey: "relay_configuration")
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
XCTAssertEqual(
repo.preferences.relayConfiguration,
RelayConfiguration(mode: .strictCustom, relayURLs: [])
)
}
func testUnknownRelayModeFailsClosed() {
let store = defaults()
store.set(
Data(#"{"mode":"future-mode","relayURLs":["https://relay.example"]}"#.utf8),
forKey: "relay_configuration"
)
let repo = AppPreferencesRepository(defaults: store, fallback: fallback())
XCTAssertEqual(
repo.preferences.relayConfiguration,
RelayConfiguration(mode: .strictCustom, relayURLs: [])
)
}
func testResetReceiveFolderRestoresFallback() {
let store = defaults()
let fb = fallback()
let repo = AppPreferencesRepository(defaults: store, fallback: fb)
repo.setReceiveFolder(ReceiveFolder(kind: .fileSystemPath, value: "/custom", displayName: "Custom"))
repo.resetReceiveFolder()
XCTAssertEqual(repo.preferences.receiveFolder.value, "/tmp")
}
}

View File

@@ -0,0 +1,68 @@
import XCTest
import Combine
@testable import VniDrop
/// Ports `feature/approvals/ApprovalCoordinatorTest.kt` (the gateway-observable
/// parts; notification assertions require a notification-service seam we don't
/// have on Apple yet).
@MainActor
final class ApprovalCoordinatorTests: XCTestCase {
private func makeCoordinator(_ core: FakeCoreGateway) -> ApprovalCoordinator {
ApprovalCoordinator(
repository: core,
notifications: LocalNotificationService(),
visibility: AppVisibility(),
messages: UiMessageController()
)
}
func testOrdersPendingRequestsByRequestedAt() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "new", requestedAt: 20),
Fixtures.request(id: "old", requestedAt: 10)]
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { !coordinator.state.pending.isEmpty }
XCTAssertEqual(coordinator.state.pending.map(\.id), ["old", "new"])
XCTAssertEqual(coordinator.state.current?.id, "old")
}
func testFailedResponseKeepsRequestVisibleAndClearsResponding() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
core.responseResult = .failure(TestError.unimplemented)
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { coordinator.state.pending.contains { $0.id == "request" } }
coordinator.accept("request")
await waitUntil { coordinator.state.respondingIds.isEmpty && core.responses.count == 1 }
XCTAssertTrue(coordinator.state.pending.contains { $0.id == "request" })
XCTAssertTrue(coordinator.state.respondingIds.isEmpty)
XCTAssertEqual(core.responses.first?.accepted, true)
}
func testAcceptRespondsPositivelyAndSingleFlights() async {
let core = FakeCoreGateway()
core.requests[1] = [Fixtures.request(id: "request", requestedAt: 10)]
let coordinator = makeCoordinator(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 1, direction: .send, status: .sharing)]))
core.emit(.approvalChanged(transferId: 1))
await waitUntil { coordinator.state.current != nil }
coordinator.accept("request")
coordinator.accept("request") // second call must be ignored (single-flight)
await waitUntil { core.responses.count >= 1 }
try? await Task.sleep(nanoseconds: 50_000_000)
XCTAssertEqual(core.responses.count, 1)
XCTAssertEqual(core.responses.first?.id, "request")
}
}

View File

@@ -0,0 +1,50 @@
import XCTest
@testable import VniDrop
final class CoreDispatcherTests: XCTestCase {
/// Regression guard for the receive-cancel deadlock: an interrupt-lane call
/// must complete even while the serial lane is occupied by a blocking call.
/// With a single shared queue (the old design) the interrupt would be stuck
/// behind the blocked `receive`, and this would time out.
func testInterruptCompletesWhileSerialLaneIsBlocked() async {
let dispatcher = CoreDispatcher()
let serialEntered = DispatchSemaphore(value: 0)
let releaseSerial = DispatchSemaphore(value: 0)
// Occupy the serial lane with a call that blocks until we release it.
let serialTask = Task {
await dispatcher.run {
serialEntered.signal()
releaseSerial.wait()
}
}
XCTAssertEqual(serialEntered.wait(timeout: .now() + 2), .success, "serial lane never started")
// The interrupt lane must run despite the serial lane being blocked.
let interruptDone = DispatchSemaphore(value: 0)
Task.detached {
_ = await dispatcher.runInterrupt { 42 }
interruptDone.signal()
}
XCTAssertEqual(
interruptDone.wait(timeout: .now() + 2), .success,
"interrupt lane was blocked behind the occupied serial lane")
releaseSerial.signal()
_ = await serialTask.value
}
func testRunPropagatesValuesAndErrors() async {
let dispatcher = CoreDispatcher()
let value = await dispatcher.run { 7 }
XCTAssertEqual(try? value.get(), 7)
let failure = await dispatcher.run { () -> Int in throw TestError.unimplemented }
switch failure {
case .success: XCTFail("expected the thrown error to propagate")
case .failure(let error): XCTAssertTrue(error is TestError)
}
}
}

View File

@@ -0,0 +1,131 @@
import Foundation
import XCTest
@preconcurrency import VnidropCore
@testable import VniDrop
private enum BlockingCoreFactoryError: Error {
case stopped
}
private final class BlockingCoreBindingFactory: CoreBindingFactory, @unchecked Sendable {
private let release = DispatchSemaphore(value: 0)
private let lock = NSLock()
private var initializeCallCount = 0
private var initializationStarted = false
private var startWaiters: [CheckedContinuation<Void, Never>] = []
var callCount: Int {
lock.lock()
defer { lock.unlock() }
return initializeCallCount
}
func initialize(
appDataDir: String,
eventSink: CoreEventSink,
networkConfiguration: RelayConfiguration
) throws -> VnidropCore {
lock.lock()
initializeCallCount += 1
let call = initializeCallCount
initializationStarted = true
let waiters = startWaiters
startWaiters.removeAll()
lock.unlock()
waiters.forEach { $0.resume() }
if call == 1 {
release.wait()
}
throw BlockingCoreFactoryError.stopped
}
func waitUntilInitializationStarts() async {
await withCheckedContinuation { continuation in
lock.lock()
if initializationStarted {
lock.unlock()
continuation.resume()
} else {
startWaiters.append(continuation)
lock.unlock()
}
}
}
func unblockInitialization() {
release.signal()
}
}
@MainActor
final class CoreRepositoryLifecycleTests: XCTestCase {
func testIdleRequirementRejectsTransfersAndShares() throws {
XCTAssertNoThrow(try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 0))
XCTAssertThrowsError(
try CoreNetworkLifecycle.requireIdle(activeTransfers: 1, activeShares: 0)
) { error in
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
}
XCTAssertThrowsError(
try CoreNetworkLifecycle.requireIdle(activeTransfers: 0, activeShares: 1)
) { error in
XCTAssertEqual(error as? CoreNetworkLifecycleError, .activeNetworkWork)
}
}
func testRestartSerializesInitializationAndRejectsNewNetworkWork() async {
let factory = BlockingCoreBindingFactory()
let repository = CoreRepository(coreFactory: factory)
let firstInitialization = Task {
await repository.initialize(appDataDir: "/tmp/first", networkConfiguration: .automatic)
}
await factory.waitUntilInitializationStarts()
let safetyRelease = Task.detached {
try? await Task.sleep(nanoseconds: 1_000_000_000)
guard !Task.isCancelled else { return }
factory.unblockInitialization()
}
defer {
safetyRelease.cancel()
factory.unblockInitialization()
}
let concurrentInitialization = await repository.initialize(
appDataDir: "/tmp/second",
networkConfiguration: RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
)
assertLifecycleFailure(concurrentInitialization, equals: .transitionInProgress)
let share = await repository.shareSources(
[],
transferName: "Blocked",
senderName: "Tester",
accessPolicy: .requireApproval
)
assertLifecycleFailure(share, equals: .transitionInProgress)
let receive = await repository.receive(ticket: "ticket", outputDir: "/tmp", receiverName: "Tester")
assertLifecycleFailure(receive, equals: .transitionInProgress)
XCTAssertEqual(factory.callCount, 1)
factory.unblockInitialization()
guard case .failure(let error) = await firstInitialization.value else {
return XCTFail("The blocking factory should fail the first initialization")
}
XCTAssertTrue(error is BlockingCoreFactoryError)
}
private func assertLifecycleFailure<T>(
_ result: Result<T, Error>,
equals expected: CoreNetworkLifecycleError,
file: StaticString = #filePath,
line: UInt = #line
) {
guard case .failure(let error) = result else {
return XCTFail("Expected lifecycle failure \(expected)", file: file, line: line)
}
XCTAssertEqual(error as? CoreNetworkLifecycleError, expected, file: file, line: line)
}
}

158
apple/Tests/Fakes.swift Normal file
View File

@@ -0,0 +1,158 @@
import Foundation
import Combine
import VnidropCore
@testable import VniDrop
enum TestError: Error { case unimplemented }
/// In-memory `CoreGateway`, mirroring `support/Fakes.kt`'s `FakeCoreGateway`.
/// Lets model tests drive core state/signals and stub results without the FFI.
@MainActor
final class FakeCoreGateway: CoreGateway {
private let stateSubject = CurrentValueSubject<CoreState, Never>(CoreState())
private let signalsSubject = PassthroughSubject<CoreSignal, Never>()
var state: CoreState { stateSubject.value }
var statePublisher: AnyPublisher<CoreState, Never> { stateSubject.eraseToAnyPublisher() }
var signals: AnyPublisher<CoreSignal, Never> { signalsSubject.eraseToAnyPublisher() }
// Stubbed results
var requests: [UInt64: [ReceiverRequestModel]] = [:]
var responseResult: Result<Void, Error> = .success(())
var shareResult: Result<Share, Error> = .failure(TestError.unimplemented)
var inspectionResult: Result<TicketInspectionModel, Error> = .failure(TestError.unimplemented)
var receiveResult: Result<Void, Error> = .success(())
var cancelResult: Result<Void, Error> = .success(())
var deleteResult: Result<Void, Error> = .success(())
var clearReceiveHistoryResult: Result<UInt64, Error> = .success(0)
var initializeResult: Result<Void, Error> = .success(())
var initializeResults: [Result<Void, Error>] = []
// Recorded calls
private(set) var responses: [(id: String, accepted: Bool, reason: String?)] = []
private(set) var deletedTransfers: [UInt64] = []
private(set) var cancelledTransfers: [UInt64] = []
private(set) var clearReceiveHistoryCount = 0
private(set) var receiveCount = 0
private(set) var lastReceiveTicket: String?
private(set) var lastReceiveReceiverName: String?
private(set) var lastShareAccessPolicy: ShareAccessPolicy?
private(set) var initializedNetworkConfigurations: [RelayConfiguration] = []
func setState(_ state: CoreState) { stateSubject.send(state) }
func emit(_ signal: CoreSignal) { signalsSubject.send(signal) }
func initialize(
appDataDir: String,
networkConfiguration: RelayConfiguration
) async -> Result<Void, Error> {
initializedNetworkConfigurations.append(networkConfiguration)
let result = initializeResults.isEmpty ? initializeResult : initializeResults.removeFirst()
guard case .success = result else { return result }
var s = stateSubject.value
s.isInitialized = true
stateSubject.send(s)
return .success(())
}
func shutdown() {}
func shareSources(_ sources: [ShareSource], transferName: String, senderName: String, accessPolicy: ShareAccessPolicy) async -> Result<Share, Error> {
lastShareAccessPolicy = accessPolicy
return shareResult
}
func inspectTicket(_ ticket: String) async -> Result<TicketInspectionModel, Error> { inspectionResult }
func receive(ticket: String, outputDir: String, receiverName: String) async -> Result<Void, Error> {
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
return receiveResult
}
func receiveIntoSecurityScopedDirectory(ticket: String, outputDirectoryUrl: String, receiverName: String) async -> Result<Void, Error> {
receiveCount += 1; lastReceiveTicket = ticket; lastReceiveReceiverName = receiverName
return receiveResult
}
func cancel(transferId: UInt64) async -> Result<Void, Error> { cancelledTransfers.append(transferId); return cancelResult }
func delete(transferId: UInt64) async -> Result<Void, Error> { deletedTransfers.append(transferId); return deleteResult }
func clearReceiveHistory() async -> Result<UInt64, Error> { clearReceiveHistoryCount += 1; return clearReceiveHistoryResult }
func storageUsage() async -> Result<CoreStorageUsageModel, Error> {
.success(CoreStorageUsageModel(blobStoreBytes: 0, appDataBytes: 0))
}
func receivedArtifacts() async -> Result<[ReceivedArtifactModel], Error> { .success([]) }
func receiverRequests(transferId: UInt64) async -> Result<[ReceiverRequestModel], Error> { .success(requests[transferId] ?? []) }
func respondReceiverRequest(requestId: String, accepted: Bool, reason: String?) async -> Result<Void, Error> {
responses.append((requestId, accepted, reason))
return responseResult
}
func refresh() async -> Result<Void, Error> { .success(()) }
}
/// Minimal `FileSystemService` fake a writable path receive folder, no reveal.
@MainActor
final class FakeFileSystemService: FileSystemService {
var supportsCustomReceiveFolders = false
var folder = ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents")
func defaultReceiveFolder() -> ReceiveFolder { folder }
func validateReceiveFolder(_ folder: ReceiveFolder) async -> FolderAccessStatus { .writable }
func canRevealReceiveFolder(_ folder: ReceiveFolder) -> Bool { false }
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
)
}
}
@MainActor
final class FakeDeviceInfoProvider: DeviceInfoProvider {
func load() async -> DeviceInfo {
DeviceInfo(deviceName: "Test Device", deviceModel: "TestModel",
operatingSystem: "TestOS 1.0", network: nil, batteryLevel: nil)
}
}
// MARK: - Factories
@MainActor
enum Fixtures {
static func preferences(username: String = "Tester") -> AppPreferencesRepository {
let defaults = UserDefaults(suiteName: "vnidrop.tests.\(UUID().uuidString)")!
return AppPreferencesRepository(
defaults: defaults,
fallback: AppPreferencesDefaults(
username: username,
receiveFolder: ReceiveFolder(kind: .fileSystemPath, value: "/tmp/vnidrop-tests", displayName: "Documents"),
themeMode: .system
)
)
}
static func request(id: String, requestedAt: Int64, transferId: UInt64 = 1, status: ReceiverDeliveryStatus = .requested) -> ReceiverRequestModel {
ReceiverRequestModel(
id: id, transferId: transferId, remoteEndpointId: "endpoint-\(id)",
transferName: "Photos", receiverName: "Peer", receiverDeviceName: "Phone",
appVersion: "1.0", status: status, reason: nil,
requestedAt: requestedAt, respondedAt: nil, completedAt: nil
)
}
static func transfer(id: UInt64, direction: TransferDirection, status: TransferStatus) -> Transfer {
Transfer(
localId: "local-\(id)", transferId: id, direction: direction, status: status,
peerId: nil, transferName: "Photos", contentHash: nil, fileCount: 1, totalSize: 1024,
ticket: "ticket", accessPolicy: .requireApproval, createdAt: 0, updatedAt: 0
)
}
}
/// Polls `condition` on the main actor until true or `timeout` elapses. Used to
/// await the models' internal `Task`s, which XCTest can't join directly.
@MainActor
func waitUntil(timeout: TimeInterval = 2, _ condition: @escaping () -> Bool) async {
let deadline = Date().addingTimeInterval(timeout)
while !condition() && Date() < deadline {
try? await Task.sleep(nanoseconds: 5_000_000)
}
}

View File

@@ -0,0 +1,43 @@
import XCTest
@testable import VniDrop
/// Ports `feature/send/FilePreviewRepositoryTest.kt` persisted thumbnails,
/// restore pruned to live transfer ids, and removal.
@MainActor
final class FilePreviewRepositoryTests: XCTestCase {
/// Minimal bytes that pass the PNG magic-byte check.
private let png = Data([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A])
private func makeRepo() -> FilePreviewRepository {
FilePreviewRepository(appDataDir: NSTemporaryDirectory() + "previews-" + UUID().uuidString)
}
func testSaveStoresPreview() {
let repo = makeRepo()
repo.save(transferId: 1, bytes: png)
XCTAssertEqual(repo.previews[1], png)
}
func testSaveRejectsNonImageBytes() {
let repo = makeRepo()
repo.save(transferId: 1, bytes: Data("not an image".utf8))
XCTAssertNil(repo.previews[1])
}
func testRestorePrunesToActiveIds() {
let repo = makeRepo()
repo.save(transferId: 1, bytes: png)
repo.save(transferId: 2, bytes: png)
repo.restore(activeTransferIds: [1])
XCTAssertEqual(repo.previews[1], png)
XCTAssertNil(repo.previews[2])
}
func testRemoveDeletesPreview() {
let repo = makeRepo()
repo.save(transferId: 1, bytes: png)
repo.remove(transferId: 1)
XCTAssertNil(repo.previews[1])
}
}

View File

@@ -0,0 +1,43 @@
import XCTest
@testable import VniDrop
/// Ports `feature/receive/ExternalInvitationControllerTest.kt` + the `.vnd`
/// decode/filename helpers.
@MainActor
final class InvitationTests: XCTestCase {
func testValidateInvitationAcceptsValid() {
guard case .success(let raw) = validateInvitation("some-ticket") else { return XCTFail("expected success") }
XCTAssertEqual(raw, "some-ticket")
}
func testValidateRejectsEmpty() {
guard case .failure(let error) = validateInvitation(" \n ") else { return XCTFail("expected failure") }
XCTAssertTrue((error as? InvitationError) != nil)
}
func testValidateRejectsTooLarge() {
let big = String(repeating: "a", count: maxVniDropInvitationBytes + 1)
guard case .failure = validateInvitation(big) else { return XCTFail("expected failure") }
}
func testDecodeInvitationBytesRoundTrip() throws {
let text = "vnidrop://ticket-abc"
let decoded = try decodeInvitationBytes(Data(text.utf8))
XCTAssertEqual(decoded, text)
}
func testDecodeRejectsEmptyData() {
XCTAssertThrowsError(try decodeInvitationBytes(Data()))
}
func testDecodeRejectsInvalidUtf8() {
XCTAssertThrowsError(try decodeInvitationBytes(Data([0xFF, 0xFE, 0xFD])))
}
func testInvitationFileNameSanitizes() {
XCTAssertEqual(invitationFileName("My Photos"), "My-Photos.vnd")
XCTAssertEqual(invitationFileName(" "), "invitation.vnd")
XCTAssertTrue(invitationFileName("a/b:c*d").hasSuffix(".vnd"))
}
}

View File

@@ -0,0 +1,81 @@
import XCTest
@testable import VniDrop
/// Ports selected `shared/src/commonTest/.../ui/state` assertions to verify the
/// progress-derivation logic matches the Kotlin implementation.
final class ProgressDerivationTests: XCTestCase {
func testFormatBytes() {
XCTAssertEqual(formatBytes(0), "0 B")
XCTAssertEqual(formatBytes(1023), "1023 B")
XCTAssertEqual(formatBytes(1024), "1.0 KB")
XCTAssertEqual(formatBytes(1536), "1.5 KB")
XCTAssertEqual(formatBytes(1024 * 1024), "1.0 MB")
}
func testWindowClassThresholds() {
XCTAssertEqual(windowClassFor(width: 320), .phone)
XCTAssertEqual(windowClassFor(width: 599), .phone)
XCTAssertEqual(windowClassFor(width: 600), .tablet)
XCTAssertEqual(windowClassFor(width: 919), .tablet)
XCTAssertEqual(windowClassFor(width: 920), .desktop)
}
func testParseProgressPrefersExported() {
XCTAssertEqual(parseProgress("{\"exported\":50,\"file_size\":100}"), 0.5)
XCTAssertEqual(parseProgress("{\"downloaded\":25,\"total_size\":100}"), 0.25)
XCTAssertNil(parseProgress("{\"foo\":1}"))
XCTAssertEqual(parseProgress("{\"offset\":200,\"size\":100}"), 1.0) // clamped
}
func testFindStringSkipsNull() {
XCTAssertEqual(findString("{\"endpoint_id\":\"abc\"}", key: "endpoint_id"), "abc")
XCTAssertNil(findString("{\"endpoint_id\":null}", key: "endpoint_id"))
XCTAssertNil(findString("{\"endpoint_id\":123}", key: "endpoint_id"))
}
func testProgressForTransferUsesLatestNewestFirst() {
let events = [
event(phase: "import", kind: "copy-progress", json: "{\"exported\":30,\"file_size\":100}"),
event(phase: "import", kind: "started", json: "{}"),
]
let progress = progressForTransfer(events: events, transferId: 1)
XCTAssertEqual(progress?.labelKey, L10n.Progress.preparing)
XCTAssertEqual(progress?.progress, 0.3)
}
func testReceiverCompletionAfterProgressIsTerminal() {
let events = [
receiverEvent(kind: "completed", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\"}"),
receiverEvent(kind: "progress", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"end_offset\":100}"),
receiverEvent(kind: "started", json: "{\"connection_id\":1,\"request_id\":1,\"endpoint_id\":\"peer-a\",\"size\":100}"),
]
let progress = progressForReceiver(
events: events,
transferId: 1,
remoteEndpointId: "peer-a",
totalSizeHint: 100
)
XCTAssertEqual(progress?.kind, .completed)
XCTAssertEqual(progress?.labelKey, L10n.Progress.completed)
XCTAssertEqual(progress?.progress, 1)
}
func testStatusLabelKeys() {
XCTAssertEqual(statusLabelKey(.sharing), L10n.Status.available)
XCTAssertEqual(statusLabelKey(.receiving), L10n.Status.receiving)
XCTAssertEqual(statusLabelKey(.done), L10n.Status.completed)
}
private func event(phase: String, kind: String, json: String) -> CoreEventModel {
CoreEventModel(
id: UUID().uuidString, revision: 1, timestamp: 0, scope: "transfer", transferId: 1,
direction: "send", phase: phase, kind: kind, dataJson: json
)
}
private func receiverEvent(kind: String, json: String) -> CoreEventModel {
event(phase: "transfer", kind: kind, json: json)
}
}

View File

@@ -0,0 +1,70 @@
import XCTest
@testable import VniDrop
/// Ports the receive-side state-machine assertions from `feature/ViewModelsTest.kt`.
@MainActor
final class ReceiveModelTests: XCTestCase {
private func makeModel(_ core: FakeCoreGateway) -> ReceiveModel {
ReceiveModel(
repository: core,
fileSystemService: FakeFileSystemService(),
preferences: Fixtures.preferences(),
messages: UiMessageController()
)
}
func testDeleteHistoryItemDeletesTerminalReceiveTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)]))
await waitUntil { model.coreState.transfers.contains { $0.transferId == 5 } }
model.requestDeleteHistoryItem(5)
XCTAssertEqual(model.state.historyDeleteTarget, .transfer(transferId: 5))
model.confirmHistoryDelete()
// Must close immediately (not after the async delete) so the alert can't
// re-present on macOS.
XCTAssertNil(model.state.historyDeleteTarget)
await waitUntil { core.deletedTransfers.contains(5) }
XCTAssertEqual(core.deletedTransfers, [5])
XCTAssertNil(model.state.historyDeleteTarget)
}
func testClearHistoryCallsClearReceiveHistory() async {
let core = FakeCoreGateway()
let model = makeModel(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 5, direction: .receive, status: .done)]))
await waitUntil { !model.coreState.transfers.isEmpty }
model.requestClearHistory()
XCTAssertEqual(model.state.historyDeleteTarget, .all)
model.confirmHistoryDelete()
await waitUntil { core.clearReceiveHistoryCount == 1 }
XCTAssertEqual(core.clearReceiveHistoryCount, 1)
XCTAssertNil(model.state.historyDeleteTarget)
}
func testDeleteHistoryItemIgnoresNonTerminalTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 9, direction: .receive, status: .receiving)]))
await waitUntil { !model.coreState.transfers.isEmpty }
model.requestDeleteHistoryItem(9)
XCTAssertNil(model.state.historyDeleteTarget) // in-flight receive can't be deleted from history
}
func testCancelActiveReceiveCancelsTheReceivingTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core)
core.setState(CoreState(isInitialized: true, transfers: [Fixtures.transfer(id: 7, direction: .receive, status: .receiving)]))
await waitUntil { !model.coreState.transfers.isEmpty }
model.cancelActiveReceive()
await waitUntil { core.cancelledTransfers.contains(7) }
XCTAssertEqual(core.cancelledTransfers, [7])
}
}

View File

@@ -0,0 +1,121 @@
import XCTest
@testable import VniDrop
final class RelayConfigurationTests: XCTestCase {
func testCustomFallbackValidatesAndPreservesItsMode() throws {
let result = try RelayConfigurationValidator.validate(
mode: .customWithDirectFallback,
relayURLs: ["https://relay.example/"]
)
XCTAssertEqual(
result,
RelayConfiguration(
mode: .customWithDirectFallback,
relayURLs: ["https://relay.example"]
)
)
}
func testLocalOnlyRetainsPreviouslySavedRelayURLs() throws {
let retained = ["https://relay.example"]
let result = try RelayConfigurationValidator.validate(
mode: .localOnly,
relayURLs: ["not a URL"],
retainedRelayURLs: retained
)
XCTAssertEqual(result, RelayConfiguration(mode: .localOnly, relayURLs: retained))
}
func testAutomaticModeIgnoresRelayDrafts() throws {
let result = try RelayConfigurationValidator.validate(
mode: .automatic,
relayURLs: ["not a URL"]
)
XCTAssertEqual(result, .automatic)
}
func testAutomaticModeRetainsPreviouslySavedRelayURLs() throws {
let result = try RelayConfigurationValidator.validate(
mode: .automatic,
relayURLs: ["not a URL"],
retainedRelayURLs: ["https://relay.example"]
)
XCTAssertEqual(result, RelayConfiguration(
mode: .automatic,
relayURLs: ["https://relay.example"]
))
}
func testCustomModeTrimsValidHTTPSRelayURLs() throws {
let result = try RelayConfigurationValidator.validate(
mode: .strictCustom,
relayURLs: [" https://relay.example/ ", "https://backup.example:443"]
)
XCTAssertEqual(result, RelayConfiguration(
mode: .strictCustom,
relayURLs: ["https://relay.example", "https://backup.example"]
))
}
func testCustomModeIgnoresEmptyURLRows() throws {
let result = try RelayConfigurationValidator.validate(
mode: .strictCustom,
relayURLs: ["", " ", "https://relay.example"]
)
XCTAssertEqual(result.relayURLs, ["https://relay.example"])
}
func testCustomModeRequiresAtLeastOneRelay() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [])) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .missingURL)
}
}
func testCustomModeRequiresHTTPS() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
mode: .strictCustom,
relayURLs: ["http://relay.example"]
)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .httpsRequired(index: 0))
}
}
func testCustomModeRejectsCredentialsQueryFragmentAndPath() {
let invalidURLs = [
"https://user:password@relay.example",
"https://relay.example?token=secret",
"https://relay.example#fragment",
"https://relay.example/custom/path",
"https://relay.example:0",
"https://relay.example:99999",
]
for relayURL in invalidURLs {
XCTAssertThrowsError(
try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: [relayURL]),
"Expected \(relayURL) to be rejected"
) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .invalidURL(index: 0))
}
}
}
func testCustomModeRejectsNormalizedDuplicate() {
XCTAssertThrowsError(try RelayConfigurationValidator.validate(
mode: .strictCustom,
relayURLs: ["https://relay.example", "https://RELAY.example:443/"]
)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .duplicateURL(index: 1))
}
}
func testCustomModeRejectsMoreThanEightRelays() {
let relayURLs = (0...RelayConfigurationValidator.maximumRelayCount).map {
"https://relay-\($0).example"
}
XCTAssertThrowsError(try RelayConfigurationValidator.validate(mode: .strictCustom, relayURLs: relayURLs)) { error in
XCTAssertEqual(error as? RelayConfigurationValidationError, .tooManyURLs)
}
}
}

View 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"))
}
}

View File

@@ -0,0 +1,82 @@
import XCTest
@testable import VniDrop
/// Ports the send-side state-machine assertions from `feature/ViewModelsTest.kt`.
@MainActor
final class SendModelTests: XCTestCase {
private func makeModel(_ core: FakeCoreGateway) -> SendModel {
SendModel(
repository: core,
fileSystemService: FakeFileSystemService(),
preferences: Fixtures.preferences(),
filePreviewRepository: FilePreviewRepository(appDataDir: NSTemporaryDirectory() + UUID().uuidString),
messages: UiMessageController()
)
}
func testOpenAndCloseTransferDetails() {
let model = makeModel(FakeCoreGateway())
model.openTransfer(3)
XCTAssertEqual(model.state.selectedTransferId, 3)
model.closeTransferDetails()
XCTAssertNil(model.state.selectedTransferId)
}
func testDeleteTransferConfirmationFlow() async {
let core = FakeCoreGateway()
let model = makeModel(core)
model.openTransfer(3)
model.requestDeleteTransfer()
XCTAssertTrue(model.state.isDeleteConfirmationOpen)
model.confirmDeleteTransfer()
// Must close immediately (not after the async delete) so the alert can't
// re-present on macOS.
XCTAssertFalse(model.state.isDeleteConfirmationOpen)
await waitUntil { core.deletedTransfers.contains(3) }
XCTAssertEqual(core.deletedTransfers, [3])
XCTAssertNil(model.state.selectedTransferId)
XCTAssertFalse(model.state.isDeleteConfirmationOpen)
}
func testStopSharingCancelsTheTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core)
model.stopSharing(transferId: 4)
await waitUntil { core.cancelledTransfers.contains(4) }
XCTAssertEqual(core.cancelledTransfers, [4])
}
func testCancelReceiverRefusesTheRequest() async {
let core = FakeCoreGateway()
let model = makeModel(core)
model.openTransfer(1)
model.cancelReceiver(requestId: "req-1")
await waitUntil { core.responses.contains { $0.id == "req-1" } }
let response = core.responses.first { $0.id == "req-1" }
XCTAssertEqual(response?.accepted, false)
}
func testOnlyActiveShareExposesStoredInvitationTicket() {
XCTAssertEqual(
Fixtures.transfer(id: 1, direction: .send, status: .sharing).invitationPresentation,
.ready("ticket")
)
XCTAssertEqual(
Fixtures.transfer(id: 2, direction: .send, status: .importing).invitationPresentation,
.preparing
)
for status in [TransferStatus.stopped, .failed, .cancelled, .done] {
XCTAssertEqual(
Fixtures.transfer(id: 3, direction: .send, status: status).invitationPresentation,
.unavailable
)
}
}
func testOversizedInvitationReportsQRCodeUnavailable() {
XCTAssertNil(QRCode.generate(from: String(repeating: "x", count: 10_000)))
}
}

View File

@@ -0,0 +1,147 @@
import XCTest
@testable import VniDrop
/// Ports settings assertions from `feature/ViewModelsTest.kt` username debounce
/// persistence and the Storage "delete all transfers" flow.
@MainActor
final class SettingsModelTests: XCTestCase {
private func makeModel(_ core: FakeCoreGateway, preferences: AppPreferencesRepository) -> SettingsModel {
SettingsModel(
environment: PlatformEnvironment(name: "Test", appVersion: "0.1.0", defaultCoreDataDir: NSTemporaryDirectory()),
deviceInfoProvider: FakeDeviceInfoProvider(),
fileSystemService: FakeFileSystemService(),
repository: core,
preferences: preferences,
notifications: LocalNotificationService(),
messages: UiMessageController(),
bugReports: NoopBugReportService()
)
}
func testUsernameChangeDebouncesAndPersists() async {
let prefs = Fixtures.preferences(username: "Original")
let model = makeModel(FakeCoreGateway(), preferences: prefs)
model.setUsername("Alice")
XCTAssertEqual(model.state.username, "Alice") // immediate local echo
await waitUntil { prefs.preferences.username == "Alice" } // persisted after debounce
XCTAssertEqual(prefs.preferences.username, "Alice")
}
func testDeleteAllTransfersDeletesEveryTransfer() async {
let core = FakeCoreGateway()
let model = makeModel(core, preferences: Fixtures.preferences())
core.setState(CoreState(isInitialized: true, transfers: [
Fixtures.transfer(id: 2, direction: .send, status: .sharing),
Fixtures.transfer(id: 3, direction: .receive, status: .done),
]))
model.deleteAllTransfers()
await waitUntil { core.deletedTransfers.count == 2 }
XCTAssertEqual(Set(core.deletedTransfers), [2, 3])
}
func testNetworkSettingsExposeCurrentEndpointId() {
let core = FakeCoreGateway()
let model = makeModel(core, preferences: Fixtures.preferences())
core.setState(CoreState(
isInitialized: true,
status: CoreStatus(endpointId: "endpoint-for-allowlist", activeTransfers: 0, activeShares: 0)
))
XCTAssertEqual(model.state.endpointId, "endpoint-for-allowlist")
}
func testApplyCustomRelayRestartsCoreThenPersistsConfiguration() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
model.setRelayMode(.strictCustom)
model.setRelayURL(" https://relay.example/ ", at: 0)
model.applyRelayConfiguration()
await waitUntil { preferences.preferences.relayConfiguration.mode == .strictCustom }
let expected = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
XCTAssertEqual(preferences.preferences.relayConfiguration, expected)
XCTAssertEqual(core.initializedNetworkConfigurations, [expected])
XCTAssertFalse(model.state.relayConfigurationIsDirty)
}
func testApplyingAutomaticRetainsLastCustomRelayURLs() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let relayURLs = ["https://relay.example", "https://backup.example"]
preferences.setRelayConfiguration(RelayConfiguration(mode: .strictCustom, relayURLs: relayURLs))
let model = makeModel(core, preferences: preferences)
model.setRelayMode(.automatic)
model.applyRelayConfiguration()
await waitUntil { preferences.preferences.relayConfiguration.mode == .automatic }
XCTAssertEqual(preferences.preferences.relayConfiguration.relayURLs, relayURLs)
XCTAssertEqual(core.initializedNetworkConfigurations, [
RelayConfiguration(mode: .automatic, relayURLs: relayURLs),
])
model.setRelayMode(.strictCustom)
XCTAssertEqual(model.state.relayURLs, relayURLs)
}
func testApplyRelayIsBlockedWhileShareIsActive() async {
let core = FakeCoreGateway()
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
core.setState(CoreState(
isInitialized: true,
status: CoreStatus(endpointId: "endpoint", activeTransfers: 0, activeShares: 1)
))
model.setRelayMode(.strictCustom)
model.setRelayURL("https://relay.example", at: 0)
model.applyRelayConfiguration()
await Task.yield()
XCTAssertTrue(core.initializedNetworkConfigurations.isEmpty)
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
}
func testRepositoryActiveWorkRejectionDoesNotAttemptRollback() async {
let core = FakeCoreGateway()
core.initializeResult = .failure(CoreNetworkLifecycleError.activeNetworkWork)
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
model.setRelayMode(.strictCustom)
model.setRelayURL(attempted.relayURLs[0], at: 0)
model.applyRelayConfiguration()
await waitUntil {
core.initializedNetworkConfigurations.count == 1 && !model.state.isApplyingRelayConfiguration
}
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted])
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertTrue(model.state.hasActiveNetworkWork)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_active_transfers")
}
func testFailedRelayApplyRollsBackWithoutPersisting() async {
let core = FakeCoreGateway()
core.initializeResults = [.failure(TestError.unimplemented), .success(())]
let preferences = Fixtures.preferences()
let model = makeModel(core, preferences: preferences)
let attempted = RelayConfiguration(mode: .strictCustom, relayURLs: ["https://relay.example"])
model.setRelayMode(.strictCustom)
model.setRelayURL(attempted.relayURLs[0], at: 0)
model.applyRelayConfiguration()
await waitUntil { core.initializedNetworkConfigurations.count == 2 }
XCTAssertEqual(core.initializedNetworkConfigurations, [attempted, .automatic])
XCTAssertEqual(preferences.preferences.relayConfiguration, .automatic)
XCTAssertEqual(model.state.relayApplyErrorKey, "relay_apply_failed")
}
}

View File

@@ -0,0 +1,51 @@
import XCTest
@testable import VniDrop
@MainActor
final class TransferNotificationTests: XCTestCase {
func testTransferNotificationsFireForTerminalStatesOnly() {
let transfers = [
Fixtures.transfer(id: 1, direction: .send, status: .failed),
Fixtures.transfer(id: 2, direction: .receive, status: .done),
Fixtures.transfer(id: 3, direction: .receive, status: .failed),
Fixtures.transfer(id: 4, direction: .receive, status: .receiving), // in-flight, ignored
Fixtures.transfer(id: 5, direction: .send, status: .sharing), // active share, ignored
Fixtures.transfer(id: 6, direction: .send, status: .done), // send-done isn't notified
]
let planned = plannedTransferNotifications(transfers, published: [])
XCTAssertEqual(planned.map(\.kind), [.sendFailed, .receiveCompleted, .receiveFailed])
XCTAssertEqual(planned.map(\.id), ["send-failed-1", "receive-completed-2", "receive-failed-3"])
XCTAssertEqual(planned.first?.transferName, "Photos")
}
func testTransferNotificationsSkipAlreadyPublished() {
let transfers = [Fixtures.transfer(id: 2, direction: .receive, status: .done)]
XCTAssertTrue(plannedTransferNotifications(transfers, published: ["receive-completed-2"]).isEmpty)
}
func testReceiverNotificationsFireOnlyForCompletedReceivers() {
let requests = [
Fixtures.request(id: "a", requestedAt: 1, status: .completed),
Fixtures.request(id: "b", requestedAt: 2, status: .accepted),
Fixtures.request(id: "c", requestedAt: 3, status: .requested),
]
let planned = plannedReceiverNotifications(requests, published: [])
XCTAssertEqual(planned.map(\.id), ["receiver-completed-a"])
XCTAssertEqual(planned.first?.kind, .receiverCompleted)
XCTAssertEqual(planned.first?.receiver, "Peer")
XCTAssertEqual(planned.first?.transferName, "Photos")
}
func testReceiverNotificationsSkipAlreadyPublished() {
let requests = [Fixtures.request(id: "a", requestedAt: 1, status: .completed)]
XCTAssertTrue(plannedReceiverNotifications(requests, published: ["receiver-completed-a"]).isEmpty)
}
func testReceiverNotificationsFireForFailedReceivers() {
let requests = [Fixtures.request(id: "x", requestedAt: 1, status: .failed)]
let planned = plannedReceiverNotifications(requests, published: [])
XCTAssertEqual(planned.map(\.id), ["receiver-failed-x"])
XCTAssertEqual(planned.first?.kind, .receiverFailed)
}
}

View File

@@ -0,0 +1,68 @@
import XCTest
import VnidropCore
@testable import VniDrop
/// Ports `ui/feedback/UiMessageControllerTest.kt` and `UserFacingErrorTest.kt`.
@MainActor
final class UiMessageControllerTests: XCTestCase {
func testQueuesAndAdvances() {
let c = UiMessageController()
c.show(UiMessage(text: .dynamic("first")))
c.show(UiMessage(text: .dynamic("second")))
XCTAssertEqual(c.current?.text, .dynamic("first"))
c.advance()
XCTAssertEqual(c.current?.text, .dynamic("second"))
c.advance()
XCTAssertNil(c.current)
}
func testErrorSuppressesUserCancellation() {
let c = UiMessageController()
c.error(InvitationError.cancelled)
XCTAssertNil(c.current) // cancellations are swallowed
}
func testErrorShowsNonCancellation() {
let c = UiMessageController()
c.error(InvitationError.raw("The transfer was refused"))
XCTAssertEqual(c.current?.tone, .error)
}
}
@MainActor
final class UserFacingErrorTests: XCTestCase {
func testIsUserCancellation() {
XCTAssertTrue(InvitationError.cancelled.isUserCancellation)
XCTAssertTrue(InvitationError.raw("User canceled the picker").isUserCancellation)
XCTAssertFalse(InvitationError.raw("A database error occurred").isUserCancellation)
}
func testToUiTextMapsKnownReasons() {
// Typed cases map directly at the UI boundary.
XCTAssertEqual(InvitationError.shareEmpty.toUiText(), .resource(L10n.Error.shareEmpty))
XCTAssertEqual(InvitationError.cameraUnavailable.toUiText(), .resource(L10n.Error.camera))
XCTAssertEqual(InvitationError.nfcFailed.toUiText(), .resource(L10n.Error.nfc))
// Dynamic `.raw` payloads still fall through the substring hints.
XCTAssertEqual(InvitationError.raw("The transfer was refused").toUiText(), .resource(L10n.Error.permission))
XCTAssertEqual(InvitationError.raw("invalid ticket").toUiText(), .resource(L10n.Error.invalidTicket))
}
func testToUiTextFallsBackToGeneric() {
XCTAssertEqual(InvitationError.raw("something entirely unexpected").toUiText(), .resource(L10n.Error.generic))
}
func testToUiTextMapsTypedTransferFailures() {
XCTAssertEqual(VnidropError.FilesystemPermission(reason: "read-only folder").toUiText(), .resource(L10n.Error.filesystem))
XCTAssertEqual(VnidropError.DestinationExists(reason: "target exists").toUiText(), .resource(L10n.Error.destinationExists))
XCTAssertEqual(VnidropError.StorageFull(reason: "disk full").toUiText(), .resource(L10n.Error.storageFull))
XCTAssertEqual(VnidropError.Network(reason: "offline").toUiText(), .resource(L10n.Error.network))
XCTAssertEqual(VnidropError.InvalidInput(reason: "bad path").toUiText(), .resource(L10n.Error.invalidInput))
XCTAssertFalse(VnidropError.FilesystemPermission(reason: "read-only").canRetryWithoutChangingInput)
XCTAssertFalse(VnidropError.DestinationExists(reason: "target exists").canRetryWithoutChangingInput)
XCTAssertTrue(VnidropError.Network(reason: "offline").canRetryWithoutChangingInput)
}
}

View File

@@ -0,0 +1,32 @@
import Foundation
/// Platform environment, ported from `Platform.kt` (`PlatformEnvironment`).
struct PlatformEnvironment {
let name: String
let appVersion: String
let defaultCoreDataDir: String
var defaultUsername: String = "Receiver"
}
/// Device info for diagnostics/about, ported from `DeviceInfo`.
struct DeviceInfo {
let deviceName: String?
let deviceModel: String?
let operatingSystem: String
let network: String?
let batteryLevel: String?
}
@MainActor
protocol DeviceInfoProvider {
func load() async -> DeviceInfo
}
/// Bundle of platform dependencies, ported from `AppDependencies`.
struct AppDependencies {
let environment: PlatformEnvironment
let deviceInfoProvider: DeviceInfoProvider
let fileSystemService: FileSystemService
let notificationService: LocalNotificationService
let externalInvitations: ExternalInvitationController
}

View File

@@ -0,0 +1,49 @@
import Foundation
import Combine
/// Object graph wiring the repositories and coordinators together, ported from
/// `AppGraph.kt`. Owned by the app root for the process lifetime.
@MainActor
final class AppGraph: ObservableObject {
let dependencies: AppDependencies
let coreRepository: CoreRepository
let visibility = AppVisibility()
let messages = UiMessageController()
let preferencesRepository: AppPreferencesRepository
let filePreviewRepository: FilePreviewRepository
let approvalCoordinator: ApprovalCoordinator
let transferNotificationCoordinator: TransferNotificationCoordinator
let backgroundActivity: BackgroundActivityController
init(dependencies: AppDependencies, coreRepository: CoreRepository? = nil) {
self.dependencies = dependencies
let coreRepository = coreRepository ?? CoreRepository()
self.coreRepository = coreRepository
self.filePreviewRepository = FilePreviewRepository(appDataDir: dependencies.environment.defaultCoreDataDir)
self.preferencesRepository = AppPreferencesRepository(
fallback: AppPreferencesDefaults(
username: dependencies.environment.defaultUsername,
receiveFolder: dependencies.fileSystemService.defaultReceiveFolder(),
themeMode: .system
)
)
self.approvalCoordinator = ApprovalCoordinator(
repository: coreRepository,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages
)
self.transferNotificationCoordinator = TransferNotificationCoordinator(
repository: coreRepository,
notifications: dependencies.notificationService,
visibility: visibility,
messages: messages
)
self.backgroundActivity = BackgroundActivityController(repository: coreRepository)
AppLogger.info("lifecycle", "graph created", ["platform": dependencies.environment.name])
}
func close() {
coreRepository.shutdown()
}
}

View File

@@ -0,0 +1,293 @@
import SFSafeSymbols
import SwiftUI
/// App root, ported from `App.kt`. Owns the object graph and feature models, wires
/// the adaptive shell, floating actions, snackbar host, and approval modal.
struct RootView: View {
@StateObject private var graph: AppGraph
@StateObject private var appModel: AppModel
@StateObject private var sendModel: SendModel
@StateObject private var receiveModel: ReceiveModel
@StateObject private var settingsModel: SettingsModel
@Environment(\.scenePhase) private var scenePhase
init(dependencies: AppDependencies) {
let graph = AppGraph(dependencies: dependencies)
_graph = StateObject(wrappedValue: graph)
_appModel = StateObject(wrappedValue: AppModel(
environment: dependencies.environment,
repository: graph.coreRepository,
preferences: graph.preferencesRepository,
messages: graph.messages
))
_sendModel = StateObject(wrappedValue: SendModel(
repository: graph.coreRepository,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
filePreviewRepository: graph.filePreviewRepository,
messages: graph.messages
))
_receiveModel = StateObject(wrappedValue: ReceiveModel(
repository: graph.coreRepository,
fileSystemService: dependencies.fileSystemService,
preferences: graph.preferencesRepository,
messages: graph.messages
))
_settingsModel = StateObject(wrappedValue: SettingsModel(
environment: dependencies.environment,
deviceInfoProvider: dependencies.deviceInfoProvider,
fileSystemService: dependencies.fileSystemService,
repository: graph.coreRepository,
preferences: graph.preferencesRepository,
notifications: dependencies.notificationService,
messages: graph.messages,
bugReports: NoopBugReportService()
))
}
var body: some View {
GeometryReader { proxy in
let windowClass = windowClassFor(width: proxy.size.width)
let isDark = resolveDarkTheme(appModel.themeMode, systemDark: systemDark)
ZStack {
navigation(windowClass: windowClass)
// Observe the coordinator/messages from the *persisted* `graph`
// StateObject. Deriving them in `init` bound the view to a throwaway
// AppGraph rebuilt on every re-init, whose coordinator never receives
// core events so the approval modal never appeared.
ApprovalLayer(
approvals: graph.approvalCoordinator,
sendModel: sendModel
)
// Top-most so the toast is never covered by the approval overlay's
// full-bleed clear layer. Observes the live `graph.messages` directly.
SnackbarHost(controller: graph.messages)
}
.overlay {
// A small, unobtrusive indicator while the core finishes its async
// startup otherwise the lists look empty and the app feels stalled.
if !sendModel.coreState.isInitialized {
CoreStartingOverlay()
}
}
.animation(.easeInOut(duration: 0.25), value: sendModel.coreState.isInitialized)
.vniDropTheme(isDark: isDark)
.preferredColorScheme(appModel.themeMode.preferredColorScheme)
.environment(\.vniColors, isDark ? .dark : .light)
}
.platformPickers(settingsModel: settingsModel)
.task { await consumeExternalInvitations() }
.onChange(of: scenePhase) { _, phase in
switch phase {
case .active:
graph.visibility.setForeground(true)
graph.backgroundActivity.didBecomeForeground()
settingsModel.refreshNotificationPermission()
// Reconcile against the durable snapshot: while the window was
// unfocused/occluded (common on macOS) live events may not have
// rendered, leaving progress/status stale.
Task { _ = await graph.coreRepository.refresh() }
case .background:
graph.visibility.setForeground(false)
// Hold the process open for iOS's grace window so an active
// transfer can finish and notify before suspension.
graph.backgroundActivity.didEnterBackground()
case .inactive:
graph.visibility.setForeground(false)
@unknown default:
break
}
}
#if os(macOS)
// macOS keeps `scenePhase == .active` even when the app loses focus, so
// drive foreground/background off NSApplication's active state instead
// otherwise notifications (only posted when unfocused) never fire.
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didResignActiveNotification)) { _ in
graph.visibility.setForeground(false)
}
.onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in
graph.visibility.setForeground(true)
settingsModel.refreshNotificationPermission()
Task { _ = await graph.coreRepository.refresh() }
}
#endif
}
/// iOS uses a bottom tab bar; macOS uses a native source-list sidebar so each
/// screen's toolbar lives in the detail column instead of the shared title bar.
@ViewBuilder
private func navigation(windowClass: WindowClass) -> some View {
#if os(macOS)
NavigationSplitView {
List(AppDestination.allCases, selection: sidebarBinding) { destination in
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
.tag(destination)
}
.navigationSplitViewColumnWidth(min: 180, ideal: 200, max: 260)
} detail: {
screen(for: appModel.destination, windowClass: windowClass)
}
#else
TabView(selection: destinationBinding) {
ForEach(AppDestination.allCases) { destination in
screen(for: destination, windowClass: windowClass)
.tabItem {
Label(String(localized: destination.labelKey), systemSymbol: destination.systemSymbol)
}
.tag(destination)
}
}
#endif
}
private var sidebarBinding: Binding<AppDestination?> {
Binding(
get: { appModel.destination },
set: { newValue in
if let value = newValue {
Task { @MainActor in appModel.selectDestination(value) }
}
}
)
}
private var destinationBinding: Binding<AppDestination> {
// Defer the write out of the current view-update cycle: TabView reconciles
// its selection synchronously during body evaluation on macOS, and mutating
// the published `destination` there triggers a "publishing within view
// updates" warning.
Binding(get: { appModel.destination }, set: { newValue in
Task { @MainActor in appModel.selectDestination(newValue) }
})
}
@ViewBuilder
private func screen(for destination: AppDestination, windowClass: WindowClass) -> some View {
switch destination {
case .send: SendScreen(model: sendModel, windowClass: windowClass)
case .receive: ReceiveScreen(model: receiveModel, windowClass: windowClass)
case .settings:
SettingsScreen(model: settingsModel, windowClass: windowClass)
}
}
private var systemDark: Bool {
#if os(iOS)
return UITraitCollection.current.userInterfaceStyle == .dark
#else
return NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua
#endif
}
private func consumeExternalInvitations() async {
for await invitation in graph.dependencies.externalInvitations.invitations {
appModel.selectDestination(.receive)
switch invitation {
case .success(let raw):
receiveModel.onInvitationResult(.invitationFile, .success(raw))
case .failure(let error):
receiveModel.onInvitationResult(.invitationFile, .failure(error))
}
}
}
}
/// Hosts the approval modal, observing the coordinator passed in from the persisted
/// `AppGraph`. Kept as a child view so the `@ObservedObject` subscription is
/// established here (in `body`) against the live instance, rather than in
/// `RootView.init` against a throwaway graph.
private struct ApprovalLayer: View {
@ObservedObject var approvals: ApprovalCoordinator
let sendModel: SendModel
/// Drives the approval sheet; toggled from the pending-approval `onChange` so the
/// presentation can be deferred until the Share/QR sheet has dismissed on macOS.
@State private var showApproval = false
/// macOS-only: an approval arrived while a share/QR sheet was still up. We close
/// that sheet and present the approval once its dismissal completes (see
/// `sendModel.shareSheetsDismissed`), since macOS drops a sheet shown mid-dismissal.
@State private var approvalAwaitingSheetDismiss = false
var body: some View {
ApprovalModalHost(
isPresented: $showApproval,
state: approvals.state,
onAccept: approvals.accept,
onRefuse: approvals.refuse
)
// A pending approval is a blocking modal. Close any open share/QR sheet first
// (the detail-view panel *or* the list-level share sheet), then present the
// approval sheet: the approval is presented from the app root and neither
// platform reliably stacks it over a sheet owned by the Send screen.
.onChange(of: approvals.state.current?.id) { _, id in
guard id != nil else {
showApproval = false
approvalAwaitingSheetDismiss = false
return
}
let wasShowingSheet = sendModel.state.detailPanel != nil
|| sendModel.state.shareTargetId != nil
sendModel.dismissShareSheets()
#if os(macOS)
// macOS silently drops a sheet presented while another is still dismissing,
// so wait for that sheet's real dismissal completion before presenting.
if wasShowingSheet {
approvalAwaitingSheetDismiss = true
} else {
showApproval = true
}
#else
_ = wasShowingSheet
showApproval = true
#endif
}
#if os(macOS)
.onReceive(sendModel.shareSheetsDismissed) { _ in
guard approvalAwaitingSheetDismiss else { return }
approvalAwaitingSheetDismiss = false
if approvals.state.current != nil { showApproval = true }
}
#endif
}
}
/// A full-window cover with a centered spinner shown while the core is starting.
private struct CoreStartingOverlay: View {
var body: some View {
ZStack {
backgroundColor.ignoresSafeArea()
VStack(spacing: 16) {
ProgressView().controlSize(.large)
Text(String(localized: L10n.App.starting))
.font(.headline)
.foregroundStyle(.secondary)
}
}
.transition(.opacity)
.accessibilityElement(children: .combine)
.accessibilityLabel(Text(String(localized: L10n.App.starting)))
}
private var backgroundColor: Color {
#if os(iOS)
Color(uiColor: .systemBackground)
#else
Color(nsColor: .windowBackgroundColor)
#endif
}
}
#if os(iOS)
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.

View File

@@ -0,0 +1,62 @@
import SwiftUI
/// Scene identifier for the single main window.
private let mainWindowId = "main"
/// Native app entry point for iOS, iPadOS, and macOS.
/// Opens `.vnd` invitations via `onOpenURL` and routes them to the receive flow.
@main
struct VniDropApp: App {
@StateObject private var externalInvitations = ExternalInvitationController()
#if DIRECT_DISTRIBUTION && os(macOS)
// Sparkle auto-updater, present only in the direct-download (.dmg) build.
@StateObject private var updater = SparkleUpdaterController()
#endif
var body: some Scene {
#if os(macOS)
// A single-instance `Window` (not `WindowGroup`): the app must never open a
// second window. `Window` also drops the N "New Window" command.
Window(Text(verbatim: "VniDrop"), id: mainWindowId) {
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
.ignoresSafeArea()
.onOpenURL(perform: openInvitation)
}
#if DIRECT_DISTRIBUTION
.commands {
UpdatesCommands(controller: updater)
}
#endif
#else
WindowGroup(id: mainWindowId) {
RootView(dependencies: makeAppDependencies(externalInvitations: externalInvitations))
.ignoresSafeArea()
.onOpenURL(perform: openInvitation)
}
#endif
}
/// Reads a `.vnd` invitation document under a security scope, enforcing the
/// 64 KiB / strict-UTF-8 rules from `ContentView.swift`.
private func openInvitation(_ url: URL) {
guard url.pathExtension.caseInsensitiveCompare(vniDropInvitationExtension) == .orderedSame else {
externalInvitations.reportOpenFailure(message: "This is not a VniDrop invitation")
return
}
let started = url.startAccessingSecurityScopedResource()
defer { if started { url.stopAccessingSecurityScopedResource() } }
do {
let values = try url.resourceValues(forKeys: [.fileSizeKey])
if let size = values.fileSize, size > maxVniDropInvitationBytes {
throw InvitationError.tooLarge
}
let data = try Data(contentsOf: url, options: .mappedIfSafe)
let raw = try decodeInvitationBytes(data)
externalInvitations.openInvitation(raw: raw)
} catch {
externalInvitations.reportOpenFailure(
message: (error as? LocalizedError)?.errorDescription ?? "The invitation could not be opened"
)
}
}
}

View File

@@ -0,0 +1,24 @@
import Foundation
import os
/// Minimal structured logger, ported from `logging/AppLogger.kt`. Never logs
/// tickets, endpoint ids, or file contents (callers pass only redacted fields).
enum AppLogger {
private static let logger = Logger(subsystem: "com.vnidrop.app", category: "app")
static func info(_ scope: String, _ message: String, _ fields: [String: String] = [:]) {
logger.info("[\(scope, privacy: .public)] \(message, privacy: .public) \(fieldString(fields), privacy: .public)")
}
static func error(_ scope: String, _ message: String, _ error: Error? = nil) {
if let error {
logger.error("[\(scope, privacy: .public)] \(message, privacy: .public): \(error.technicalDetail, privacy: .public)")
} else {
logger.error("[\(scope, privacy: .public)] \(message, privacy: .public)")
}
}
private static func fieldString(_ fields: [String: String]) -> String {
fields.isEmpty ? "" : fields.map { "\($0)=\($1)" }.joined(separator: " ")
}
}

View File

@@ -0,0 +1,232 @@
import Foundation
import Combine
/// Receive-destination descriptor, ported from `core/FileSystemService.kt`.
enum ReceiveFolderKind: String, Codable, Sendable {
case fileSystemPath
case iosSecurityScopedUrl
}
struct ReceiveFolder: Equatable, Codable, Sendable {
let kind: ReceiveFolderKind
let value: String
let displayName: String
}
enum FolderAccessStatus {
case writable
case permissionRequired
case unavailable
}
enum RelayPreferenceMode: String, Codable, CaseIterable, Sendable {
case automatic
case strictCustom = "custom"
case customWithDirectFallback = "custom-with-direct-fallback"
case localOnly = "local-only"
var usesCustomRelayURLs: Bool {
self == .strictCustom || self == .customWithDirectFallback
}
}
struct RelayConfiguration: Equatable, Codable, Sendable {
var mode: RelayPreferenceMode
var relayURLs: [String]
static let automatic = RelayConfiguration(mode: .automatic, relayURLs: [])
}
enum RelayConfigurationValidationError: Error, Equatable, Sendable {
case missingURL
case tooManyURLs
case httpsRequired(index: Int)
case invalidURL(index: Int)
case duplicateURL(index: Int)
var urlIndex: Int? {
switch self {
case .httpsRequired(let index), .invalidURL(let index), .duplicateURL(let index): return index
case .missingURL, .tooManyURLs: return nil
}
}
}
enum RelayConfigurationValidator {
static let maximumRelayCount = 8
static let maximumRelayURLBytes = 2_048
static func validate(
mode: RelayPreferenceMode,
relayURLs: [String],
retainedRelayURLs: [String] = []
) throws -> RelayConfiguration {
guard mode.usesCustomRelayURLs else {
return RelayConfiguration(mode: mode, relayURLs: retainedRelayURLs)
}
let relayEntries = relayURLs.enumerated().compactMap { index, value -> (Int, String)? in
let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
return trimmed.isEmpty ? nil : (index, trimmed)
}
guard !relayEntries.isEmpty else {
throw RelayConfigurationValidationError.missingURL
}
guard relayEntries.count <= maximumRelayCount else {
throw RelayConfigurationValidationError.tooManyURLs
}
var seen = Set<String>()
var normalizedURLs: [String] = []
for (index, relayURL) in relayEntries {
guard relayURL.lengthOfBytes(using: .utf8) <= maximumRelayURLBytes,
relayURL.rangeOfCharacter(from: .whitespacesAndNewlines.union(.controlCharacters)) == nil,
var components = URLComponents(string: relayURL) else {
throw RelayConfigurationValidationError.invalidURL(index: index)
}
guard components.scheme?.lowercased() == "https" else {
throw RelayConfigurationValidationError.httpsRequired(index: index)
}
guard
let host = components.host,
!host.isEmpty,
components.port.map({ (1...65_535).contains($0) }) ?? true,
components.user == nil,
components.password == nil,
components.query == nil,
components.fragment == nil,
components.path.isEmpty || components.path == "/"
else {
throw RelayConfigurationValidationError.invalidURL(index: index)
}
components.scheme = "https"
components.host = host.lowercased()
if components.port == 443 { components.port = nil }
if components.path == "/" { components.path = "" }
guard let canonicalURL = components.string, seen.insert(canonicalURL).inserted else {
throw RelayConfigurationValidationError.duplicateURL(index: index)
}
normalizedURLs.append(canonicalURL)
}
return RelayConfiguration(mode: mode, relayURLs: normalizedURLs)
}
}
/// Persisted app preferences, ported from `preferences/AppPreferencesRepository.kt`.
/// Backed by `UserDefaults` instead of DataStore; keys and semantics match.
struct AppPreferences: Equatable {
var username: String
var receiveFolder: ReceiveFolder
var themeMode: ThemeMode
var diagnosticsInstallId: String
var relayConfiguration: RelayConfiguration
}
struct AppPreferencesDefaults {
let username: String
let receiveFolder: ReceiveFolder
let themeMode: ThemeMode
}
@MainActor
final class AppPreferencesRepository: ObservableObject {
@Published private(set) var preferences: AppPreferences
private let defaults: UserDefaults
private let fallback: AppPreferencesDefaults
private enum Key {
static let username = "username"
static let receiveFolderKind = "receive_folder_kind"
static let receiveFolderValue = "receive_folder_value"
static let receiveFolderDisplayName = "receive_folder_display_name"
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
self.fallback = fallback
self.preferences = Self.read(from: defaults, fallback: fallback)
}
private static func read(from defaults: UserDefaults, fallback: AppPreferencesDefaults) -> AppPreferences {
let username = (defaults.string(forKey: Key.username)).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.username
let folder = resolveReceiveFolder(defaults, fallback: fallback.receiveFolder)
let themeMode = defaults.string(forKey: Key.themeMode).flatMap(ThemeMode.init(rawValue:)) ?? fallback.themeMode
let installId = defaults.string(forKey: Key.diagnosticsInstallId) ?? ""
return AppPreferences(
username: username,
receiveFolder: folder,
themeMode: themeMode,
diagnosticsInstallId: installId,
relayConfiguration: resolveRelayConfiguration(defaults)
)
}
private static func resolveRelayConfiguration(_ defaults: UserDefaults) -> RelayConfiguration {
guard defaults.object(forKey: Key.relayConfiguration) != nil else { return .automatic }
guard
let data = defaults.data(forKey: Key.relayConfiguration),
let configuration = try? JSONDecoder().decode(RelayConfiguration.self, from: data)
else {
// A stored profile must never silently fall back to public relays. Strict
// custom with no URLs makes startup fail closed until Settings repairs it.
return RelayConfiguration(mode: .strictCustom, relayURLs: [])
}
return configuration
}
private static func resolveReceiveFolder(_ defaults: UserDefaults, fallback: ReceiveFolder) -> ReceiveFolder {
let kind = defaults.string(forKey: Key.receiveFolderKind)
.flatMap(ReceiveFolderKind.init(rawValue:)) ?? fallback.kind
let value = defaults.string(forKey: Key.receiveFolderValue).flatMap { $0.isEmpty ? nil : $0 } ?? fallback.value
let displayName = defaults.string(forKey: Key.receiveFolderDisplayName)
.flatMap { $0.isEmpty ? nil : $0 } ?? fallback.displayName
return ReceiveFolder(kind: kind, value: value, displayName: displayName)
}
private func reload() {
preferences = Self.read(from: defaults, fallback: fallback)
}
func setUsername(_ username: String) {
defaults.set(username.trimmingCharacters(in: .whitespacesAndNewlines), forKey: Key.username)
reload()
}
func setReceiveFolder(_ folder: ReceiveFolder) {
defaults.set(folder.kind.rawValue, forKey: Key.receiveFolderKind)
defaults.set(folder.value, forKey: Key.receiveFolderValue)
defaults.set(folder.displayName, forKey: Key.receiveFolderDisplayName)
reload()
}
func resetReceiveFolder() {
setReceiveFolder(fallback.receiveFolder)
}
func setThemeMode(_ mode: ThemeMode) {
defaults.set(mode.rawValue, forKey: Key.themeMode)
reload()
}
func setRelayConfiguration(_ configuration: RelayConfiguration) {
guard let encoded = try? JSONEncoder().encode(configuration) else { return }
defaults.set(encoded, forKey: Key.relayConfiguration)
reload()
}
@discardableResult
func ensureDiagnosticsInstallId() -> String {
let existing = preferences.diagnosticsInstallId
if !existing.isEmpty { return existing }
let created = UUID().uuidString
defaults.set(created, forKey: Key.diagnosticsInstallId)
reload()
return created
}
}

View File

@@ -0,0 +1,12 @@
import Foundation
import Combine
/// Tracks whether the app is in the foreground, ported from `platform/AppVisibility.kt`.
@MainActor
final class AppVisibility: ObservableObject {
@Published private(set) var isForeground: Bool = true
func setForeground(_ value: Bool) {
isForeground = value
}
}

Some files were not shown because too many files have changed in this diff Show More