# Paywall / Premium Feature — Implementation Plan

> **Spec reference:** `docs/specs.md` §5.7
> **ADR reference:** `docs/adr.md` ADR-012
> **Related:** `docs/plan-smoke-tests.md` (S18–S20 added by Phase 6 below)

## Overview

Freemium model: 1 vehicle free with full read/write access. Unlimited vehicles via a one-time lifetime Google Play purchase (`premium_lifetime`, `INAPP`). When a free-tier user has more than 1 vehicle in their vault, the entire app enters **read-only mode** with a persistent warning banner on every paywalled screen.

**Read-only rule:** `vehicleCount > 1 && !isPremium`

**Architecture summary:**
- A single `BillingManager` wraps Google Play Billing 7.x (purchase, query, restore, refresh).
- A single `AppGate` derives `isReadOnly` and exposes `isPremium` — every read-only check across the app reads from this one source.
- A single `PaywallScaffold` Composable hosts the read-only banner — every paywalled screen wraps its content in it.
- Premium status is cached in DataStore so cold starts and offline use don't flicker.
- A debug-only developer panel in Settings overrides billing state at runtime to allow testing free/paid/upgrade paths on a debug APK without round-tripping through Google Play.

### Phasing

Each phase below is a self-contained, individually-reviewable chunk. Tests for each layer live in that layer's phase, not batched into a separate "testing" phase.

| Phase | Title | Reviewable deliverable |
|---|---|---|
| 1 | Foundation: prefs + billing types | App compiles with new DataStore keys, billing dependency, and pure data types. No wiring. |
| 2 | BillingManager + Debug Override | Billing logic exists in isolation, fully unit-tested via fake `BillingClientProvider`. Not yet wired into app. |
| 3 | App wiring + AppGate | `BillingManager`, `AppGate`, lifecycle hooks all wired in `YorvanaApplication` and `MainActivity`. No UI consumes them yet. |
| 4 | ViewModel gating | All 7 VMs read from `AppGate`; `VehicleListVM` has dialog + auto-navigate; `SettingsVM` has premium + restore. ViewModelFactory updated. |
| 5 | UI components + screen integration | `ReadOnlyWarningBanner`, `UpgradeDialog`, `PaywallScaffold`. All 7 paywalled screens swapped to `PaywallScaffold`. Settings Premium section + debug developer panel. |
| 6 | Smoke test coverage | `SmokeTest.kt` helpers (`setBillingOverride`, `addVehicleForTesting`); scenarios S18–S20; `gmd_smoke.ec` baseline refreshed; `docs/plan-smoke-tests.md` updated. |
| 7 | On-device verification + Play setup | Manual run-throughs on Track A (debug override) and Track B (real Play with license tester); Play Console one-time setup. |

**Sequencing notes:**
- Phases 1–5 can land as separate PRs but must merge in order.
- Phase 6 depends on Phase 3 (specifically `DebugBillingOverride` being wired). Don't start before then.
- Phase 7 is post-merge validation; no code changes.

---

## Phase 1: Foundation — Preferences + Billing Types

**Goal:** New types compile, DataStore keys exist, billing dependency added. Nothing is wired yet — this phase is intentionally inert.

### Step 1.1: Add `isPremiumCached` to `AppPreferences`

**File:** `data/preferences/AppPreferences.kt`
- Add field: `isPremiumCached: Boolean = false`
- On `AppPreferencesStore`: add `suspend fun setIsPremiumCached(value: Boolean)`

### Step 1.2: Add debug billing override key

**File:** `data/preferences/AppPreferences.kt` + `AppPreferencesImpl.kt`
- Add field `debugBillingOverrideMode: String = "NONE"` to `AppPreferences`.
- Add setter on `AppPreferencesStore`. Used only when `BuildConfig.DEBUG`; harmless on release.

### Step 1.3: Implement keys in `AppPreferencesImpl`

**File:** `data/preferences/AppPreferencesImpl.kt`
- Add `val IS_PREMIUM_CACHED = booleanPreferencesKey("is_premium_cached")`.
- Add `val BILLING_OVERRIDE_MODE = stringPreferencesKey("debug_billing_override_mode")`.
- Implement setters and update the `preferences` Flow mapping.

### Step 1.4: Add Google Play Billing dependency

**File:** `gradle/libs.versions.toml`
- Add version: `billing = "7.1.1"` (or latest 7.x).
- Add library: `androidx-billing = { module = "com.android.billingclient:billing-ktx", version.ref = "billing" }`.

**File:** `app/build.gradle.kts`
- Add: `implementation(libs.androidx.billing)`.

Verify: `./gradlew assembleDebug` compiles.

### Step 1.5: Define billing result types

**New file:** `data/billing/BillingResults.kt`

```kotlin
sealed class BillingLaunchResult {
    object Launched : BillingLaunchResult()
    object Unavailable : BillingLaunchResult()
    object AlreadyOwned : BillingLaunchResult()
    data class Error(val message: String) : BillingLaunchResult()
}

sealed class RestoreResult {
    object Restored : RestoreResult()
    object NotFound : RestoreResult()
    data class Error(val message: String) : RestoreResult()
}
```

### Step 1.6: Define `BillingManager` interface + `BillingClientProvider` seam

**New file:** `data/billing/BillingManager.kt`

```kotlin
interface BillingManager {
    val isPremium: StateFlow<Boolean>
    fun startConnection()
    fun endConnection()
    fun refresh()                                                    // called from MainActivity.onResume
    fun launchPurchaseFlow(activity: Activity): BillingLaunchResult  // not suspend
    suspend fun restorePurchases(): RestoreResult
}
```

**New file:** `data/billing/BillingClientProvider.kt`

```kotlin
fun interface BillingClientProvider {
    fun create(listener: PurchasesUpdatedListener): BillingClient
}

class DefaultBillingClientProvider(private val context: Context) : BillingClientProvider {
    override fun create(listener: PurchasesUpdatedListener): BillingClient =
        BillingClient.newBuilder(context)
            .setListener(listener)
            .enablePendingPurchases()
            .build()
}
```

The provider is the test seam: `BillingClient` is final and awkward to mock through its builder. Tests pass a fake provider.

### Phase 1 — Tests

- `AppPreferencesImplTest.kt`: round-trip both new keys; defaults to `false` / `"NONE"` when absent.

### Phase 1 — Done when

- `./gradlew assembleDebug ktlintCheck detekt test` all green.
- `BillingManager` interface + `BillingClientProvider` + `BillingResults.kt` exist; nothing implements `BillingManager` yet.

---

## Phase 2: BillingManager + Debug Override

**Goal:** Billing logic implemented and unit-tested in full isolation. Not yet wired into the application.

### Step 2.1: Create `DebugBillingOverride`

**New file:** `data/billing/DebugBillingOverride.kt`

```kotlin
class DebugBillingOverride(private val preferences: AppPreferencesStore) {
    enum class Mode { NONE, FORCE_FREE, FORCE_PREMIUM }

    val mode: StateFlow<Mode> = preferences.preferences
        .map { runCatching { Mode.valueOf(it.debugBillingOverrideMode) }.getOrDefault(Mode.NONE) }
        .stateIn(/* application scope; passed in via constructor */)

    suspend fun setMode(mode: Mode) =
        preferences.setDebugBillingOverrideMode(mode.name)
}
```

(Compiled in all build types; only consulted when `BuildConfig.DEBUG`.)

### Step 2.2: Create `BillingManagerImpl`

**New file:** `data/billing/BillingManagerImpl.kt`

**Constructor:** `clientProvider: BillingClientProvider`, `preferences: AppPreferencesStore`, `coroutineScope: CoroutineScope`, `debugOverride: DebugBillingOverride? = null`.

| Concern | Detail |
|---|---|
| Product ID | `private const val PRODUCT_ID = "premium_lifetime"` |
| `_realIsPremium` | `MutableStateFlow<Boolean>(false)`, seeded from cache via `coroutineScope.launch { _realIsPremium.value = preferences.preferences.first().isPremiumCached }` |
| `isPremium` | If `debugOverride != null` → `combine(_realIsPremium, debugOverride.mode)` mapped to `NONE → real`, `FORCE_FREE → false`, `FORCE_PREMIUM → true`, `stateIn(scope, Eagerly, false)`. Else `_realIsPremium.asStateFlow()`. |
| `startConnection()` | `clientProvider.create(purchasesUpdatedListener)` then `startConnection`. On `BillingResult.OK`, call internal `queryPurchases()`. |
| `queryPurchases()` (internal) | `billingClient.queryPurchasesAsync(INAPP)`. If `OK`: find `PRODUCT_ID`. **Only treat as premium when `purchase.purchaseState == PURCHASED`** — PENDING ignored (no acknowledge, no flip). If found+PURCHASED+unack → `acknowledgePurchase`, then flip + persist. If found+PURCHASED+ack → flip + persist. If query OK but no premium → set `false` + persist. If query NOT OK → trust cache, do nothing. |
| `refresh()` | If connected, call `queryPurchases()`. Else no-op. |
| `launchPurchaseFlow()` | If `debugOverride?.mode != NONE` → return `Launched` immediately (no real Play sheet). Else: if client not ready → `Unavailable`. Query product details; missing → `Error`. Build `BillingFlowParams`, call `launchBillingFlow`. Map result code to `Launched` / `AlreadyOwned` / `Error`. |
| `restorePurchases()` | Suspend. Calls `queryPurchases()` and inspects post-state. Returns `Restored` / `NotFound` / `Error`. |
| `endConnection()` | `billingClient.endConnection()`. |
| `PurchasesUpdatedListener` | On `OK` → iterate, ignore `PENDING`, acknowledge `PURCHASED` if not yet, update `isPremium` and persist. On `USER_CANCELED` / `ITEM_ALREADY_OWNED` → log; trust subsequent `queryPurchases()`. |

### Step 2.3: Create `FakeBillingManager`

**New file:** `app/src/test/java/com/yorvana/data/billing/FakeBillingManager.kt`

```kotlin
class FakeBillingManager : BillingManager {
    private val _isPremium = MutableStateFlow(false)
    override val isPremium: StateFlow<Boolean> = _isPremium

    var nextRestoreResult: RestoreResult = RestoreResult.NotFound
    var nextLaunchResult: BillingLaunchResult = BillingLaunchResult.Launched
    private var _launchCount = 0
    val launchCount get() = _launchCount

    fun setPremium(value: Boolean) { _isPremium.value = value }

    override fun startConnection() {}
    override fun endConnection() {}
    override fun refresh() {}
    override fun launchPurchaseFlow(activity: Activity): BillingLaunchResult {
        _launchCount++
        return nextLaunchResult
    }
    override suspend fun restorePurchases(): RestoreResult = nextRestoreResult
}
```

Used by every VM test in Phase 4 and by `MainActivityTest`.

### Phase 2 — Tests

`BillingManagerImplTest.kt` (local-only — depends on `BillingClient` class which requires Play Services; mark `@Tag("local-only")` or `*LocalTest.kt` and exclude from CI):

- Uses fake `BillingClientProvider` returning a mockk `BillingClient`.
- `isPremium` updates on valid PURCHASED query.
- **PENDING purchase does not flip `isPremium` and is not acknowledged.**
- DataStore persistence via `setIsPremiumCached()`.
- `restorePurchases` returns `Restored` / `NotFound` / `Error` for the three cases.
- `queryPurchases` preserves cached `isPremium` when `BillingResult` is not `OK`.
- `launchPurchaseFlow` returns `Unavailable` when client not ready, `Launched` for normal path.
- Debug override `FORCE_PREMIUM` short-circuits `launchPurchaseFlow` without calling `BillingClient`.
- `acknowledgePurchase` called for unacknowledged PURCHASED purchases.

### Phase 2 — Done when

- `./gradlew test` (excluding local-only tag) green.
- `./gradlew test -Plocal-only` (or local IDE run) green for `BillingManagerImplTest`.

---

## Phase 3: App Wiring + AppGate

**Goal:** Billing connection lives, premium/read-only state propagates, `MainActivity` lifecycle hooks fire. No UI surfaces consume the state yet — that's Phase 4. App is functionally unchanged from a user's perspective at the end of this phase.

### Step 3.1: Centralize read-only state — `AppGate`

**New file:** `domain/AppGate.kt`

```kotlin
class AppGate(
    vehicleRepository: VehicleRepository,
    billingManager: BillingManager,
    scope: CoroutineScope,
) {
    val isPremium: StateFlow<Boolean> = billingManager.isPremium

    val isReadOnly: StateFlow<Boolean> =
        combine(vehicleRepository.observeVehicles(), isPremium) { vehicles, premium ->
            vehicles.size > 1 && !premium
        }.stateIn(scope, SharingStarted.Eagerly, initialValue = false)
}
```

`observeVehicles()` is subscribed **once** for the whole app. Two roles separate cleanly:
- `AppGate` — read state (consumed by every paywalled VM).
- `BillingManager` — drive purchase flows (consumed only by `VehicleListViewModel` and `SettingsViewModel`).

### Step 3.2: Register dependencies in `YorvanaApplication`

**File:** `YorvanaApplication.kt`

```kotlin
open lateinit var billingManager: BillingManager
    protected set
open lateinit var debugBillingOverride: DebugBillingOverride
    protected set
open lateinit var appGate: AppGate
    protected set
```

In `initDependencies()`, after `vehicleRepository`:

```kotlin
debugBillingOverride = DebugBillingOverride(preferences, applicationScope)
val clientProvider = DefaultBillingClientProvider(this)
billingManager = BillingManagerImpl(
    clientProvider = clientProvider,
    preferences = preferences,
    coroutineScope = applicationScope,
    debugOverride = if (BuildConfig.DEBUG) debugBillingOverride else null,
)
appGate = AppGate(vehicleRepository, billingManager, applicationScope)
```

### Step 3.3: Wire billing lifecycle in `MainActivity`

**File:** `MainActivity.kt`

- `onCreate`: `application.billingManager.startConnection()`.
- `onResume` (new override): `application.billingManager.refresh()`. Catches purchases completed while the app was backgrounded.
- `onDestroy` (new override): `application.billingManager.endConnection()`.

### Step 3.4: Update `TestYorvanaApplication`

- Set `billingManager = FakeBillingManager()`.
- Set `debugBillingOverride = DebugBillingOverride(preferences, applicationScope)`.
- Set `appGate = AppGate(vehicleRepository, billingManager, applicationScope)`.

### Phase 3 — Tests

- `AppGateTest.kt` (**new**) — `isReadOnly` derivation across all combinations of vehicle count {0, 1, 2} × premium {true, false}.
- Update `MainActivityTest`: assert `startConnection` / `refresh` / `endConnection` are called at the expected lifecycle moments. Use `FakeBillingManager` with a counter / spy.

### Phase 3 — Done when

- `./gradlew test` green.
- App still runs end-to-end as before; user sees no paywall behavior yet.

---

## Phase 4: ViewModel Gating

**Goal:** Every paywalled VM observes `AppGate` and gates its write actions. `VehicleListVM` shows the upgrade dialog and auto-navigates after a successful purchase. `SettingsVM` exposes premium state and restore.

### Step 4.1: `VehicleListViewModel`

**File:** `ui/vehicles/VehicleListViewModel.kt`

**Constructor adds:** `appGate: AppGate, billingManager: BillingManager` (this VM both observes state *and* triggers purchase).

**State adds:** `isReadOnly: Boolean`, `showUpgradeDialog: Boolean`.

**Events add:** `DismissUpgradeDialog`, `RequestUpgrade`.

**Effects add:** `LaunchPurchaseFlow`, `ShowBillingError(message: String)`.

**Logic:**
- Observe `appGate.isReadOnly` into state.
- Auto-navigate on successful purchase (spec FR-P3):
  ```kotlin
  appGate.isPremium
      .drop(1)             // ignore initial cached value
      .filter { it }
      .onEach {
          if (_state.value.showUpgradeDialog) {
              _state.update { it.copy(showUpgradeDialog = false) }
              _effects.send(VehicleListEffect.NavigateToAddVehicle)
          }
      }
      .launchIn(viewModelScope)
  ```
- `AddVehicle` event: if `vehicles.size >= 1 && !appGate.isPremium.value` → `showUpgradeDialog = true`. Else emit `NavigateToAddVehicle`.
- `RequestDeleteVehicle`: ignore when `state.isReadOnly`.
- `RequestUpgrade` → emit `LaunchPurchaseFlow`.
- `DismissUpgradeDialog` → clear flag.

### Step 4.2–4.6: Other paywalled VMs

For each of `AddEditVehicleViewModel`, `VehicleDetailViewModel`, `RecordDetailViewModel`, `AddEditRecordViewModel`, `CategoriesViewModel`:
- Constructor adds `appGate: AppGate` (no `billingManager` — these don't trigger purchase).
- State adds `isReadOnly: Boolean`.
- Observe `appGate.isReadOnly` into state.
- Block all write events when `isReadOnly`: save (`AddEditVehicle`, `AddEditRecord`), add/edit/delete record (`VehicleDetail`, `RecordDetail`), add/delete category (`Categories`).

### Step 4.7: `SettingsViewModel`

**File:** `ui/settings/SettingsViewModel.kt`

**Constructor adds:** `appGate: AppGate, billingManager: BillingManager, debugBillingOverride: DebugBillingOverride`.

**State adds:** `isPremium`, `isReadOnly`, `isRestoringPurchases`, `debugOverrideMode`.

**Events add:** `RequestUpgrade`, `RestorePurchases`, plus debug-only events (`SetDebugOverride`, `SimulatePurchaseSuccess`, `SimulatePurchaseCancel`, `ResetBillingState`).

**Effects add:** `LaunchPurchaseFlow`, `ShowBillingError(message)`, `ShowSnackbar(message)`.

**Restore logic:**
```kotlin
RestorePurchases -> {
    _state.update { it.copy(isRestoringPurchases = true) }
    viewModelScope.launch {
        val result = billingManager.restorePurchases()
        _state.update { it.copy(isRestoringPurchases = false) }
        val msg = when (result) {
            is RestoreResult.Restored -> stringRes(R.string.restore_success)
            is RestoreResult.NotFound -> stringRes(R.string.restore_none_found)
            is RestoreResult.Error    -> stringRes(R.string.restore_error, result.message)
        }
        _effects.send(SettingsEffect.ShowSnackbar(msg))
    }
}
```

### Step 4.8: `ViewModelFactory`

**File:** `ui/util/ViewModelFactory.kt`

```kotlin
VehicleListViewModel(..., appGate = app.appGate, billingManager = app.billingManager)
AddEditVehicleViewModel(..., appGate = app.appGate)
VehicleDetailViewModel(..., appGate = app.appGate)
RecordDetailViewModel(..., appGate = app.appGate)
AddEditRecordViewModel(..., appGate = app.appGate)
CategoriesViewModel(..., appGate = app.appGate)
SettingsViewModel(..., appGate = app.appGate, billingManager = app.billingManager,
                  debugBillingOverride = app.debugBillingOverride)
```

### Phase 4 — Tests

| Test file | Key new tests |
|---|---|
| `VehicleListViewModelTest.kt` | Upgrade dialog trigger, read-only gating, auto-navigate on purchase, billing error effect |
| `AddEditVehicleViewModelTest.kt` | Save blocked in read-only |
| `VehicleDetailViewModelTest.kt` | Write ops blocked in read-only |
| `RecordDetailViewModelTest.kt` | Edit/delete blocked in read-only |
| `AddEditRecordViewModelTest.kt` | Save blocked in read-only |
| `CategoriesViewModelTest.kt` | Add/delete blocked in read-only |
| `SettingsViewModelTest.kt` | Premium state propagation, restore success/notfound/error, debug override events |
| `ViewModelFactoryTest.kt` | All VMs construct with `FakeBillingManager` + real `AppGate` over fake repo |

### Phase 4 — Done when

- `./gradlew test` green. App still has no paywall UI; gating is enforced at the VM layer only.

---

## Phase 5: UI Components + Screen Integration

**Goal:** End user can see the banner, the upgrade dialog, and the Settings premium section. All paywalled screens use the shared `PaywallScaffold`. The debug developer panel is available in debug builds.

### Step 5.1: Create `ReadOnlyWarningBanner`

**New file:** `ui/components/ReadOnlyWarningBanner.kt`

- Full-width banner, `MaterialTheme.colorScheme.errorContainer`, leading lock icon, "Read-only mode — upgrade to edit" text, trailing `TextButton("Upgrade")`.
- All strings via `stringResource`.
- Padding 12.dp horizontal, 8.dp vertical.

### Step 5.2: Create `UpgradeDialog`

**New file:** `ui/components/UpgradeDialog.kt`

- `AlertDialog` with title/text/upgrade/dismiss actions; all strings via `stringResource`.

### Step 5.3: Create `PaywallScaffold`

**New file:** `ui/components/PaywallScaffold.kt`

```kotlin
@Composable
fun PaywallScaffold(
    isReadOnly: Boolean,
    onUpgradeClick: () -> Unit,
    topBar: @Composable () -> Unit,
    floatingActionButton: @Composable () -> Unit = {},
    snackbarHost: @Composable () -> Unit = {},
    modifier: Modifier = Modifier,
    content: @Composable (PaddingValues) -> Unit,
)
```

Internally a `Scaffold` whose content is a `Column` with the optional `ReadOnlyWarningBanner` above the page content. Banner does **not** scroll. Single source of truth for banner placement, color, and string.

### Step 5.4: Swap each paywalled screen to `PaywallScaffold`

| Screen | Additional changes |
|---|---|
| `ui/vehicles/VehicleListScreen.kt` | Show `UpgradeDialog` when `state.showUpgradeDialog`; handle `LaunchPurchaseFlow` and `ShowBillingError` effects (snackbar). Disable swipe-to-dismiss when `isReadOnly`. FAB stays visible — taps go through the VM gate. |
| `ui/records/VehicleDetailScreen.kt` | Hide Add Record FAB and disable Edit/Delete menu items when `isReadOnly`. |
| `ui/records/RecordDetailScreen.kt` | Disable Edit/Delete buttons when `isReadOnly`. |
| `ui/vehicles/AddEditVehicleScreen.kt` | Disable Save button when `isReadOnly`. |
| `ui/records/AddEditRecordScreen.kt` | Disable Save button when `isReadOnly`. |
| `ui/settings/CategoriesScreen.kt` | Disable add input/button and delete actions when `isReadOnly`. |
| `ui/settings/SettingsScreen.kt` | Premium section + debug developer panel (Step 5.5). |

**Activity resolution helper for `LaunchPurchaseFlow`** (`LocalContext.current` is not guaranteed to be an `Activity`):

```kotlin
fun Context.findActivity(): Activity? {
    var ctx: Context? = this
    while (ctx is ContextWrapper) {
        if (ctx is Activity) return ctx
        ctx = ctx.baseContext
    }
    return null
}
```

Effect handler:
```kotlin
is VehicleListEffect.LaunchPurchaseFlow -> {
    val activity = context.findActivity() ?: return@collect
    when (val result = (context.applicationContext as YorvanaApplication)
        .billingManager.launchPurchaseFlow(activity)) {
        BillingLaunchResult.Launched, BillingLaunchResult.AlreadyOwned -> {}
        BillingLaunchResult.Unavailable, is BillingLaunchResult.Error ->
            snackbarHostState.showSnackbar(...)
    }
}
```

### Step 5.5: Settings — Premium section + Debug panel

In `SettingsScreen.kt`:

**Premium section** (always visible):
- If `state.isPremium == true`: ListItem "Premium active" / "Unlimited vehicles unlocked" (star icon, tertiary container).
- If `state.isPremium == false`: ListItem "Upgrade to Premium" / "One-time purchase for unlimited vehicles", tappable → `RequestUpgrade`.
- "Restore purchases" ListItem → `RestorePurchases`. Show `CircularProgressIndicator` when `state.isRestoringPurchases`.

**Developer panel** (gated by `if (BuildConfig.DEBUG)`):
```
[ Developer ]
  Billing override
    ( ) Real (use Play)         → SetDebugOverride(NONE)
    ( ) Force free              → SetDebugOverride(FORCE_FREE)
    ( ) Force premium           → SetDebugOverride(FORCE_PREMIUM)
  [ Simulate purchase success ] → SimulatePurchaseSuccess
  [ Simulate purchase cancel  ] → SimulatePurchaseCancel
  [ Reset all billing state   ] → ResetBillingState
```

### Phase 5 — Tests (screenshot)

| Test file | Scenarios |
|---|---|
| `PaywallScaffoldScreenshotTest.kt` (**new**) | Banner present / absent, light + dark |
| `ReadOnlyWarningBannerScreenshotTest.kt` (**new**) | Component in isolation |
| `UpgradeDialogScreenshotTest.kt` (**new**) | Dialog open |
| `VehicleListScreenshotTest.kt` | List with read-only banner + upgrade dialog |
| `VehicleDetailScreenshotTest.kt` | Detail with banner + hidden FAB |
| `RecordDetailScreenshotTest.kt` | Detail with banner + disabled buttons |
| `SettingsScreenshotTest.kt` | Premium-active, premium-free, developer panel hidden in non-debug |

`./gradlew recordRoborazziDebug` to baseline new tests; `verifyRoborazziDebug` in CI.

### Phase 5 — Strings

`res/values/strings.xml` adds keys for: banner text, banner upgrade button, upgrade-dialog title/body/buttons, premium-active title/subtitle, upgrade-cta title/subtitle, restore button, restore success/notfound/error, billing-error generic.

### Phase 5 — Done when

- `./gradlew lintDebug ktlintCheck detekt test verifyRoborazziDebug` green.
- Manual: build `installDebug`; toggle developer panel; observe banner appears across all paywalled screens. (This is a sanity check — full on-device validation is Phase 7.)

---

## Phase 6: Smoke Test Coverage

**Goal:** Three GMD scenarios catch end-to-end regressions in the gate, banner propagation, and reactive lift on premium activation. Doc updates in `docs/plan-smoke-tests.md` keep both plans consistent.

**Depends on:** Phase 3 complete (`DebugBillingOverride` wired in `YorvanaApplication`).

### Step 6.1: Test infrastructure helpers

**File:** `app/src/androidTest/java/com/yorvana/SmokeTest.kt`

Add helpers (these become part of the standing test infrastructure even after this phase):

```kotlin
private fun setBillingOverride(mode: DebugBillingOverride.Mode) {
    val app = ApplicationProvider.getApplicationContext<YorvanaApplication>()
    runBlocking { app.debugBillingOverride.setMode(mode) }
}

private fun addVehicleForTesting(nickname: String) {
    val app = ApplicationProvider.getApplicationContext<YorvanaApplication>()
    runBlocking { app.vehicleRepository.saveVehicle(Vehicle(nickname = nickname, /* ... */)) }
}

@After fun resetBillingOverride() {
    setBillingOverride(DebugBillingOverride.Mode.NONE)
}
```

`@After` reset is mandatory — without it, paywall state leaks into subsequent scenarios.

### Step 6.2: S18 — Upgrade dialog on free tier

**Test:** `test_18_upgradeDialogOnFreeTier()`

1. Setup: `configureVaultForTesting()` + `addVehicleForTesting("Test Car")` + `setBillingOverride(FORCE_FREE)`.
2. Tap FAB on `VehicleListScreen` → assert `UpgradeDialog` is displayed (`testTag = "upgrade-dialog"` — add this tag to `UpgradeDialog` in Phase 5).
3. Tap "Not now" → assert dialog dismisses; `VehicleListScreen` still shows 1 vehicle.

### Step 6.3: S19 — Read-only banner across paywalled screens

**Test:** `test_19_readOnlyBannerAcrossScreens()`

1. Setup: vault + `addVehicleForTesting("Car A")` + `addVehicleForTesting("Car B")` + `setBillingOverride(FORCE_FREE)`.
2. `VehicleListScreen`: assert banner displayed (`testTag = "readonly-banner"`); attempt swipe-delete → vehicle still present.
3. Tap "Car A" → `VehicleDetailScreen`: assert banner displayed; "Add Record" FAB hidden/disabled.
4. Tap Settings icon → `SettingsScreen`: assert banner displayed; "Premium" section shows "Upgrade to Premium".

### Step 6.4: S20 — Read-only lifts on premium activation

**Test:** `test_20_readOnlyLiftsOnPremium()`

1. Setup: vault + 2 vehicles + `setBillingOverride(FORCE_FREE)`.
2. Launch on `VehicleListScreen`. Assert banner displayed.
3. From the test process, call `setBillingOverride(FORCE_PREMIUM)`.
4. `waitUntil` banner not displayed (timeout 5s).
5. Tap FAB → asserts navigation to `AddEditVehicleScreen` (no upgrade dialog).
6. Press back; tap "Car A" → `VehicleDetailScreen`: assert banner absent; "Add Record" FAB visible.

This is the most important paywall smoke test — catches stale-state bugs in the `combine` pipeline that unit tests miss when scope/dispatcher choices differ from production.

### Step 6.5: Add `testTag`s to UI

In Phase 5 components, add semantic test tags:
- `UpgradeDialog`: `Modifier.testTag("upgrade-dialog")` on the `AlertDialog`.
- `ReadOnlyWarningBanner`: `Modifier.testTag("readonly-banner")` on the root container.

(Listed here for visibility; can be added during Phase 5 if convenient — but they're only *required* by Phase 6.)

### Step 6.6: Doc amendment — `docs/plan-smoke-tests.md`

Already done as part of this plan's review:
- Removed "Premium / Paywall flows" and "Read-only mode" from *Out of Scope*; replaced with a narrower exclusion for the real Play purchase sheet (covered by Phase 7 Track B here).
- Added scenarios S18–S20 with the same Precondition / Flow / Verifies structure as existing scenarios.
- Added Step 1 sub-step 8: `setBillingOverride` + `addVehicleForTesting` helpers.
- Added Step 6 (Paywall Scenarios), bumping the original "Coverage Baseline" to Step 7.

When implementing this phase, re-read `docs/plan-smoke-tests.md` Step 6 — that's the canonical sequence.

### Step 6.7: Coverage baseline

After S18–S20 are passing:
- `./gradlew generateGmdCoverage` → updated `app/coverage-baselines/gmd_smoke.ec`.
- `./gradlew verifyWithCoverage` → confirm paywall scenarios contribute coverage to `BillingManagerImpl`, `AppGate`, `PaywallScaffold` paths.
- Commit the baseline (Git LFS).

### Phase 6 — Done when

- `./gradlew pixel2api33Check` green (full smoke suite, S01–S20).
- Suite total runtime ≤ 2 min 30 s (the original 2 min budget plus ≤ 30 s for paywall).
- `gmd_smoke.ec` baseline regenerated and committed.
- `docs/plan-smoke-tests.md` matches the implemented scenarios.

---

## Phase 7: On-Device Verification + Play Setup

**Goal:** Validate end-to-end on a real phone. No code changes — this is the manual run-through and the one-time Play Console setup.

Two distinct testing tracks. Track A iterates per change; Track B runs once before each release.

### Track A — Fast loop with debug override

```bash
./gradlew installDebug
adb shell am start -n com.yorvana/.MainActivity
```

Open Settings → Developer panel.

**Free with 1 vehicle → upgrade → success → AddVehicle:**
1. Set *Force free*, ensure 1 vehicle in vault.
2. Tap FAB → upgrade dialog appears.
3. Tap *Simulate purchase success* → dialog auto-dismisses → AddVehicle screen opens (verifies Step 4.1 transition observer).

**Read-only mode:**
1. Set *Force premium*, add 2 vehicles.
2. Switch to *Force free*. Confirm:
   - Banner on every paywalled screen.
   - FAB hidden on detail screens; visible on vehicle list as upgrade entry point.
   - Save buttons disabled on add/edit forms.
   - Categories add/delete blocked.
   - Existing data still viewable.

**Cache resilience:**
1. *Force premium* → kill app → cold start in airplane mode → premium persists.
2. *Reset all billing state* → cache cleared → `isPremium = false`.

**Edge cases:**
- 1 vehicle + premium → no banner.
- Delete down to 1 vehicle while in read-only → banner lifts on next emission.
- 0 vehicles + free → can add 1 vehicle, no dialog.

### Track B — Real Google Play purchase (once per release)

**One-time Play Console setup:**
1. Create app entry; applicationId matches `app/build.gradle.kts` namespace.
2. Create in-app product `premium_lifetime` (Non-consumable, Active).
3. *Setup → License testing*: add the test Google account.
4. Upload a release-signed AAB to *Internal testing*. Artifact must exist and be reviewed/approved; need not be public.
5. Add the test account to internal testers; accept the opt-in URL on the test phone.

**Phone:** signed into license-tester account; opted into the internal track via opt-in URL.

**Install and validate:**
1. `./gradlew installDebug` (real `BillingClient` connects to Play; purchase sheet shows "(Test)"; purchases free and auto-refunded).
2. Settings → Developer → *Real (use Play)* (no override).
3. Tap Upgrade → real Play sheet → complete with test card. Premium unlocks; banner clears.
4. Uninstall + reinstall. Settings → Restore purchases → snackbar "Purchase restored".
5. Play Console → Order management → cancel test order. Reopen app → next `onResume` triggers `refresh()` → `isPremium = false`. If vault has >1 vehicle, read-only re-applies.

**PENDING purchase:** test using Play's "slow test card" (approves after delay). Premium must **not** flash to true during pending window — verifies Phase 2 PENDING handling.

### Track C — Inspection helpers

```bash
adb shell run-as com.yorvana ls -la files/datastore/
adb shell run-as com.yorvana cat files/datastore/preferences.preferences_pb \
  | hexdump -C | grep -A1 is_premium_cached
```
The DataStore proto isn't human-readable, but the key name + adjacent value byte are visible. `adb shell pm clear com.yorvana` resets all app state faster than uninstall.

### Phase 7 — Done when

- All Track A flows pass on a real device.
- Track B end-to-end purchase + restore + refund cycle pass on a license-tester device.
- Track B PENDING test confirms no premature `isPremium` flip.

---

## File Summary

### New Files

| File | Phase | Description |
|---|---|---|
| `data/billing/BillingResults.kt` | 1 | `BillingLaunchResult` + `RestoreResult` sealed types |
| `data/billing/BillingClientProvider.kt` | 1 | Test seam + default impl |
| `data/billing/BillingManager.kt` | 1 | Interface |
| `data/billing/BillingManagerImpl.kt` | 2 | Google Play Billing 7.x wrapper |
| `data/billing/DebugBillingOverride.kt` | 2 | Runtime billing override for debug |
| `app/src/test/java/com/yorvana/data/billing/FakeBillingManager.kt` | 2 | Test double |
| `app/src/test/java/com/yorvana/data/billing/BillingManagerImplTest.kt` | 2 | Local-only billing tests |
| `domain/AppGate.kt` | 3 | Centralized read-only / premium state |
| `app/src/test/java/com/yorvana/domain/AppGateTest.kt` | 3 | Gate derivation tests |
| `ui/components/ReadOnlyWarningBanner.kt` | 5 | Persistent warning banner |
| `ui/components/UpgradeDialog.kt` | 5 | Upgrade prompt dialog |
| `ui/components/PaywallScaffold.kt` | 5 | Shared banner-bearing Scaffold |

### Modified Files

| File | Phase | Changes |
|---|---|---|
| `gradle/libs.versions.toml` | 1 | Add billing dep |
| `app/build.gradle.kts` | 1, 2 | Add billing impl; tag `BillingManagerImplTest` as local-only |
| `data/preferences/AppPreferences.kt` | 1 | New fields + setters |
| `data/preferences/AppPreferencesImpl.kt` | 1 | Implement new keys |
| `YorvanaApplication.kt` | 3 | `billingManager`, `debugBillingOverride`, `appGate` properties + wiring |
| `MainActivity.kt` | 3 | `startConnection` / `refresh` (onResume) / `endConnection` |
| `ui/util/ViewModelFactory.kt` | 4 | Pass `appGate` everywhere; `billingManager` to VehicleList + Settings |
| `ui/vehicles/VehicleListViewModel.kt` | 4 | Read-only state, upgrade dialog, auto-navigate, billing error effect |
| `ui/vehicles/AddEditVehicleViewModel.kt` | 4 | Read-only gating |
| `ui/records/VehicleDetailViewModel.kt` | 4 | Read-only gating |
| `ui/records/RecordDetailViewModel.kt` | 4 | Read-only gating |
| `ui/records/AddEditRecordViewModel.kt` | 4 | Read-only gating |
| `ui/settings/CategoriesViewModel.kt` | 4 | Read-only gating |
| `ui/settings/SettingsViewModel.kt` | 4 | Premium state, upgrade, restore (`RestoreResult`), debug override events |
| `ui/vehicles/VehicleListScreen.kt` | 5 | `PaywallScaffold`, upgrade dialog, purchase effect handling, `testTag`s |
| `ui/records/VehicleDetailScreen.kt` | 5 | `PaywallScaffold`, hide FAB, disable menu |
| `ui/records/RecordDetailScreen.kt` | 5 | `PaywallScaffold`, disable buttons |
| `ui/vehicles/AddEditVehicleScreen.kt` | 5 | `PaywallScaffold`, disable save |
| `ui/records/AddEditRecordScreen.kt` | 5 | `PaywallScaffold`, disable save |
| `ui/settings/CategoriesScreen.kt` | 5 | `PaywallScaffold`, disable add/delete |
| `ui/settings/SettingsScreen.kt` | 5 | Premium section + debug developer panel |
| `res/values/strings.xml` | 5 | Paywall + restore-result strings |
| All paywalled VM test files | 4 | Inject `FakeBillingManager` + `AppGate` |
| `app/src/androidTest/java/com/yorvana/SmokeTest.kt` | 6 | Helpers + S18/S19/S20 |
| `app/coverage-baselines/gmd_smoke.ec` | 6 | Regenerated baseline |
| `docs/plan-smoke-tests.md` | 6 | (already amended in this plan's review pass) |

> Unless a path starts with `app/` or `gradle/`, treat it as relative to `app/src/main/`: Kotlin source files are listed relative to `java/com/yorvana/`, and Android resource files are listed relative to `res/`.
