# FairFrame Studio — build spec

A no-card AI photo and short-video generator that only finalizes a credit after the generation succeeds and passes an automated quality check.

## Project context

This spec describes an independent, alternative Android app you are building from scratch to compete with an existing incumbent app on Google Play — not a modification, clone, or reskin of the incumbent's own code, assets, or branding. Use the incumbent only as a market reference (via the report data below), and design working_name/package_id/store_listing/design_system so the result is clearly its own product.

## Incumbent app

- **Name:** Hailuo AI: Image&Video Maker
- **Package id:** `ai.hailuo.video`
- **Google Play:** https://play.google.com/store/apps/details?id=ai.hailuo.video
- **appy.fyi report:** https://appy.fyi/report/ai.hailuo.video
- **Category:** Photography

## Overview

- **Working name:** FairFrame Studio (trademark cleared: no)
- **Package id:** `fyi.appy.fairframestudio`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 8 weeks
- **Pricing:** subscription, $7.99 via `revenuecat`
- **Runtime AI:** yes (image_gen_api)
- **Permissions:** `INTERNET`

## Non-goals (out of scope for v1)

- No custom AI model training or self-hosted image/video inference in v1; generation is purchased from an existing API as stated in the report.
- No billing outside Google Play / RevenueCat; the app must not collect card details directly.
- No social feed, public profiles, comments, remix marketplace, or creator community features.
- No separate one-off credit-pack store in v1; v1 focuses on the report’s $7.99/month subscription funnel plus transparent generation ledger.
- No manual support-ticket refund workflow as the primary fix; failed generations must be handled automatically.

## Tech stack

- **Language / UI:** Kotlin, Jetpack Compose
- **Kotlin:** 2.0.21
- **Compose BOM:** 2024.10.01
- **Gradle:** 8.9

| Purpose | Gradle coordinate |
| --- | --- |
| Compose UI runtime and foundation components | `androidx.compose.ui:ui:1.7.4` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Compose preview tooling for debug builds | `androidx.compose.ui:ui-tooling:1.7.4` |
| Activity integration for setContent and edge-to-edge Compose | `androidx.activity:activity-compose:1.9.3` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware ViewModels in Compose | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware Flow collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Kotlin coroutines on Android | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Firebase anonymous authentication for account/history/abuse guardrails | `com.google.firebase:firebase-auth-ktx:23.1.0` |
| Cloud Firestore for user profile, generation jobs, credit ledger, and billing state | `com.google.firebase:firebase-firestore-ktx:25.1.1` |
| Firebase Cloud Functions callable client for server-side generation, verification, and ledger actions | `com.google.firebase:firebase-functions-ktx:21.1.0` |
| Firebase Storage client for generated media URLs when the backend stores image/video outputs | `com.google.firebase:firebase-storage-ktx:21.0.1` |
| RevenueCat subscription purchases backed by Google Play Billing | `com.revenuecat.purchases:purchases:8.10.4` |
| Room local cache for gallery/history offline display | `androidx.room:room-runtime:2.6.1` |
| Room Kotlin coroutine and Flow APIs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| Image loading for generated image thumbnails and previews | `io.coil-kt:coil-compose:2.7.0` |
| Video playback engine for generated short videos | `androidx.media3:media3-exoplayer:1.4.1` |
| Media3 UI PlayerView embedded from Compose for video results | `androidx.media3:media3-ui:1.4.1` |
| Encrypted local preferences for non-secret app settings and cached anonymous user id mirror | `androidx.datastore:datastore-preferences:1.1.1` |

## Design system

- **Primary color:** `#5B3DF5`
- **Background color:** `#0F1020`
- **Error color:** `#E5484D`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `video-camera` (regular weight)
- **Theme notes:** Dark theme is the default because generated media should visually dominate; light theme uses the same primary color on #FAFAFF background. Use rounded 24dp cards, 16dp screen padding, and high-contrast billing/credit status chips: success #2E7D32, pending #F9A825, refunded #0277BD.

## Screens

### WelcomeFreeTry
- **Route:** `welcome`
- **Purpose:** Explain the no-card low-resolution free generation and start anonymous sign-in before the user reaches creation.
- **Reached via:** app launch when no local onboarding completion exists; tap Sign out/reset account is not available in v1
- **Key UI elements:** App logo and one-sentence trust promise; No-card free generation callout; Continue button; Small text: paid plan is optional until free try is used
- **States:** loading_auth, ready_free_available, ready_free_used, auth_error

### Create
- **Route:** `create`
- **Purpose:** Collect prompt and generation type, then submit either the free low-res generation or a subscribed paid generation.
- **Reached via:** tap Continue on WelcomeFreeTry; bottom navigation Create tab; tap Create another from GenerationStatusResult
- **Key UI elements:** Prompt multiline text field; Generation type segmented control: Image, Short video; Resolution/plan badge showing Free low-res or Subscriber; Generate button; Credit safety note: no successful output, no finalized credit; Link to BillingCenter
- **States:** loading_user_state, ready_free_available, ready_subscribed, blocked_subscription_required, submitting, validation_error, submit_error

### Paywall
- **Route:** `paywall`
- **Purpose:** Show the $7.99/month subscription offer, disclosures, and purchase/restore actions.
- **Reached via:** tap Subscribe from Create blocked state; tap upgrade prompt after free generation is used; tap Manage plan from BillingCenter when not subscribed
- **Key UI elements:** Monthly price card showing $7.99/month; Clear renewal disclosure; Subscribe button; Restore purchases button; Link to BillingCenter cancellation information; Error banner area
- **States:** loading_offering, offering_loaded_not_subscribed, already_subscribed, purchase_pending, purchase_error, restore_error

### GenerationStatusResult
- **Route:** `generation/{generationId}`
- **Purpose:** Display generation progress, retry/refund state, verification state, and the final image or video.
- **Reached via:** successful submit from Create; tap active job from Gallery; tap generation notification is out of scope because notifications are not requested
- **Key UI elements:** Prompt summary; Progress timeline: queued, generating, verifying, finalized; Retry/refund status chip; Generated image preview or video player; Create another button; Open details button
- **States:** loading, queued, generating, verifying, auto_retrying, succeeded_image, succeeded_video, failed_no_charge, failed_refunded, not_found, error

### Gallery
- **Route:** `gallery`
- **Purpose:** Show the signed-in user’s generation history from local Room cache backed by Firestore snapshots.
- **Reached via:** bottom navigation Gallery tab; tap View history after a generation completes
- **Key UI elements:** Grid/list toggle; Generation thumbnail cards; Status badges: generating, verifying, refunded, complete; Empty state CTA to Create; Pull-to-refresh
- **States:** loading, empty, populated, sync_error

### GenerationDetail
- **Route:** `generationDetail/{generationId}`
- **Purpose:** Show immutable details for one generation: prompt, type, timestamps, ledger entry, verification outcome, and media preview.
- **Reached via:** tap a Gallery item; tap Open details on GenerationStatusResult
- **Key UI elements:** Full prompt text; Large image preview or video player; Ledger section showing no debit, finalized debit, or refund; Verification result text; Provider failure reason when available
- **States:** loading, populated_complete, populated_failed_refunded, populated_failed_no_charge, missing, error

### BillingCenter
- **Route:** `billing`
- **Purpose:** Make subscription status, next billing date, cancellation path, and ledger trust rules visible inside the app.
- **Reached via:** tap billing link from Create; tap Manage plan from Paywall; bottom navigation Account/Billing tab
- **Key UI elements:** Current plan card; Next billing date or cancellation effective date; Open Google Play subscription management button; Restore purchases button; Recent ledger entries list; Disclosure: failed or unusable generations are auto-refunded or not finalized
- **States:** loading, not_subscribed, subscribed_active, cancelled_access_until_period_end, restore_pending, error

### Settings
- **Route:** `settings`
- **Purpose:** Hold privacy, legal, and account support links without adding unsupported account-management complexity.
- **Reached via:** bottom navigation Settings tab
- **Key UI elements:** Privacy policy link; Terms/disclosures link; App version text; Open BillingCenter button
- **States:** populated

## Data model

### UserProfile (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | primary key; Firebase Auth uid |
| createdAt | `Instant` | server timestamp converted from Firestore Timestamp |
| freeGenerationsUsed | `Int` | starts at 0; v1 limit is 1 no-card low-resolution generation |
| activeEntitlement | `String` | empty string when not subscribed; expected value pro_monthly when active |
| nextBillingDate | `Instant?` | nullable; derived from RevenueCat CustomerInfo and mirrored for display |
| cancellationStatus | `String` | one of active, cancelled_access_until_period_end, none |

### GenerationJob (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key generated by backend callable |
| userId | `String` | foreign key to UserProfile.uid |
| type | `String` | one of image, short_video |
| prompt | `String` | user-entered prompt, trimmed, 1..1000 characters |
| status | `String` | queued, generating, verifying, auto_retrying, succeeded, failed_no_charge, failed_refunded |
| isFreeTrial | `Boolean` | true only for the one no-card low-res generation |
| lowResolution | `Boolean` | true for free trial output |
| providerJobId | `String?` | nullable until provider accepts the job |
| outputMediaUrl | `String?` | nullable until success; HTTPS URL to image or video |
| thumbnailUrl | `String?` | nullable for in-progress or failed jobs |
| failureReason | `String?` | nullable; provider or verification failure reason shown to user |
| verificationStatus | `String` | not_started, passed, failed_mismatch, failed_unusable |
| retryCount | `Int` | backend retries automatically up to 1 time before refund/no-charge final state |
| createdAt | `Instant` | server timestamp |
| updatedAt | `Instant` | server timestamp |

### CreditLedgerEntry (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; idempotent backend-generated id |
| userId | `String` | foreign key to UserProfile.uid |
| generationId | `String?` | nullable for subscription entitlement events; foreign key to GenerationJob.id when generation-related |
| delta | `Int` | 0 for pending/no-charge audit entries, -1 for finalized successful generation debit, +1 for refund correction |
| reason | `String` | pending_hold, successful_generation_finalized, provider_failure_no_charge, verification_failure_refund, subscription_entitlement_sync |
| idempotencyKey | `String` | unique; prevents duplicate debit/refund for the same generation state transition |
| createdAt | `Instant` | server timestamp |

### PurchaseEntitlement (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| userId | `String` | primary key; same as Firebase Auth uid |
| revenueCatAppUserId | `String` | RevenueCat user id set to Firebase Auth uid |
| productId | `String` | expected v1 value pro_monthly_799 |
| entitlementId | `String` | expected v1 value pro_monthly |
| isActive | `Boolean` | true when RevenueCat CustomerInfo entitlement is active |
| periodType | `String` | normal, trial, intro, prepaid, unknown as returned/mapped from RevenueCat |
| latestExpirationDate | `Instant?` | nullable when not subscribed |
| lastSyncedAt | `Instant` | server timestamp |

### FreeTrialClaim (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | primary key; Firebase Auth uid |
| claimedAt | `Instant` | server timestamp when the free generation is first submitted |
| generationId | `String` | foreign key to GenerationJob.id |
| status | `String` | claimed, consumed_success, consumed_failed_no_charge |

### GalleryCacheItem (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; mirrors GenerationJob.id |
| type | `String` | image or short_video |
| prompt | `String` | cached prompt text |
| status | `String` | cached GenerationJob.status |
| thumbnailUrl | `String?` | nullable |
| outputMediaUrl | `String?` | nullable |
| updatedAtEpochMillis | `Long` | used for descending sort |

## Features

### Anonymous account and trusted state sync

Create a Firebase anonymous account and keep user profile, entitlement, and generation state synchronized for history and abuse guardrails.

- **Answers complaint:** baseline parity

- **Screens:** WelcomeFreeTry, Create, Gallery, BillingCenter

- **Estimated hours:** 38

**Implementation notes:** On first app launch call FirebaseAuth.getInstance().signInAnonymously() if currentUser is null, then use the uid as the RevenueCat app user id via Purchases.logIn(uid). Attach Firestore snapshot listeners to /users/{uid}, /users/{uid}/generations ordered by updatedAt desc, and /users/{uid}/ledger ordered by createdAt desc limit 20. Mirror generation snapshots into Room GalleryCacheItem using REPLACE on primary key. If auth fails, WelcomeFreeTry shows auth_error and all generate buttons remain disabled. Firestore writes that affect credits, free claims, and generation status must be done only by Cloud Functions, not directly by the Android client.

**Acceptance criteria:**
- Fresh install with network signs in anonymously and creates/loads UserProfile before Create becomes ready.
- If FirebaseAuth returns an error, WelcomeFreeTry displays an auth error and no generation can be submitted.
- When a GenerationJob document changes from generating to succeeded in Firestore, the corresponding Room GalleryCacheItem is updated within one active snapshot event.
- RevenueCat app user id equals Firebase Auth uid after sign-in.

### No-card low-resolution free generation

Let each new user submit one low-resolution image or short-video generation before any payment is requested.

- **Answers complaint:** No genuine free trial

- **Screens:** WelcomeFreeTry, Create, GenerationStatusResult, Paywall

- **Estimated hours:** 40

**Implementation notes:** Create screen checks UserProfile.freeGenerationsUsed < 1 and activeEntitlement is empty or pro_monthly. For the first free submit, call Firebase Functions HTTPS callable startGeneration with payload {prompt, type, freeTrial:true}. The backend must atomically create FreeTrialClaim and GenerationJob only if no FreeTrialClaim exists for uid; otherwise it returns FAILED_PRECONDITION and the client navigates to Paywall. Free trial jobs set lowResolution=true so the provider request uses the backend’s low-res preset. The Android client never asks for a card, Play purchase, or RevenueCat offering before this call.

**Acceptance criteria:**
- A new anonymous user can tap Generate once from Create without opening Paywall or Google Play Billing.
- The created GenerationJob has isFreeTrial=true and lowResolution=true.
- A second free submit attempt for the same uid receives blocked_subscription_required and shows the Paywall entry point.
- The Paywall is not shown during the first free generation flow unless the backend says the free claim already exists.

### Image and short-video generation flow

Submit text prompts for AI image or short-video generation and show queued, generating, and completed media states.

- **Answers complaint:** baseline parity

- **Screens:** Create, GenerationStatusResult, Gallery, GenerationDetail

- **Estimated hours:** 60

**Implementation notes:** Create validates prompt.trim().length in 1..1000 and type in {image, short_video}. It calls the same startGeneration Cloud Function with {prompt,type,freeTrial:false} for subscribed users. The backend proxies an existing image/video generation API and stores providerJobId on GenerationJob; Android observes status changes through Firestore rather than polling the provider. GenerationStatusResult renders image jobs with Coil AsyncImage using outputMediaUrl and video jobs with Media3 ExoPlayer inside AndroidView PlayerView. If outputMediaUrl is null for succeeded status, treat it as error and show a retry-safe error message without creating any client-side ledger entry.

**Acceptance criteria:**
- Submitting a non-empty prompt creates a GenerationJob and navigates to generation/{generationId}.
- Queued, generating, verifying, and succeeded statuses each show distinct visible labels in GenerationStatusResult.
- Succeeded image jobs display the HTTPS image URL with Coil.
- Succeeded short_video jobs play the HTTPS video URL with Media3 ExoPlayer controls.
- Prompt strings longer than 1000 characters are rejected locally with validation_error and no Function call is made.

### Credit ledger with no debit on provider failure

Record generation ledger events so a failed or errored generation never finalizes a credit debit.

- **Answers complaint:** Credits burned on failed generations

- **Screens:** GenerationStatusResult, GenerationDetail, BillingCenter

- **Estimated hours:** 70

**Implementation notes:** The Android client displays ledger entries but never mutates them. The backend state machine is: create GenerationJob queued and CreditLedgerEntry delta=0 reason=pending_hold; when provider succeeds and verification passes, create exactly one ledger entry delta=-1 reason=successful_generation_finalized using idempotencyKey='finalize:'+generationId; when provider returns error/timeout before usable media exists, set job status failed_no_charge and create delta=0 reason=provider_failure_no_charge using idempotencyKey='provider_fail:'+generationId. Firestore transaction must check no existing ledger entry with the same idempotencyKey before writing. GenerationStatusResult maps failed_no_charge to the message 'Generation failed — no credit was finalized.'

**Acceptance criteria:**
- For a simulated provider error, the final GenerationJob.status is failed_no_charge.
- For that provider error, no CreditLedgerEntry with delta=-1 exists for the generationId.
- The visible result screen says no credit was finalized for failed_no_charge.
- Repeating the same provider failure callback twice creates only one provider_failure_no_charge ledger entry because idempotencyKey is unique.

### Auto-retry and AI quality verification before finalizing credit

Use a cheap vision-language verification call after media generation and automatically retry or refund/no-charge when output is unusable or mismatched.

- **Answers complaint:** Credits burned on failed generations

- **Screens:** GenerationStatusResult, GenerationDetail, BillingCenter

- **Estimated hours:** 34

**Implementation notes:** After provider media is available, backend sets GenerationJob.status='verifying' and sends the prompt plus generated media URL to a vision-language model through the AI gateway. The verifier returns passed, failed_unusable, or failed_mismatch. If passed, backend finalizes the successful debit. If failed and retryCount is 0, backend increments retryCount, sets status auto_retrying, and starts one replacement provider generation for the same prompt/type. If failed again, backend sets status failed_refunded for paid jobs and failed_no_charge for free jobs; for paid jobs it writes CreditLedgerEntry delta=+1 reason=verification_failure_refund only if a debit was already finalized, otherwise it writes delta=0 provider/verification no-charge audit. Android shows auto_retrying as an active progress state and failed_refunded as 'Output failed quality check — credit automatically returned.'

**Acceptance criteria:**
- When verifier returns passed on first output, GenerationJob.status becomes succeeded and one delta=-1 finalized debit exists for a paid generation.
- When verifier returns failed_mismatch once and retryCount is 0, GenerationJob.status becomes auto_retrying and retryCount becomes 1.
- When verifier fails after the retry, GenerationJob.status becomes failed_refunded for a paid job.
- A paid job that reaches failed_refunded has either no finalized debit or a matching +1 refund entry; it must not leave the user with only a -1 debit.
- The result screen visibly distinguishes verifying, auto_retrying, and failed_refunded states.

### Subscription disclosure, purchase, restore, and self-service cancellation

Sell the $7.99/month plan with clear renewal dates and an in-app route to Google Play cancellation.

- **Answers complaint:** Charged without consent or after cancelling

- **Screens:** Paywall, BillingCenter, Create

- **Estimated hours:** 48

**Implementation notes:** Configure RevenueCat offering identifier default with product pro_monthly_799 and entitlement pro_monthly. Paywall fetches Purchases.sharedInstance.getOfferings(); if unavailable show loading_offering then purchase_error. The monthly price text must display '$7.99/month' as the planned product copy while the purchase button uses the localized Package.product.price.formatted from RevenueCat when available. On purchase success or restore, read CustomerInfo.entitlements['pro_monthly']; update local UI immediately and call syncEntitlement Cloud Function so Firestore PurchaseEntitlement mirrors isActive, productId, expirationDate, and cancellationStatus. BillingCenter shows nextBillingDate from CustomerInfo.latestExpirationDate when active. The cancellation button opens Intent ACTION_VIEW to https://play.google.com/store/account/subscriptions?sku=pro_monthly_799&package=${context.packageName}. After returning from Play, BillingCenter calls restorePurchases and refreshes CustomerInfo.

**Acceptance criteria:**
- Paywall copy contains the exact phrase '$7.99/month' and a renewal disclosure before the Subscribe button.
- A successful RevenueCat purchase activates entitlement pro_monthly and unblocks paid generation on Create.
- Restore purchases refreshes CustomerInfo and updates BillingCenter without requiring an app restart.
- BillingCenter displays a next billing date or cancellation effective date when RevenueCat provides an expiration date.
- Tapping Cancel/Manage opens the Google Play subscriptions URL containing sku=pro_monthly_799 and the runtime package name.

### Private gallery and generation detail history

Show the user’s own previous generations, including completed, in-progress, failed, refunded, and no-charge jobs.

- **Answers complaint:** baseline parity

- **Screens:** Gallery, GenerationDetail, GenerationStatusResult

- **Estimated hours:** 30

**Implementation notes:** Gallery reads Room GalleryCacheItem as a Flow ordered by updatedAtEpochMillis descending. Firestore snapshot listener from the account feature upserts each GenerationJob into Room, including failures so the trust history remains visible. Use Coil for thumbnails where thumbnailUrl exists; for missing thumbnails render a deterministic placeholder card with type icon and status badge. GenerationDetail loads from Firestore by id for authoritative ledger and verification fields, falling back to Room cache only for prompt/status while loading. Do not expose public sharing or social discovery in v1.

**Acceptance criteria:**
- With no cached or remote jobs, Gallery shows an empty state with a Create CTA.
- A succeeded image job appears in Gallery with its thumbnail after the Firestore snapshot is received.
- A failed_refunded job remains visible in Gallery with a refunded badge instead of disappearing.
- Tapping any Gallery item opens generationDetail/{generationId}.
- GenerationDetail shows prompt, type, status, verificationStatus, and related ledger entries for the selected generation.

## Store listing

- **Title:** FairFrame AI
- **Short description:** AI photos and videos with no-card free try and fair credit refunds.
- **Category:** Photography
- **Keywords:** AI video generator, AI photo generator, text to video, text to image, free AI trial, credit refund, fair billing
- **Icon prompt:** Create a modern Android launcher icon for an AI photo and short-video generator named FairFrame AI. Dark indigo rounded-square background (#0F1020), centered stylized video camera glyph with a small magic sparkle, primary accent violet (#5B3DF5), subtle cyan highlight, flat vector style, high contrast, no text, no watermark, suitable at 48dp and 512px.

**Long description:**

Create AI images and short videos from text prompts with a trust-first flow. Try one low-resolution generation before paying, then upgrade only if the output is worth it.

FairFrame AI is built around a simple promise: failed generations should not silently cost you credits. Generation jobs are tracked from queued to verifying to complete, and provider failures or unusable outputs are automatically retried, not hidden.

What v1 includes:
• One no-card low-resolution free generation
• Text-to-image and short text-to-video creation
• Clear $7.99/month subscription offer
• In-app billing status and next billing date
• One-tap route to Google Play subscription management
• Private generation history with refund/no-charge status
• Automated verification before a paid generation is finalized

No direct card collection. Purchases and subscription management are handled through Google Play via RevenueCat.

## Legal

- **Regulated category:** financial
- **Privacy policy URL:** https://example.com/fairframe-ai/privacy (privacy claims verified: no)
- **Data collected:** Anonymous Firebase user ID; Text prompts submitted for generation; Generated image and video URLs; Generation status and failure/refund history; Subscription entitlement status and billing renewal/cancellation dates from RevenueCat; Crash/error diagnostics generated by Firebase client SDKs if enabled in the project

## Test plan

### 1. Credits burned on failed generations (unit)

1. Create an in-memory fake ledger repository with no entries.
2. Create a GenerationJob id='gen_fail_1' status='generating' for userId='user_1'.
3. Invoke the backend state-machine function handleProviderFailure(generationId='gen_fail_1', providerError='Generation Failure').
4. Query ledger entries for generationId='gen_fail_1'.
5. Invoke handleProviderFailure again with the same generationId to simulate a duplicate callback.

**Expected:** GenerationJob.status is failed_no_charge; there is zero ledger entry with delta=-1; exactly one ledger entry exists with reason=provider_failure_no_charge because the idempotency key prevents duplicates.

### 2. Credits burned on failed generations (unit)

1. Create a paid GenerationJob id='gen_verify_1' retryCount=0 status='verifying'.
2. Stub the vision-language verifier to return failed_mismatch for the first generated media URL.
3. Run the verification handler.
4. Read the updated GenerationJob.
5. Stub the verifier to return failed_unusable for the retry media URL.
6. Run the verification handler again.
7. Read ledger entries for gen_verify_1.

**Expected:** After first verifier failure the job status is auto_retrying and retryCount is 1; after second verifier failure the job status is failed_refunded, and the ledger does not contain an unmatched final -1 debit.

### 3. No genuine free trial (instrumented)

1. Install a fresh debug build with cleared app data.
2. Launch the app and wait until WelcomeFreeTry reaches ready_free_available.
3. Tap Continue.
4. Enter prompt 'a robot goalkeeper celebrating a world cup save' in Create.
5. Select Short video.
6. Tap Generate.
7. Observe navigation destination and ensure Paywall was not opened before submission.

**Expected:** The app navigates to generation/{generationId}; the first generation starts without opening RevenueCat purchase UI, Google Play Billing UI, or Paywall.

### 4. No genuine free trial (instrumented)

1. Use a test account whose Firestore UserProfile has freeGenerationsUsed=1 and activeEntitlement empty.
2. Launch the app directly to Create.
3. Enter prompt 'a cinematic sunrise over mountains'.
4. Select Image.
5. Tap Generate.

**Expected:** Create enters blocked_subscription_required and shows a Paywall navigation option; no startGeneration callable is made for a second free generation.

### 5. Charged without consent or after cancelling (manual)

1. Install a build configured with the RevenueCat sandbox product pro_monthly_799.
2. Open Paywall.
3. Verify the offer text before purchase.
4. Complete a sandbox subscription purchase.
5. Open BillingCenter.
6. Tap Cancel/Manage subscription.
7. In Google Play sandbox subscription management, cancel the subscription.
8. Return to the app and tap Restore purchases in BillingCenter.

**Expected:** Before purchase the Paywall visibly says $7.99/month and includes renewal disclosure; BillingCenter opens the Play subscription management page for pro_monthly_799; after restore, BillingCenter shows cancelled_access_until_period_end or the available cancellation effective date instead of implying the subscription is still indefinitely active.

### 6. baseline parity (instrumented)

1. Seed the local Room database with three GalleryCacheItem rows: one succeeded image with thumbnailUrl, one generating short_video with no thumbnailUrl, and one failed_refunded image.
2. Launch Gallery.
3. Tap the failed_refunded item.
4. Wait for GenerationDetail to load from the fake Firestore repository.

**Expected:** Gallery shows all three items with distinct status badges; the failed_refunded item is not hidden; GenerationDetail displays the prompt, status failed_refunded, verification status, and ledger section.

### 7. baseline parity (instrumented)

1. Create a fake GenerationJob with status=succeeded, type=short_video, and outputMediaUrl pointing to a local test MP4 served by the test web server.
2. Navigate to generation/{generationId}.
3. Wait for GenerationStatusResult to render.

**Expected:** GenerationStatusResult shows the succeeded_video state and displays Media3 playback controls for the MP4 URL.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
keytool -genkeypair -v -keystore release-upload.jks -storepass changeit -keypass changeit -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=FairFrame Studio, OU=Android, O=Solo Builder, L=Unknown, ST=Unknown, C=US"
ANDROID_KEYSTORE_PATH="$PWD/release-upload.jks" ANDROID_KEYSTORE_PASSWORD="changeit" ANDROID_KEY_ALIAS="upload" ANDROID_KEY_PASSWORD="changeit" ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
- `regulated_category_go_no_go`
