diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 1439858..99ba457 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1,12 +1,18 @@ # App Architecture -> **Stack:** Kotlin Multiplatform (Android + iOS) · MVI + Repository Pattern · Claude AI +> **Stack:** Kotlin Multiplatform (Android + iOS) · MVI + Repository Pattern · Claude API · on-device AI · YouCam Apparel VTO --- ## 1. Overview -This document describes the architecture of **a KMP wardrobe manager app** for Android and iOS. Users can catalog their clothing items with photos stored in app-private internal storage. AI features (image analysis and auto-tagging) are powered by the Anthropic Claude API, using an API key provided by the user and stored as an encrypted secret. +This document describes the architecture of **Worn**, a KMP wardrobe manager for Android and iOS. Users catalog clothing items with photos stored in app-private internal storage, assemble outfits from them, and see which items are missing from their wardrobe. + +Three AI-backed capabilities sit on top of that catalog, each independently opt-in and each using a credential the user supplies (BYOK — nothing is bundled in the build): + +- **Photo analysis and auto-tagging**, plus gap recommendations and prospective-purchase analysis — served by either the Anthropic **Claude API** or the device's **on-device model**, behind one interface. +- **Virtual try-on** — Perfect Corp's **YouCam Apparel VTO** renders a garment onto the user's saved model photo. +- **Background removal** — ML Kit Subject Segmentation, on-device, no credential required. --- @@ -28,9 +34,12 @@ ViewModel ──► Repository Interface ▼ Repository Implementation State (business logic here) │ │ - ▼ ┌─────┴──────┐ -UI (Compose / LocalSource AiSource - SwiftUI) (photos + db) (Claude API) + ▼ ┌─────────┼──────────┐ +UI (Compose /│ │ │ + SwiftUI) │ │ │ + LocalSource AiSource TryOnSource + (photos + db) (Claude | (YouCam) + on-device) ``` --- @@ -39,30 +48,33 @@ UI (Compose / LocalSource AiSource ``` root/ -├── androidApp/ # Android entry point -├── iosApp/ # iOS entry point (Xcode project) -└── shared/ # KMP shared module +├── composeApp/ # Android application module (Compose UI) +├── iosApp/ # iOS app (Xcode/SwiftUI) + WornShareExtension +├── journeys/ # XML end-to-end journey specs +└── shared/ # KMP shared module ├── commonMain/ │ ├── data/ - │ │ ├── repository/ # Repository implementations (business logic) + │ │ ├── repository/ # Repository implementations (business logic) │ │ └── source/ - │ │ ├── local/ # SQLDelight DB + file storage abstraction - │ │ └── remote/ # Claude API client + │ │ ├── local/ # SQLDelight DB, DataStore, PhotoFileStorage + │ │ ├── remote/ # Claude + YouCam API clients + │ │ ├── ai/ # Shared prompts, response models, parser, on-device engine + │ │ └── image/ # BackgroundRemover (expect) │ ├── domain/ - │ │ ├── model/ # Pure Kotlin data classes - │ │ └── repository/ # Repository interfaces + │ │ ├── model/ # Pure Kotlin data classes + │ │ └── repository/ # Repository interfaces │ ├── presentation/ - │ │ └── viewmodel/ # Shared ViewModels (MVI) - │ └── util/ - │ └── secret/ # SecretStore interface - ├── androidMain/ - │ ├── source/local/ # Android file I/O - │ └── secret/ # EncryptedSharedPreferences - ├── iosMain/ - │ ├── source/local/ # iOS file I/O - │ └── secret/ # Keychain - └── commonTest/ - └── repository/ # Unit tests (all repositories) + │ │ └── viewmodel/ # Shared ViewModels (MVI) + │ ├── util/ + │ │ ├── secret/ # SecretStore interface + │ │ ├── image/ # CropGeometry + │ │ └── crypto/ # RsaEncryptor (expect) + │ ├── di/ # Koin modules + │ └── sqldelight/ # .sq schemas + ├── androidMain/ # Keystore, AndroidSqliteDriver, ML Kit, OkHttp, JCA + ├── iosMain/ # Keychain, NativeSqliteDriver, Darwin, Security framework + ├── commonTest/ # ViewModel tests + fakes + └── androidHostTest/ # Repository and API client tests (JVM) ``` --- @@ -80,226 +92,242 @@ data class ClothingItem( val name: String, val category: Category, val colors: List, + val seasons: List, + val subcategory: Subcategory? = null, + val fit: Fit? = null, + val material: Material? = null, val tags: List = emptyList(), val description: String? = null, val photoPath: String, val createdAt: Long ) -enum class Category { - TOP, BOTTOM, DRESS, OUTERWEAR, SHOES, ACCESSORY -} +enum class Category { TOP, BOTTOM, DRESS, OUTERWEAR, SHOES, ACCESSORY } -data class AiAnalysisResult( - val description: String, - val suggestedCategory: Category, - val colors: List, - val tags: List +/** Which YouCam endpoint family and garment_category a try-on uses. */ +enum class GarmentCategory { TOP, BOTTOM, FULL_BODY, SHOES } + +data class TryItResult( + val matchingItems: List, + val combinationsUnlocked: Int, + val gapsFilled: List, + val worthAdding: Boolean, ) +``` -// Repository interfaces +Repository interfaces expose `kotlin.Result` for one-shot operations, and plain `Flow` for reactive reads (failures surface as stream exceptions, handled with `catch` by the collector). + +```kotlin interface WardrobeRepository { - suspend fun getAll(): List - suspend fun getById(id: String): ClothingItem? - suspend fun getByCategory(category: Category): List - suspend fun search(query: String): List - suspend fun addItem(imageBytes: ByteArray, name: String): ClothingItem - suspend fun analyzeAndTag(itemId: String): ClothingItem - suspend fun updateItem(item: ClothingItem): ClothingItem - suspend fun deleteItem(id: String) + fun observeAll(): Flow> // single source of truth for the UI + suspend fun getAll(): Result> + suspend fun search(query: String): Result> + suspend fun addItem(imageBytes: ByteArray, name: String, /* ... */): Result + suspend fun analyzeAndTag(itemId: String): Result + suspend fun deleteItem(id: String): Result + suspend fun getGapRecommendations(): Result> + suspend fun analyzeProspectiveItem(imageBytes: ByteArray): Result } +interface TryOnRepository { + suspend fun generateTryOn(garmentBytes: ByteArray, category: GarmentCategory): Result + suspend fun verifyCredentials(clientId: String, clientSecret: String): Result +} + +// Also: OutfitRepository, SettingsRepository (profile, model photo, credential state) +``` + +`SecretStore` is keyed by name so multiple providers coexist: + +```kotlin interface SecretStore { - fun getApiKey(): String? - fun saveApiKey(key: String) - fun clearApiKey() + fun getSecret(name: String): String? + fun saveSecret(name: String, value: String) + fun clearSecret(name: String) + + companion object { + const val CLAUDE_KEY = "claude_api_key" + const val YOUCAM_CLIENT_ID = "youcam_client_id" + const val YOUCAM_CLIENT_SECRET = "youcam_client_secret" + } } ``` ### 4.2 Data Layer (`shared/commonMain/data/`) -Repository implementations contain all business logic — validation, orchestration between local and AI sources, error handling. +Repository implementations contain all business logic — validation, orchestration between local, AI, and try-on sources, error handling. Two conventions apply throughout: + +- **The `CoroutineContext` is injected via the constructor**, never hardcoded to `Dispatchers.IO`. Every DB, file, and network call is wrapped in `withContext(dispatcher)`; callers never switch dispatchers, and platform data sources don't dispatch on their own behalf. +- **`runCatching`, not `try/catch`**, so implementations return the `Result` the interface promises. ```kotlin class WardrobeRepositoryImpl( - private val db: WardrobeDatabase, // SQLDelight + private val db: WardrobeDatabase, // SQLDelight private val fileStorage: PhotoFileStorage, // expect/actual - private val aiClient: ClaudeApiClient + private val aiClient: ClaudeApiClient, + private val onDeviceAi: OnDeviceAiSource, + private val settingsRepository: SettingsRepository, + private val dispatcher: CoroutineContext, // injected — never hardcoded ) : WardrobeRepository { - // Business logic: save photo, persist metadata, return item - override suspend fun addItem(imageBytes: ByteArray, name: String): ClothingItem { - val path = fileStorage.write("${uuid()}.jpg", imageBytes) - val item = ClothingItem( - id = uuid(), - name = name, - category = Category.TOP, // default until analyzed - colors = emptyList(), - photoPath = path, - createdAt = currentTimeMillis() - ) - db.clothingItemQueries.insert(item) - return item + // Business logic: pick a provider, call it, map the result, persist the updated item + override suspend fun analyzeAndTag(itemId: String): Result = runCatching { + withContext(dispatcher) { + val item = findById(itemId) ?: error("Item not found: $itemId") + val imageBytes = fileStorage.read(item.photoPath) + val analysis = if (useOnDeviceAi()) { + onDeviceAi.analyzeImage(imageBytes) + } else { + aiClient.analyzeImage(imageBytes) + } + db.clothingItemQueries.update(/* mapped fields */) + item.copy(/* ... */) + } } - // Business logic: call AI, map result, persist updated item - override suspend fun analyzeAndTag(itemId: String): ClothingItem { - val item = getById(itemId) ?: error("Item not found: $itemId") - val imageBytes = fileStorage.read(item.photoPath) - val analysis = aiClient.analyzeImage(imageBytes) - val updated = item.copy( - description = analysis.description, - category = analysis.suggestedCategory, - colors = analysis.colors, - tags = analysis.tags - ) - db.clothingItemQueries.update(updated) - return updated + // Business logic: deleting an item cascades to its outfits and its photo file + override suspend fun deleteItem(id: String): Result = runCatching { + withContext(dispatcher) { + val item = findById(id) ?: return@withContext + db.transaction { /* delete affected outfits, then the item */ } + fileStorage.delete(item.photoPath) + } } - - // Business logic: also deletes photo file - override suspend fun deleteItem(id: String) { - val item = getById(id) ?: return - fileStorage.delete(item.photoPath) - db.clothingItemQueries.delete(id) - } - - override suspend fun search(query: String): List = - db.clothingItemQueries.search("%$query%").executeAsList().map { it.toDomain() } } ``` -**Local photo storage** — app-private only, via `expect/actual`: +Note the nesting: `runCatching` is the outer wrapper and `withContext` the inner one, so a dispatcher failure is captured in the `Result` alongside everything else. + +**Platform abstractions** — all `expect/actual`, with the platform-specific work kept behind a common signature: ```kotlin -expect class PhotoFileStorage { +expect class PhotoFileStorage { // app-private internal storage suspend fun write(fileName: String, bytes: ByteArray): String suspend fun read(filePath: String): ByteArray suspend fun delete(filePath: String) } -``` -**Claude API client** — thin HTTP wrapper, no business logic: +expect class BackgroundRemover { // ML Kit on Android, Vision on iOS + suspend fun removeBackground(bytes: ByteArray): ByteArray +} -```kotlin -class ClaudeApiClient(private val secretStore: SecretStore) { - suspend fun analyzeImage(imageBytes: ByteArray): AiAnalysisResult - // Sends image to claude-sonnet-4-20250514 via /v1/messages - // Parses response into AiAnalysisResult +expect class RsaEncryptor { // JCA on Android, Security framework on iOS + fun encrypt(plaintext: String, publicKeyBase64: String): String } ``` +**API clients** are thin HTTP wrappers with no business logic — `ClaudeApiClient` and `YouCamApiClient`. All decisions about *when* and *how* to call them live in the repositories. + ### 4.3 Presentation Layer (`shared/commonMain/presentation/`) -ViewModels are thin — they call the repository and map results to UI state. No business logic here. +ViewModels are thin — they call the repository, consume the `Result` directly with `.onSuccess`/`.onFailure` (never `try/catch` or `runCatching` of their own), and map to UI state. No business logic here. ```kotlin -sealed class WardrobeIntent { - object LoadItems : WardrobeIntent() - data class AddItem(val imageBytes: ByteArray, val name: String) : WardrobeIntent() - data class AnalyzeItem(val itemId: String) : WardrobeIntent() - data class DeleteItem(val itemId: String) : WardrobeIntent() - data class Search(val query: String) : WardrobeIntent() - data class FilterByCategory(val category: Category?) : WardrobeIntent() +sealed interface TryItIntent { + data class GarmentSelected(val imageBytes: ByteArray) : TryItIntent + data class CategorySelected(val category: GarmentCategory) : TryItIntent + data object GenerateTryOn : TryItIntent + data object AnalyzeItem : TryItIntent } -data class WardrobeState( - val items: List = emptyList(), - val isLoading: Boolean = false, - val analyzingItemId: String? = null, - val activeCategory: Category? = null, - val searchQuery: String = "", - val error: String? = null +data class TryItState( + val hasClaudeKey: Boolean = false, + val hasYouCamKey: Boolean = false, + val personImage: ByteArray? = null, + val selectedCategory: GarmentCategory? = null, + val tryOnLoading: Boolean = false, + val tryOnImage: ByteArray? = null, + val tryOnError: String? = null, + val analysis: TryItResult? = null, ) -sealed class WardrobeEffect { - data class ShowError(val message: String) : WardrobeEffect() - object ItemAdded : WardrobeEffect() - object ItemDeleted : WardrobeEffect() -} - -class WardrobeViewModel( - private val repository: WardrobeRepository +class TryItViewModel( + private val tryOnRepository: TryOnRepository, + private val wardrobeRepository: WardrobeRepository, ) : ViewModel() { - val state: StateFlow - val effects: Flow - fun onIntent(intent: WardrobeIntent) + val state: StateFlow + val effects: Flow + fun onIntent(intent: TryItIntent) } ``` +The Android UI is Compose Multiplatform; the iOS UI is native SwiftUI driven by wrapper classes that bridge the shared ViewModels. Both consume the same state. + --- -## 5. AI Integration (Claude API) +## 5. AI & Try-On Integration + +### 5.1 Claude API - The API key is **user-provided** and stored in the platform secret store — never hardcoded. -- Photos are sent as base64-encoded images to `claude-sonnet-4-20250514`. -- The client is a thin wrapper; all decisions about *when* and *how* to call AI live in the Repository. +- Photos are sent as base64-encoded images to the Messages API. +- The client is a thin wrapper; all decisions about *when* and *how* to call AI live in the repository. + +### 5.2 On-device AI + +An alternative provider the repository selects per call, based on the user's setting: **ML Kit GenAI Prompt** on Android, **Apple Intelligence** (`FoundationModels`, reached through a small Swift bridge) on iOS. + +Prompts, response models, and parsing are shared across both providers in `data/source/ai/` (`AiPrompts`, `AiResponseModels`, `AiResponseParser`), so switching providers doesn't fork the prompt logic. Availability is a first-class domain model (`OnDeviceAiAvailability`, with an `OnDeviceAiUnavailableReason`) because support varies by device, OS version, and user opt-in. + +One deliberate exception: `analyzeProspectiveItem` — the Try It analysis — stays on Claude regardless of the preference, because reasoning over the whole wardrobe against a new photo is a task small on-device models handle poorly. + +### 5.3 YouCam Apparel VTO + +Server-to-server against `https://yce-api-01.perfectcorp.com`, from shared Kotlin, with no SDK and no backend of our own: + +**authenticate → presigned upload (person + garment) → create task → poll → download JPEG** + +- **Auth is RSA, not a bearer secret.** The `id_token` is `client_id=×tamp=` encrypted with the user's public key under RSA/PKCS#1 v1.5, Base64'd. Access tokens are cached for their 2h TTL with a 5-minute refresh margin. +- The `expect/actual` `RsaEncryptor` exists because the platforms disagree on key format: Android's `X509EncodedKeySpec` takes the X.509 SPKI key the portal issues, while iOS's `SecKeyCreateWithData` requires PKCS#1 — so the iOS actual walks the ASN.1 TLV structure to unwrap the inner `RSAPublicKey`. +- **Endpoint family is routed by garment category**: `v2.0`/`cloth-v3` with `garment_category` = `upper_body`/`lower_body`/`full_body`, or `v1.0`/`shoes` (no garment category). +- Polling runs every 2s for up to 60 attempts; HTTP status codes map to user-actionable messages (401/403 credentials, 429 quota, 5xx service). + +See the README for the full walkthrough. --- ## 6. Security -| Concern | Solution | -|---|-------------------------------------------------------| -| API key at rest | Android: `Keystore` · iOS: Keychain | -| API key in transit | HTTPS only (Ktor) | -| Photo data | App-private internal storage — no external app access | -| No key in logs | `SecretStore` never exposes raw key to logging layers | -| No key in source | User-provided at runtime | +| Concern | Solution | +|---|---| +| Secrets at rest | Android: Keystore AES/GCM-encrypted per-name file · iOS: Keychain | +| Secrets in transit | HTTPS only (Ktor) | +| Photo data | App-private internal storage — no external app access, no storage permission | +| No secret in logs | Only lengths are logged; `SecretStore` never exposes raw values to logging layers | +| No secret in source | User-provided at runtime (BYOK); nothing in the build, gradle, or CI | +| Bad credentials | Verified with a live auth handshake **before** being persisted | +| Key-value storage | DataStore for non-secret preferences; the Keystore path is used only where encryption is required | --- ## 7. Testing Strategy -Repositories are the main unit test target — they hold the business logic. +Repositories hold the business logic, so they are the main unit test target. ViewModel state transitions are asserted with Turbine. ``` -shared/commonTest/ -├── repository/ -│ └── WardrobeRepositoryTest.kt -└── viewmodel/ - └── WardrobeViewModelTest.kt +shared/ +├── commonTest/ # multiplatform +│ ├── viewmodel/ # Wardrobe, Outfit, Settings, TryIt +│ ├── ai/ # OnDeviceAiSourceTest +│ ├── util/image/ # CropGeometryTest +│ ├── model/ # AppShortcutTest +│ └── fake/ # fakes for every repository + SecretStore +└── androidHostTest/ # JVM-only (needs JCA, MockK) + ├── repository/ # WardrobeRepositoryImpl, TryOnRepositoryImpl + ├── remote/ # ClaudeApiClientTest, YouCamApiClientTest + └── util/crypto/ # RsaEncryptorTest ``` -```kotlin -class WardrobeRepositoryTest { - private val fakeDb = FakeWardrobeDatabase() - private val fakeStorage = FakePhotoFileStorage() - private val fakeAiClient = FakeClaudeApiClient() - private val repository = WardrobeRepositoryImpl(fakeDb, fakeStorage, fakeAiClient) - - @Test - fun `adding item saves photo and persists metadata`() = runTest { - val item = repository.addItem(imageBytes = byteArrayOf(1, 2, 3), name = "Blue Jeans") - assertEquals("Blue Jeans", item.name) - assertTrue(fakeStorage.hasFile(item.photoPath)) - assertNotNull(fakeDb.findById(item.id)) - } - - @Test - fun `analyzeAndTag updates category colors and tags`() = runTest { - val item = repository.addItem(byteArrayOf(), "Jacket") - fakeAiClient.willReturn(AiAnalysisResult( - description = "A navy blue jacket", - suggestedCategory = Category.OUTERWEAR, - colors = listOf("navy"), - tags = listOf("jacket", "formal") - )) - - val updated = repository.analyzeAndTag(item.id) - - assertEquals(Category.OUTERWEAR, updated.category) - assertEquals(listOf("navy"), updated.colors) - assertEquals(listOf("jacket", "formal"), updated.tags) - } +- **`ktor-client-mock`** drives the API client tests, so the full YouCam auth → upload → poll → download sequence is exercised without network access. +- **Turbine** asserts `StateFlow` / effect emissions; **MockK** covers the JVM-side doubles. +- **`journeys/`** holds XML end-to-end journey specs run by the `android` CLI against a real device (see [`journeys/README.md`](journeys/README.md)). Compose test tags are mirrored as iOS `accessibilityIdentifier` values so one spec describes both platforms. - @Test - fun `deleting item removes photo file and db record`() = runTest { - val item = repository.addItem(byteArrayOf(), "Old Shirt") - repository.deleteItem(item.id) - assertFalse(fakeStorage.hasFile(item.photoPath)) - assertNull(fakeDb.findById(item.id)) - } +```kotlin +@Test +fun `generateTryOn fails when no model photo is saved`() = runTest { + val repository = TryOnRepositoryImpl(youCamClient, fakeSettings, testDispatcher) + val result = repository.generateTryOn(garmentBytes = byteArrayOf(1, 2, 3), category = TOP) + assertTrue(result.isFailure) } ``` @@ -310,11 +338,16 @@ class WardrobeRepositoryTest { | Library | Purpose | |---|---| | `kotlinx-coroutines` | Async / Flow | -| `ktor-client` | HTTP client for Claude API (multiplatform) | +| `ktor-client` | HTTP client for Claude + YouCam (multiplatform) | | `kotlinx-serialization` | JSON parsing | | `SQLDelight` | Local clothing metadata DB (multiplatform) | | `Koin` | Dependency injection (multiplatform) | -| `kotlin-test` | Unit testing in commonTest | +| `Coil` | Image loading | +| `androidx-datastore` | Key-value storage for non-secret preferences | +| `mlkit-genai-prompt` | On-device AI (Android) | +| `play-services-mlkit-subject-segmentation` | Background removal (Android) | +| `kotlin-test` · `turbine` · `mockk` · `ktor-client-mock` | Testing | +| `detekt` | Static analysis | --- @@ -323,8 +356,15 @@ class WardrobeRepositoryTest { | Decision | Rationale | |---|---| | No Use Cases layer | Avoids indirection for a focused single-domain app | -| Business logic in Repository | Orchestration of AI + storage + DB in one testable place | +| Business logic in Repository | Orchestration of AI + try-on + storage + DB in one testable place | | MVI for presentation | Unidirectional flow handles AI async states cleanly | | App-private storage | No permission requests; simpler security model | | Koin over Hilt | Multiplatform; Hilt is Android-only | | SQLDelight | Only multiplatform SQL solution with type-safe queries | +| BYOK over bundled keys | No credential ships in the binary; each AI feature is independently opt-in and the app is useful with none of them | +| `CoroutineContext` injected via constructor | Dependency inversion — repositories are testable with a test dispatcher and never hardcode `Dispatchers.IO` | +| Repositories return `Result` | Errors are values at the layer boundary; ViewModels consume them without `try/catch` | +| DataStore over SharedPreferences | Async, type-safe, transactional; the Keystore path is reserved for secrets that need real encryption | +| On-device AI as a swappable provider | Shared prompts and parsing, so the repository picks a provider per call and analysis can run offline and free | +| `expect/actual` RSA | No multiplatform library covers PKCS#1 v1.5 on both targets, and the two platforms disagree on public-key encoding (SPKI vs PKCS#1) | +| Native SwiftUI on iOS | Compose Multiplatform for Android UI, native SwiftUI on iOS, sharing only the ViewModels — platform-idiomatic UI on both | diff --git a/README.md b/README.md index f643625..7dc0048 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,195 @@ # Worn -A wardrobe manager for Android and iOS, built with Kotlin Multiplatform. Catalog your clothing with photos and let AI auto-tag and categorize items using the Claude API. +**Try it on, and find out whether it fits the closet you already own.** + +A wardrobe manager for Android and iOS, built with Kotlin Multiplatform. Worn catalogs what you own, then uses Perfect Corp's **YouCam Apparel Virtual Try-On** to render a garment on you — and Claude to tell you whether that garment is worth buying at all. + +--- + +## The problem + +You photograph a jacket in a store, buy it, get home, and discover it pairs with nothing you own. That single moment holds two separate unknowns: *does this look good on me*, and *does this work with what I already have*. Every shopping tool answers at most one of them. + +Worn's user is a beginner — someone with a closet full of impulse buys that don't combine, who shops two to four times a year and dreads every trip. He doesn't need more options. He needs to know whether *this one* is a mistake, before he pays for it. + +## Demo + + + +▶ [Watch the try-on demo](screenshots/try_on.mp4) — garment photo in, rendered on the user, no store dressing room. + +## Screens + +| Try It | Wardrobe | Gaps | +|---|---|---| +| Try It screen | Wardrobe screen | Gaps screen | +| See a garment on yourself and get a buy/skip verdict | Your catalogued items, auto-tagged from photos | Ranked list of what's missing from your closet | + +| Outfits | Settings | +|---|---| +| Outfits screen | Settings screen | +| Saved combinations built from items you own | Profile, on-device AI, and your own API credentials | + +--- ## Features -- Catalog clothing items with photos stored in app-private storage -- AI-powered image analysis and auto-tagging (via Anthropic Claude API) -- Search and filter by category, color, or tags -- Cross-platform: Android and iOS from a single codebase +### Try It — the two questions, on one screen + +Upload a photo of something you're considering. Worn answers both unknowns at once: + +**See it on me** sends the garment and your saved full-body photo to the YouCam Apparel VTO API, with a category you pick (Top / Bottom / Full outfit / Shoes), and renders you wearing it. + +**Would it fit your wardrobe?** reads your *actual catalogued items* and returns what the garment pairs with, how many new outfit combinations it unlocks, which gap it fills, and a plain **Worth adding** or **Skip this one**. + +That pairing is the point. A try-on rendered in isolation tells you the shirt looks fine — it can't tell you that you already own four like it, or that nothing in your closet goes with it. Worn gates the try-on behind a real wardrobe, so the rendered image arrives next to a verdict grounded in what you own. The render answers *how does it look on me*; the catalog answers *should I buy it*. Neither is useful alone at the moment of purchase. + +It also has to reach you at that moment. Try It accepts photos straight from the **system share sheet** — share an image out of any shopping app and land directly in the try-on flow — and is exposed as a **launcher shortcut / quick action**, so it works while you're standing in the store, not only when you remember to open the app. + +### Wardrobe + +Catalog items from the camera or gallery, with a crop editor and automatic **background removal** (ML Kit Subject Segmentation) so a photo taken on a messy bed still produces a clean catalog card. AI auto-tags each item into category, subcategory, color, material, fit, season, and free tags — the manual fields are the fallback, not the default path. + +This catalog is what makes everything else contextual. Without it, the try-on is a picture and the gap analysis is a generic listicle. + +### Outfits + +Named combinations assembled from catalogued items, so a look you worked out once survives past the morning you worked it out. + +### Gaps + +*What's missing* — the items that would expand your combinations most, ranked, each annotated with how many of your existing items it would pair with. With no AI connected it falls back to a fixed capsule-wardrobe list, so the screen is useful on a fresh install with zero credentials. + +### Settings -## Architecture +A style profile (body type, style, lifestyle, age range) that feeds the AI prompts, an on-device AI toggle, and the two credential sheets described below. -MVI + Repository pattern with no Use Cases layer. Business logic lives in Repository implementations. See [ARCHITECTURE.md](./ARCHITECTURE.md) for full details. +--- -## Tech Stack +## How the YouCam integration works -| Library | Purpose | +Worn talks to Perfect Corp's YCE server-to-server API at `https://yce-api-01.perfectcorp.com` directly from shared Kotlin — no SDK, no backend of our own. The whole flow lives in [`YouCamApiClient.kt`](shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiClient.kt). + +1. **Authenticate** — `POST /s2s/v1.0/client/auth` +2. **Request presigned upload slots** — `POST /s2s/{version}/file/{feature}`, then `PUT` the raw JPEG bytes for both the person and the garment +3. **Create the task** — `POST /s2s/{version}/task/{feature}` +4. **Poll** — `GET /s2s/{version}/task/{feature}/{taskId}`, every 2s, up to 60 attempts +5. **Download** the result and hand back raw JPEG bytes + +**Auth is RSA, not a bearer secret.** The `id_token` is `client_id=×tamp=` encrypted with the user's public key under RSA/PKCS#1 v1.5 and Base64'd; the returned access token is cached for its 2h TTL with a 5-minute refresh margin. Doing that in shared code needed an `expect/actual` pair — and the two platforms disagree about key formats: + +- **Android** ([`RsaEncryptor.android.kt`](shared/src/androidMain/kotlin/com/github/worn/util/crypto/RsaEncryptor.android.kt)) — JCA, `RSA/ECB/PKCS1Padding` with `X509EncodedKeySpec`, which consumes the X.509 SPKI key the portal issues as-is. +- **iOS** ([`RsaEncryptor.ios.kt`](shared/src/iosMain/kotlin/com/github/worn/util/crypto/RsaEncryptor.ios.kt)) — Security framework. `SecKeyCreateWithData` wants a PKCS#1 `RSAPublicKey`, *not* SPKI, so this file walks the ASN.1 TLV structure by hand (`stripSpkiHeader`) to unwrap the inner key before importing it. + +**Two endpoint families, routed by garment category.** Tops, bottoms, and full outfits go to `v2.0` / `cloth-v3` with `garment_category` set to `upper_body`, `lower_body`, or `full_body`; shoes go to `v1.0` / `shoes`, which takes no garment category. One `GarmentCategory` enum drives both the chip the user taps and the endpoint selection. + +HTTP failures are mapped to messages a non-technical user can act on — 401/403 to "check your credentials", 429 to "wait and try again", 5xx to "try again later" — and credentials are never written to logs, only their lengths. + +| File | Role | |---|---| -| Kotlin Multiplatform | Shared business logic (Android + iOS) | -| Compose Multiplatform | Shared UI (Android) | -| kotlinx-coroutines | Async / Flow | -| Ktor | HTTP client (Claude API) | -| kotlinx-serialization | JSON | -| SQLDelight | Local database | -| Koin | Dependency injection | +| [`YouCamApiClient.kt`](shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiClient.kt) | Auth, upload, task, poll, download; token cache; error mapping | +| [`YouCamApiModels.kt`](shared/src/commonMain/kotlin/com/github/worn/data/source/remote/YouCamApiModels.kt) | Request/response DTOs | +| [`TryOnRepositoryImpl.kt`](shared/src/commonMain/kotlin/com/github/worn/data/repository/TryOnRepositoryImpl.kt) | Loads the saved model photo, orchestrates the call, returns `Result` | +| [`RsaEncryptor.kt`](shared/src/commonMain/kotlin/com/github/worn/util/crypto/RsaEncryptor.kt) | `expect` declaration + the two platform actuals | + +--- + +## Setup — bring your own keys + +No credentials are baked into the build. Every AI feature is opt-in, and the app is fully usable as a plain wardrobe catalog with none of them. + +| Feature | Credential | Where | +|---|---|---| +| Virtual try-on | YouCam `client_id` + `client_secret` from [yce.perfectcorp.com](https://yce.perfectcorp.com) | Settings → AI Features → YouCam Try-On | +| Auto-tagging, gaps, Try It analysis | Anthropic API key | Settings → AI Features → Claude API Key | +| Same, offline and free | none — uses the device's own model | Settings → AI Features → On-device AI | + +Two things the YouCam portal doesn't make obvious: + +- The **shorter** value is the API key (`client_id`); the **longer** one is the secret key. +- Paste the secret **without** the `-----BEGIN PUBLIC KEY-----` / `-----END PUBLIC KEY-----` lines. + +Credentials are checked against the server with a live auth handshake *before* they're saved, so a typo fails in Settings rather than halfway through your first try-on. -## Project Structure +## Privacy + +- Photos live in **app-private internal storage** — no gallery writes, no storage permission, no external app access. +- Secrets go to the **Android Keystore** (AES/GCM-encrypted file) or the **iOS Keychain** — never DataStore, never plaintext, never logged. +- All network traffic is HTTPS. +- Nothing leaves the device unless you invoke a feature backed by a credential you supplied. With on-device AI enabled, analysis never leaves the device at all. + +--- + +## Development + +### Architecture + +MVI + Repository, with no Use Cases layer — business logic lives in the repository implementations, which orchestrate the database, photo storage, and the AI/try-on clients in one testable place. ViewModels are thin: intent in, state and effects out. See [ARCHITECTURE.md](./ARCHITECTURE.md). + +### Tech stack + +| Library | Version | Purpose | +|---|---|---| +| Kotlin Multiplatform | 2.3.21 | Shared logic across Android + iOS | +| Compose Multiplatform | 1.11.1 | Android UI (iOS is native SwiftUI) | +| kotlinx-coroutines | 1.11.0 | Async / Flow | +| Ktor | 3.5.1 | HTTP client (Claude + YouCam) | +| kotlinx-serialization | 1.11.0 | JSON | +| SQLDelight | 2.3.2 | Local database | +| Koin | 4.2.2 | Dependency injection | +| Coil | 3.5.0 | Image loading | +| AndroidX DataStore | 1.2.1 | Key-value storage (non-secret) | +| ML Kit GenAI Prompt | 1.0.0-beta2 | On-device AI | +| ML Kit Subject Segmentation | 16.0.0-beta1 | Background removal | +| Turbine · MockK · ktor-client-mock | 1.2.1 · 1.14.11 | Testing | +| Detekt | 2.0.0-alpha.5 | Static analysis | + +AGP 9.3.1 · `minSdk` 29 · `targetSdk` 36 · JDK 17+. + +### Project structure ``` root/ -├── composeApp/ # Android app entry point + Compose UI -├── iosApp/ # iOS app entry point (Xcode/SwiftUI) -└── shared/ # KMP shared module - ├── commonMain/ # Domain, data, and presentation layers - ├── androidMain/# Android platform implementations - └── iosMain/ # iOS platform implementations +├── composeApp/ # Android application module (Compose UI) +├── iosApp/ # iOS app (Xcode/SwiftUI) + WornShareExtension +├── shared/ # KMP shared module +│ └── src/ +│ ├── commonMain/ +│ │ ├── domain/{model,repository} +│ │ ├── data/ +│ │ │ ├── repository/ # business logic +│ │ │ └── source/{local,remote,ai,image} +│ │ ├── presentation/viewmodel/ # MVI +│ │ ├── util/{secret,image,crypto} +│ │ ├── di/ +│ │ └── sqldelight/ # .sq schemas +│ ├── androidMain/ # Keystore, ML Kit, OkHttp, JCA +│ ├── iosMain/ # Keychain, Darwin, Security framework +│ ├── commonTest/ # ViewModel tests + fakes +│ └── androidHostTest/ # repository & client tests +├── journeys/ # XML end-to-end journey specs +├── design/ # design source + persona +└── screenshots/ ``` -## Build & Run - -### Android +### Build & run ```shell +# Android debug APK ./gradlew :composeApp:assembleDebug + +# Shared module tests +./gradlew :shared:allTests + +# Static analysis +./gradlew detekt ``` -Or use the run configuration in Android Studio / Fleet. +For iOS, open `iosApp/` in Xcode and run. -### iOS +End-to-end UI journeys live in [`journeys/`](journeys/README.md) as XML specs driven by the `android` CLI. -Open `iosApp/` in Xcode and run, or use the KMP run configuration in Fleet. +--- ## Buy Me New Socks diff --git a/screenshots/gaps.png b/screenshots/gaps.png new file mode 100644 index 0000000..34ddd39 Binary files /dev/null and b/screenshots/gaps.png differ diff --git a/screenshots/outfits.png b/screenshots/outfits.png new file mode 100644 index 0000000..32a650c Binary files /dev/null and b/screenshots/outfits.png differ diff --git a/screenshots/settings.png b/screenshots/settings.png new file mode 100644 index 0000000..124facd Binary files /dev/null and b/screenshots/settings.png differ diff --git a/screenshots/try_it.png b/screenshots/try_it.png new file mode 100644 index 0000000..7eea72e Binary files /dev/null and b/screenshots/try_it.png differ diff --git a/screenshots/try_on.mp4 b/screenshots/try_on.mp4 new file mode 100644 index 0000000..1dfa516 Binary files /dev/null and b/screenshots/try_on.mp4 differ diff --git a/screenshots/wardrobe.png b/screenshots/wardrobe.png new file mode 100644 index 0000000..5088082 Binary files /dev/null and b/screenshots/wardrobe.png differ