# Yorvana — Architecture Decision Records (ADR)

> **Development cycle stage:** Step 2 of 3 — ADR
> **Spec reference:** `docs/specs.md`

---

## ADR-001: UI Framework & Platform Strategy

**Status:** Accepted

### Context
The app targets Android first, with iOS and Web as future possibilities (speculative, no firm timeline). A core product goal is that the app feels native and performant — "indistinguishable from a platform-native app." This includes using native controls, native scroll physics, and following each platform's design language.

### Decision
**Native Kotlin + Jetpack Compose for the Android MVP.**

Each future platform will get its own native implementation:
- iOS → Swift + SwiftUI + Human Interface Guidelines
- Web → separate (technology TBD when relevant)

No cross-platform framework (Flutter, React Native, KMP UI) will be used.

### Rationale
- Cross-platform frameworks (Flutter included) render their own widgets. Even the best implementations diverge from platform feel in subtle but perceptible ways: scroll physics, text selection handles, date pickers, bottom sheets, and OS-level animations.
- This app explicitly requires native controls and platform design language — a constraint that cross-platform frameworks cannot fully satisfy.
- Jetpack Compose is Android's modern, declarative, idiomatic UI toolkit. Material Design 3 is its first-class design system.
- The app's domain is not complex enough that sharing UI code across platforms is a meaningful productivity win — the forms, lists, and detail views are straightforward to implement natively on each platform.

### Consequences
- Future iOS implementation will be a separate codebase (Swift + SwiftUI). This is intentional and acceptable.
- Business logic (data models, file I/O, validation) may be candidates for Kotlin Multiplatform extraction later — this is a future option, not a current requirement.
- The data format (ADR-002) is platform-agnostic, ensuring any future platform can read the same vault.

### Alternatives Considered
- **Flutter**: Excellent performance and true cross-platform code sharing, but renders its own widget engine — cannot use native controls or fully match platform design language.
- **React Native**: JS bridge overhead, furthest from native feel.
- **Kotlin Multiplatform (KMP) with shared UI via Compose Multiplatform**: Still not native controls on iOS. Same fundamental problem.

---

## ADR-002: Data Storage Format

**Status:** Accepted

### Context
A defining product constraint (NFR-2, FR-D1) is that all data must be stored in an open, portable, human-readable format on the user's filesystem. Users must be able to read, back up, and migrate their data without the app. The format must be text-based, not binary.

### Decision
**Plain JSON files.** One file per vehicle profile, one file per service record.

### Rationale
- JSON is universally readable without special tooling. Any text editor, any platform, any programming language can parse it.
- JSON is unambiguous — no YAML edge cases (implicit type coercion, date parsing quirks, significant whitespace).
- JSON maps directly to the app's data model (structured records, not prose), making serialization/deserialization trivial and lossless.
- Human-editable: a user comfortable with text files can manually correct or augment their data.
- Markdown with YAML frontmatter (Obsidian-style) is better suited for note-heavy content where the body is free text. Service records are structured data — JSON is the more honest fit.

### Schema

**`vehicle.json`**
```json
{
  "schemaVersion": 1,
  "id": "string (slug, e.g. toyota-corolla-2019)",
  "nickname": "string",
  "make": "string",
  "model": "string",
  "year": 2019,  // integer | null
  "vin": "string | null",
  "licensePlate": "string | null",
  "odometerUnit": "km | mi",
  "createdAt": "ISO 8601 datetime",
  "lastServiceDate": "ISO 8601 date | null",  // cached — updated on record write/delete (see ADR-008)
  "recordCount": 0                            // cached — updated on record write/delete (see ADR-008)
}
```

**`{record-id}.json`** (UUID v4 filename)
```json
{
  "schemaVersion": 1,
  "id": "string (UUID v4)",
  "vehicleId": "string",
  "date": "ISO 8601 date (YYYY-MM-DD)",
  "odometer": 45230,
  "category": "string (category id)",
  "performer": "string (free text or 'self')",
  "cost": 120.50,
  "currency": "string (ISO 4217 code, e.g. USD)",
  "notes": "string | null",
  "attachments": ["filename1.pdf", "photo1.jpg"],
  "createdAt": "ISO 8601 datetime",
  "updatedAt": "ISO 8601 datetime"
}
```

**`categories.json`**
```json
{
  "schemaVersion": 1,
  "custom": [
    { "id": "string", "label": "string", "createdAt": "ISO 8601 datetime" }
  ]
}
```

**`settings.json`** (app-private storage, not in vault)
```json
{
  "defaultOdometerUnit": "km | mi",
  "defaultCurrency": "string (ISO 4217)",
  "vaultPath": "string (absolute path)"
}
```

### Consequences
- A `"schemaVersion"` field is included in all data files to support future migrations.
- The app must read unknown fields without error (forward compatibility: if a newer app version adds fields, an older version reading those files must not crash).
- If a file fails JSON parsing, it is surfaced as an error entry in the UI — never silently dropped or overwritten.

### Alternatives Considered
- **YAML**: More human-writable, but has known parsing footguns (implicit type coercion, date literals). Extra dependency, no benefit for machine-generated files.
- **SQLite**: Fast queries, but binary — violates the human-readable constraint. Export-to-JSON could satisfy portability but adds friction.
- **Markdown + YAML frontmatter**: Well-suited for note content. This app's records are structured, not prose-heavy — JSON is a cleaner fit.

---

## ADR-003: Vault Folder Structure

**Status:** Accepted

### Context
All data is stored as files in a user-accessible folder (the "vault"). The structure must be intuitive to a user browsing with a file manager, and copying the entire folder must yield a fully portable backup.

### Decision

```
Yorvana/                          ← vault root (user-configurable)
├── categories.json
└── vehicles/
    └── {vehicle-slug}/               ← e.g. my-civic/
        ├── vehicle.json
        └── records/
            ├── {uuid}.json           ← one file per service record
            └── {uuid}/               ← only exists if record has attachments
                ├── receipt.pdf
                └── photo.jpg
```

App-level settings (`vaultPath`, `defaultOdometerUnit`, `defaultCurrency`) are stored in Android's app-private DataStore — not in the vault — so the app can locate the vault on launch even before reading any vault files.

**Vehicle folder naming:** slugified `nickname` in lowercase kebab-case (e.g. `my-civic`, `work-truck`). If a collision occurs (two vehicles with identical nickname slugs), a numeric suffix is appended: `my-civic-2`. The slug is generated once at creation and never changes, even if the nickname is later edited.

**Record filenames:** UUID v4 (e.g. `3f2504e0-4f89-11d3-9a0c-0305e82c3301.json`). UUIDs are used here (not slugs) because records have no natural human-readable identifier, and uniqueness is critical.

**Attachment folder:** Created only when the record has at least one attachment. Named identically to the record UUID (no `.json` extension). Keeps record JSON and its attachments co-located.

### Rationale
- Vehicle slug folders make the vault immediately navigable to a human: a user can open their file manager and understand the structure without documentation.
- One file per record (vs. one big `records.json` per vehicle) means: no risk of a large monolithic file, easy to diff in version control, and deleting a record is a simple file deletion.
- Attachment subfolder co-located with the record: delete the record folder and all its attachments are gone — no orphaned files.

### Consequences
- Vehicle slug must be generated at vehicle creation and must not change when the user renames the vehicle (the nickname is display-only; the folder name is stable).
- The app must handle slug collisions at creation time.
- The `vehicle.json` `id` field must match the folder name for consistency.

---

## ADR-004: Odometer Unit — Scope

**Status:** Accepted

### Context
The spec (FR-S1) leaves open whether odometer unit is app-level only or per-vehicle overridable.

### Decision
**Per-vehicle, with an app-level default.**

- App settings store a `defaultOdometerUnit` (km or mi).
- Each vehicle's `vehicle.json` stores its own `odometerUnit`.
- When creating a new vehicle, `odometerUnit` is pre-filled from the app default.
- All record odometer values are stored in the vehicle's unit — no conversion is stored.

### Rationale
A user might own a US-spec vehicle (miles) and a European vehicle (km). Per-vehicle unit is the correct model. The app-level default covers the common case (user sets it once and never thinks about it again) while not blocking the edge case.

### Consequences
- When displaying a record, the app reads the parent vehicle's `odometerUnit` to display the correct unit label.
- Unit conversion between vehicles is out of scope.

---

## ADR-005: Data Folder Location Change

**Status:** Accepted

### Context
The spec (FR-S3) requires the user to be able to change the vault root folder. The behavior when they do so must be defined.

### Decision
When the user changes the vault location:
1. **Offer a choice**: "Move my data to the new location" or "Start fresh at the new location (keep old data where it is)."
2. If "Move": the app copies the entire vault to the new path, verifies the copy succeeded, then deletes the original.
3. If "Start fresh": the app simply switches the active path. The old vault is left untouched.
4. On failure during move: abort, leave original intact, show error.

### Rationale
Never silently abandon data. The user must make an explicit choice. The copy-then-delete strategy (not move) ensures the original is intact until the copy is verified.

### Consequences
- A "move" operation on a large vault with many attachments may take time — a progress indicator is required.
- App-level settings (the vault path pointer) are stored in Android DataStore (app-private), not in the vault itself, so the app can bootstrap on launch.

---

## ADR-006: Data Integrity — Manual Edits & Schema Evolution

**Status:** Accepted

### Context
Because data is stored as plain JSON on the user's filesystem, users may manually edit files. The app must handle this gracefully.

### Decision
- **Defensive reads**: The app validates JSON structure on every read. Unknown fields are ignored (forward compatibility). Missing optional fields use defaults.
- **Graceful degradation**: If a file fails to parse, the affected record or vehicle is shown in the UI as an error entry rather than being silently dropped or crashing the app.
- **No silent overwrites**: The app never rewrites a file unless the user explicitly saves a change in the UI.
- **Schema versioning**: All data files include a `"schemaVersion": 1` field. Future schema changes increment this version. The app includes migration logic to upgrade older schemas on read.
- **Round-trip fidelity**: If the user adds extra fields manually, the app preserves them on the next write where feasible.

### Consequences
- All deserialization must use lenient/null-safe parsing — strict parsers that throw on missing fields are not acceptable.
- Error entries in the UI need a clear visual treatment and a path to resolution (e.g. a way to open the file in a file manager).

---

## ADR-007: In-App Image Viewer for Image Attachments

**Status:** Accepted

### Context
Service records support image attachments. AC-A1 originally specified that tapping any attachment opens the system viewer. Image files benefit from in-app viewing: the system viewer varies by device and installed apps, offers no guaranteed zoom/pan controls, and context-switches the user out of the app.

### Decision
**Image attachments open in a dedicated full-screen in-app viewer (`ImageViewerScreen`). Non-image attachments (PDF, etc.) continue to open via the system viewer (`Intent.ACTION_VIEW`).**

The in-app viewer provides:
- Pinch-to-zoom (1×–5×)
- Pan with bounds clamping (image cannot slide off-screen)
- Animated double-tap: zoom to 2× centered on the tapped point; tap again to return to 1×

### Rationale
- Consistent UX regardless of what apps the user has installed.
- Pinch, pan, and double-tap are expected interactions when viewing photos on mobile; the system viewer may not provide them.
- Non-image files (PDFs, documents) are better served by the system viewer, which can offer app-specific features (annotation, print, etc.).

### Consequences
- `AttachmentMeta.isImage` determines routing: `true` → `ImageViewerScreen`; `false` → `Intent.ACTION_VIEW` via `FileProviderHelper`.
- The in-app viewer is intentionally minimal — viewer only, no editing or sharing actions.

---

## ADR-008: Denormalized Vehicle Stats for Fast List Load

**Status:** Accepted

### Context
The vehicle list screen (`FR-V2`) requires `lastServiceDate` and `recordCount` to render each card. Without caching, `loadAll()` must read every record file across all vehicles to compute these values — O(N records) I/O on every navigation to the vehicle list. With a modest vault (7 vehicles, 60+ records) this was ~68 file reads per load, causing a visible blank-screen pop-in before content appeared.

### Decision
**Denormalize `lastServiceDate` and `recordCount` into `vehicle.json`.** The app updates these cached fields after every record write or delete (via `VaultStorageImpl.updateVehicleMeta()`). The vehicle list only reads `vehicle.json` files — O(N vehicles), not O(N records).

### Rationale
- Vehicle list loads are frequent (every navigation to the home screen). Record writes/deletes are infrequent. The cost of recomputation moves from "every load" to "every mutation."
- The two fields are derivable from the records directory — they are a performance cache, not source-of-truth data. If they go stale (e.g. due to a manual edit outside the app), they self-correct on the next record write to that vehicle.
- Mutating `vehicle.json` on record write is consistent with ADR-006's "no silent overwrites" principle being limited to user-visible data — `lastServiceDate`/`recordCount` are computed metadata, not user-authored content.

### Update logic
`VaultStorageImpl.updateVehicleMeta(vehicleId)`:
1. Re-read the vehicle.
2. List all record IDs for that vehicle.
3. Read each record to find the maximum date.
4. Write back `vehicle.copy(lastServiceDate = maxDate, recordCount = count)`.

This runs in a nested `runCatching` — a failure to update metadata does not fail the outer record write/delete.

### No migration required
`lastServiceDate` defaults to `null` and `recordCount` defaults to `0` — old vault files load safely. Fields self-populate on the next record write per vehicle.

### Consequences
- `vehicle.json` gains two new optional fields (see ADR-002 schema).
- `VehicleRepositoryImpl.loadAll()` no longer reads record files; it uses the cached fields directly.
- `RecordRepositoryImpl` holds a reference to `VehicleRepository` and calls `refresh()` after mutations so the vehicle list flow re-fires with updated stats.
- Vault structure and file format remain unchanged.

---

## ADR-009: Testing Strategy (Functional E2E and Visual Regression)

**Status:** Accepted

### Context
To ensure high quality and prevent regressions without relying on slow and sometimes unstable emulators during local development (especially in containerized environments like Distrobox), a robust testing strategy is required.

### Decision
**Adopt a hybrid testing approach using Robolectric for local functional/visual tests, and Gradle Managed Devices for CI.**

- **Functional E2E Tests**: Use Robolectric to run Compose UI tests directly on the JVM locally. This allows for fast, interactive E2E testing (clicks, navigation, state changes) without the overhead or hardware requirements of an emulator.
- **Visual Regression Tests**: Use Roborazzi with a custom `BaseScreenshotTest` base class to capture pixel-perfect golden images (in both Light and Dark modes, and modern device sizes) directly on the JVM. Snapshots are stored adjacently to their respective test files.
- **CI Environment**: Use Gradle Managed Devices (GMD) to run emulator-based E2E verification exclusively in the GitHub Actions CI pipeline, where nested virtualization (KVM) is reliably available.

### Rationale
- Emulator-based tests (GMD) are the gold standard for E2E but can be flaky or impossible to run in certain local Linux environments (e.g., due to missing KVM or GPU driver issues).
- Robolectric + Roborazzi provides a fast, deterministic, and highly productive local development loop while maintaining high confidence.

### Consequences
- Tests are organized into the `src/test` directory (rather than `src/androidTest`) to run via Robolectric locally.
- Developers must run `./gradlew verifyRoborazziDebug` to check for visual changes and `./gradlew recordRoborazziDebug` to update golden images when UI changes are intentional.

---

## ADR-012: Freemium Paywall — One Vehicle Free, Lifetime Premium

**Status:** Accepted

### Context
The app needs a monetization strategy. The core value proposition is data ownership and portability — the app should remain useful without paying. However, managing multiple vehicles is a natural upgrade path that aligns user value with willingness to pay.

The model must be simple: no subscriptions, no recurring fees, no feature degradation for paying users who lose internet access.

### Decision
**Freemium with a one-time lifetime in-app purchase via Google Play Billing.**

- **Free tier:** Full read/write functionality for exactly 1 vehicle (records, attachments, categories, settings — everything). If the vault contains >1 vehicle, the app enters read-only mode (see Read-Only Enforcement below).
- **Premium tier:** Unlimited vehicles. Unlocked by a single non-consumable purchase (`INAPP` product type, product ID: `premium_lifetime`).
- **Gate location:** `VehicleListViewModel` checks `vehicles.size >= 1 && !isPremium` when the user taps "Add Vehicle." If gated, an upgrade dialog is shown.
- **Post-purchase UX:** After a successful purchase, the upgrade dialog auto-dismisses and the user is navigated directly to the Add Vehicle screen.

### Billing Architecture

```
BillingManager (interface — for testability)
  └── BillingManagerImpl (Google Play Billing 7.x)
        ├── isPremium: StateFlow<Boolean>      // public
        ├── startConnection()                  // public — called in MainActivity.onCreate
        ├── launchPurchaseFlow(activity)       // public
        ├── restorePurchases()                 // public — explicit re-query by user
        ├── endConnection()                    // public — called in MainActivity.onDestroy
        └── queryPurchases()                   // internal — called by startConnection & restorePurchases
```

**Premium state lifecycle:**
1. On app start, `BillingManagerImpl` initializes `isPremium` from the cached preference field `isPremiumCached` (backed by the DataStore key `is_premium_cached` in `AppPreferences` / `AppPreferencesImpl`). This provides instant, flicker-free state before BillingClient connects.
2. `MainActivity.onCreate()` calls `startConnection()`, which connects to BillingClient and calls `queryPurchases()`.
3. `queryPurchases()` checks for the `premium_lifetime` product in `INAPP` purchases. If found and acknowledged, `isPremium` flips to `true` and the DataStore cache is updated. If not found **and billing is confirmed available** (i.e., `BillingClient` connected successfully), `isPremium` is set to `false` and the cache is updated.
4. If BillingClient fails to connect (no Play Store, network down, sideloaded APK), the DataStore-cached value is used as-is — `isPremium` is **not** cleared to `false`. **Trust the cache.**

**Purchase flow:**
1. User taps "Add Vehicle" with 1 vehicle → upgrade dialog shown.
2. User taps "Upgrade" → `launchPurchaseFlow(activity)` called.
3. `BillingManagerImpl` queries product details, then launches the Google Play purchase sheet.
4. `onPurchasesUpdated` callback: if successful, acknowledge the purchase, update `isPremium` to `true`, persist to DataStore.
5. ViewModel observes `isPremium` flip → auto-dismisses dialog, navigates to Add Vehicle.

**Restore flow:**
1. User goes to Settings → Premium → "Restore purchases."
2. `queryPurchases()` is called explicitly.
3. Result shown via snackbar: "Purchase restored" or "No previous purchase found."

### Read-Only Enforcement

Free-tier users may end up with a vault containing >1 vehicle in several ways: selecting an existing vault with multiple vehicles, vehicles being added externally (file manager, another app instance), or a premium user losing premium status (refund). Rather than blocking access to the user's own data, the app enters **read-only mode**.

**Detection:** `VehicleListViewModel` (and other ViewModels) observe the vehicle count via the repository flow and `BillingManager.isPremium`. A derived `isReadOnly: Boolean` state is computed as `vehicleCount > 1 && !isPremium`.

**Enforcement (UI-level):**
- Mutating actions are blocked while in read-only mode: create/edit/delete operations do not proceed; FABs may be hidden, edit buttons disabled, and swipe-to-delete disabled
- On surfaces where **Add Vehicle** is shown, it is **gated** rather than non-interactive: tapping it launches the upgrade/purchase prompt instead of allowing vehicle creation for a free user over the limit
- A persistent **warning banner** is shown at the top of every screen (vehicle list, vehicle detail, record list, record detail, settings)
- The banner explains the free-tier limit and includes a button to launch the upgrade/purchase flow
- Read-only mode is lifted immediately when `isPremium` flips to `true` (purchase/restore) or vehicle count drops to ≤1 (external removal) — no app restart needed

**Design rationale:** Data ownership is a core principle. The user's data is always accessible for reading. The gate prevents *editing* in excess of the free tier, not *viewing*. This avoids the complexity of HMAC manifests or signed vehicle lists while still enforcing the paywall meaningfully.

### Offline & Edge Cases

| Scenario | Behavior |
|---|---|
| Offline, previously purchased | DataStore cache = `true` → premium access preserved |
| Offline, never purchased | DataStore cache = `false` → limit enforced |
| Sideloaded, no Play Store | Same as offline — trust the cache |
| Refund via Google Play | Next successful `queryPurchases()` returns empty → `isPremium` flips to `false`, cache updated. If >1 vehicle → read-only mode |
| User deletes vehicles to stay under 1 | Allowed — gate only checks current count, not historical |
| Free user selects vault with >1 vehicle | Vault loads, app enters read-only mode, warning banner shown on all screens |
| Free user's vault gains external vehicle (>1 total) | Detected on next vehicle list refresh → read-only mode, warning banner shown |
| Premium user downgrades/refund with >1 vehicle | Read-only mode until re-purchase or vehicle count reduced to ≤1 |
| Free user in read-only, vehicles removed externally to ≤1 | Read-write restored on next vault refresh |

### Integration Points

| Component | Change |
|---|---|
| `AppPreferences` / `AppPreferencesImpl` | Keep `AppPreferencesStore` exposing `Flow<AppPreferences>`; add `isPremiumCached: Boolean` field and `setIsPremiumCached(Boolean)` using the existing DataStore instance |
| `YorvanaApplication` | Add `billingManager: BillingManager` property, create in `initDependencies()` |
| `MainActivity` | Call `startConnection()` / `endConnection()` tied to Activity lifecycle |
| `VehicleListViewModel` | Add `billingManager` param, gate `AddVehicle` event, add upgrade dialog state/events, compute `isReadOnly` from vehicle count + premium status |
| `VehicleListScreen` | Show `UpgradeDialog`, handle `LaunchPurchaseFlow` effect, show `ReadOnlyWarningBanner` when read-only, disable add/delete actions |
| `SettingsViewModel` | Add `billingManager` param, expose `isPremium` state, handle upgrade/restore events |
| `SettingsScreen` | Add Premium section (upgrade button or "active" status + restore link), show `ReadOnlyWarningBanner` when read-only |
| Vehicle detail / record ViewModels | Observe `isReadOnly`, disable edit/delete actions when true |
| Vehicle detail / record screens | Show `ReadOnlyWarningBanner`, hide/disable mutation UI elements when read-only |
| `ViewModelFactory` | Pass `billingManager` to `VehicleListViewModel`, `SettingsViewModel`, and other affected ViewModels |

### New Files
- `data/billing/BillingManager.kt` — interface
- `data/billing/BillingManagerImpl.kt` — Google Play Billing wrapper
- `ui/components/UpgradeDialog.kt` — upgrade prompt composable
- `ui/components/ReadOnlyWarningBanner.kt` — persistent banner shown on all screens when free-tier vault has >1 vehicle

### Rationale
- **One-time purchase over subscription:** Aligns with the app's philosophy of user ownership. No recurring cost, no risk of losing access. Simpler billing logic (no subscription state management, grace periods, or renewal failures).
- **1-vehicle free tier:** Generous enough that single-vehicle users get full value (builds goodwill and reviews), but multi-vehicle users have a clear reason to pay.
- **Interface-based BillingManager:** Enables unit testing of all gate logic without Google Play dependencies. ViewModels depend on the interface, not the implementation.
- **DataStore cache for offline:** Avoids blocking users who lose internet or sideload the app. The cache is updated whenever BillingClient connects, so it stays in sync under normal conditions.
- **Gate at ViewModel, not repository:** The repository should remain a pure data layer. The business rule "max 1 vehicle for free users" is presentation/feature logic, not data logic.

### Alternatives Considered
- **Subscription model:** More revenue potential, but contradicts the app's ethos of ownership without ongoing costs. Also adds complexity (grace periods, billing cycles, state management).
- **Feature-gating (e.g. free tier has no attachments):** Degrades the experience for free users. A vehicle-count gate is cleaner — free users get the full experience, just for fewer vehicles.
- **Server-side purchase verification:** Overkill for a client-side-only app with no backend. Google Play's on-device verification is sufficient for a trust-the-client model.
- **Gate at repository level:** Would conflate data access with business rules. The ViewModel is the correct layer for feature gating.

### Consequences
- Google Play Billing library (`billing-ktx 7.x`) becomes a new dependency.
- The `premium_lifetime` product must be created in Google Play Console before release.
- ProGuard: `billing-ktx` ships its own consumer rules; no manual configuration expected.
- Testing: BillingClient is mocked in unit tests via the `BillingManager` interface. Real purchase flow requires license testers on a physical device.

---

## Summary Table

| ADR | Decision |
|-----|----------|
| ADR-001 | Native Kotlin + Jetpack Compose (Android MVP). Future platforms get native implementations (Swift/SwiftUI for iOS). |
| ADR-002 | Plain JSON files. One file per vehicle, one file per record. Schema versioned from day one. |
| ADR-003 | Vault: `Yorvana/vehicles/{nickname-slug}/records/{uuid}.json`. Slug is derived from nickname at creation and never changes. Attachments in `{uuid}/` subfolder. App settings in Android DataStore. |
| ADR-004 | Odometer unit is per-vehicle with an app-level default. |
| ADR-005 | Folder change: offer migrate or start fresh. Copy-verify-delete strategy for migration. |
| ADR-006 | Defensive reads, graceful degradation on malformed files, schema versioning, no silent overwrites. |
| ADR-007 | Image attachments open in the in-app full-screen viewer (pinch/pan/double-tap). Non-image attachments delegate to the system viewer. |
| ADR-008 | `lastServiceDate` and `recordCount` are cached in `vehicle.json` and updated on every record write/delete. Vehicle list load is O(N vehicles), not O(N records). |
| ADR-009 | Hybrid testing strategy: Robolectric for fast local functional E2E tests, Roborazzi for visual regression, and Gradle Managed Devices for CI. |
| ADR-012 | Freemium paywall: 1 vehicle free, unlimited via one-time lifetime Google Play purchase. BillingManager interface wraps BillingClient with DataStore cache for offline resilience. Gate at ViewModel layer. Vaults with >1 vehicle on free tier enter read-only mode with persistent warning banner. |
| ADR-013 | User Feedback (Sentry) uses a scoped, one-shot initialization if crash reporting is disabled. This is explicitly disclosed to the user in the UI at the point of submission. |

---

## ADR-013: User Feedback Opt-in Policy

**Status:** Accepted

### Context
The "Report a Bug" feature uses Sentry's User Feedback API. Sentry is typically initialized only if the user opts into crash reporting (ADR-006 logic). However, users might want to send a manual report even if they have background telemetry disabled.

### Decision
Allow User Feedback submission regardless of the global crash reporting preference, using a "lazy initialization" strategy:
1. If Sentry is not initialized, perform a scoped initialization specifically for the feedback event.
2. Explicitly disclose this behavior in the UI when the user has crash reporting disabled.
3. Call `Sentry.close()` immediately after the submission attempt to ensure no persistent background telemetry remains active.

### Consequences
- Users can report bugs without enabling persistent crash reporting.
- Privacy is maintained via explicit one-shot initialization and immediate teardown.
- The UI must react to the `crashReportingEnabled` preference to show the disclosure.
