# PreviewForge AI — build spec

An AI photo-to-video generator that shows a low-resolution/watermarked sample before any payment prompt and makes subscription credits and cancellation obvious inside the app.

## 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:** Hype AI - AI Photo & Video
- **Package id:** `com.ai.hype`
- **Google Play:** https://play.google.com/store/apps/details?id=com.ai.hype
- **appy.fyi report:** https://appy.fyi/report/com.ai.hype
- **Category:** Photography

## Overview

- **Working name:** PreviewForge AI (trademark cleared: no)
- **Package id:** `fyi.appy.previewforgeai`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 2.9 weeks
- **Pricing:** subscription, $5 via `revenuecat`
- **Runtime AI:** yes (image_gen_api): Generate the low-resolution or watermarked sample preview before any payment prompt ≈ $0/call; Generate the full-resolution AI video after subscription entitlement and credit reservation ≈ $0/call
- **Permissions:** `INTERNET`

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

- No custom AI model training; v1 wraps third-party image/video-generation APIs through Firebase Cloud Functions.
- No social feed, public profiles, comments, likes, or creator network.
- No promise of daily free generations; v1 guarantees a sample preview before the paywall, not unlimited free output.
- No in-app human support chat in v1; the app instead prevents the reported credit-loss and cancellation-discovery problems through transparent flows.
- No hidden or variable credit pricing; v1 uses one fixed credit cost for a full video generation.
- No desktop, web, or iOS client in v1.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Main Activity integration for Jetpack Compose and Activity Result Photo Picker | `androidx.activity:activity-compose:1.9.3` |
| Compose UI runtime and primitives | `androidx.compose.ui:ui:1.7.4` |
| Material 3 Compose components for app screens, dialogs, buttons, cards, and progress indicators | `androidx.compose.material3:material3:1.3.0` |
| Compose Navigation graph for the listed routes | `androidx.navigation:navigation-compose:2.8.3` |
| ViewModel state holders exposed to Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware collection of Kotlin Flows in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Asynchronous image loading for selected input photos, generated previews, and generated final results | `io.coil-kt:coil-compose:2.7.0` |
| Anonymous Firebase user identity for per-user credit ledger, generation jobs, and subscription entitlement | `com.google.firebase:firebase-auth-ktx:23.1.0` |
| Firestore storage for user credit balance, credit ledger, and generation job status | `com.google.firebase:firebase-firestore-ktx:25.1.1` |
| Firebase Storage upload/download paths for source photos and generated media URLs | `com.google.firebase:firebase-storage-ktx:21.0.1` |
| Callable Firebase Cloud Functions client for starting preview and full generation jobs without exposing image/video API keys in the app | `com.google.firebase:firebase-functions-ktx:21.1.0` |
| Await Firebase Task results from Kotlin coroutines | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| Subscription purchase and entitlement management for the $5/week plan | `com.revenuecat.purchases:purchases:8.10.4` |

## Design system

- **Primary color:** `#7C3AED`
- **Background color:** `#0F1020`
- **Error color:** `#EF4444`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `sparkle` (regular weight)
- **Theme notes:** Use a dark-first Material 3 theme. Primary actions use #7C3AED with white text. Screen background is #0F1020 in dark mode and #FAF7FF in light mode. Cards use #1B1B32 in dark mode and #FFFFFF in light mode. Generated media previews are displayed in rounded 20dp cards. Use explicit plain-language labels for price, credits, and cancellation; never hide purchase terms behind icon-only controls.

## Screens

### HomeUpload
- **Route:** `home`
- **Purpose:** Entry screen where the user selects a photo and chooses an AI video style before requesting a free sample preview.
- **Reached via:** app launch; tap Back from PreviewResult; tap Start another from FinalResult
- **Key UI elements:** Top app bar with title PreviewForge AI; Selected-photo preview card; Choose photo button using Android Photo Picker; Template/style carousel with at least three static local template IDs; Plain text promise: 'See a watermarked sample before any payment prompt.'; Generate free sample button; Credits summary chip showing current balance when signed in; Subscription/settings icon button
- **States:** loading_user, no_photo_selected, photo_selected, uploading_photo, preview_start_error

### PreviewProgress
- **Route:** `preview/{jobId}`
- **Purpose:** Shows the status of the no-payment sample generation job until it succeeds or fails.
- **Reached via:** tap Generate free sample on HomeUpload
- **Key UI elements:** Large circular progress indicator; Status text for queued, running, succeeded, or failed; Input photo thumbnail; No charge message: 'This sample costs 0 credits.'; Retry sample button visible only on failed state; Back to upload button
- **States:** loading_job, queued, running, failed, job_not_found

### PreviewResult
- **Route:** `previewResult/{jobId}`
- **Purpose:** Displays the low-resolution or watermarked sample result before showing any purchase prompt.
- **Reached via:** automatic navigation from PreviewProgress when job status becomes succeeded; tap a completed preview job from local back stack after rotation
- **Key UI elements:** Generated preview media card with visible watermark or low-resolution badge; Input photo thumbnail; Template name; Create full video button; Try another photo/style button; Text: 'Preview was free. Full video costs 40 credits.'
- **States:** loading_job, preview_available, preview_missing_error, job_not_found

### PricingCredits
- **Route:** `pricing?sourceJobId={sourceJobId}`
- **Purpose:** Explains the subscription, credit bundle, and exact full-video cost before purchase.
- **Reached via:** tap Create full video on PreviewResult when user lacks entitlement or credits; tap Credits summary chip on HomeUpload; tap Manage plan on SubscriptionSettings
- **Key UI elements:** Price card: '$5/week subscription'; Credit bundle statement: 'Each weekly renewal grants 40 credits.'; Cost statement: 'One full video costs 40 credits.'; Preview statement: 'Watermarked previews cost 0 credits.'; Current credit balance row; Subscribe button; Restore purchases button; Continue if already subscribed button when entitlement is active
- **States:** loading_entitlement, not_subscribed, purchase_in_progress, purchase_error, subscribed_no_credits, subscribed_with_credits

### FullGenerationProgress
- **Route:** `full/{jobId}`
- **Purpose:** Shows paid full-video generation progress while credits are reserved but not permanently debited until success.
- **Reached via:** tap Create full video from PreviewResult when subscribed with at least 40 credits; tap Continue if already subscribed on PricingCredits
- **Key UI elements:** Large progress indicator; Input photo thumbnail; Reserved credits text: '40 credits reserved; returned if generation fails.'; Status text for queued, running, succeeded, or failed; Failure explanation card; Retry button on failed state when the balance is still sufficient; Back to upload button
- **States:** loading_job, queued, running, succeeded, failed_credit_returned, job_not_found

### FinalResult
- **Route:** `result/{jobId}`
- **Purpose:** Displays the completed full-resolution generated video result and confirms the exact credit debit.
- **Reached via:** automatic navigation from FullGenerationProgress when job status becomes succeeded
- **Key UI elements:** Generated video/player card or animated result preview; Download/share button using system share sheet; Credit receipt text: '40 credits used for this video.'; Start another button; Report mismatch button explaining it marks the job for review but does not provide human chat in v1
- **States:** loading_job, result_available, result_url_error, job_not_found

### SubscriptionSettings
- **Route:** `subscription`
- **Purpose:** Shows subscription status, credit balance, and a prominent cancellation link to Google Play subscription settings.
- **Reached via:** tap subscription/settings icon on HomeUpload; tap Manage plan from PricingCredits
- **Key UI elements:** Subscription status row; Current credit balance row; Weekly plan details: '$5/week, 40 credits/week, 40 credits/full video'; Cancel subscription button; Restore purchases button; Credit ledger preview list with latest entries; Back button
- **States:** loading, not_subscribed, subscribed, restore_in_progress, restore_error, open_cancel_link_error

## Data model

### UserProfile (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | document id; Firebase anonymous auth uid |
| createdAtMillis | `Long` | epoch milliseconds |
| subscriptionActive | `Boolean` | derived from RevenueCat entitlement sync |
| creditBalance | `Int` | available credits; must never go below 0 |
| currentPeriodStartMillis | `Long` | nullable represented as 0 when not subscribed |
| currentPeriodEndMillis | `Long` | nullable represented as 0 when not subscribed |
| revenueCatAppUserId | `String` | RevenueCat app user id associated with Firebase uid |

### GenerationJob (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id; UUID generated by client before calling Cloud Function |
| uid | `String` | owner uid; query only jobs for current Firebase user |
| inputStoragePath | `String` | Firebase Storage path users/{uid}/inputs/{jobId}.jpg |
| outputUrl | `String` | nullable represented as empty string until generation succeeds |
| templateId | `String` | static local template identifier chosen on HomeUpload |
| kind | `String` | allowed values: preview, full_video |
| status | `String` | allowed values: queued, running, succeeded, failed |
| creditsReserved | `Int` | 0 for preview; 40 for full_video while running |
| creditsDebited | `Int` | 0 until success; 40 after full_video success |
| errorMessage | `String` | empty string unless status is failed |
| createdAtMillis | `Long` | epoch milliseconds |
| updatedAtMillis | `Long` | epoch milliseconds |

### CreditLedgerEntry (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id; UUID |
| uid | `String` | owner uid |
| delta | `Int` | positive for grants/returns, negative for successful full-video debit |
| reason | `String` | allowed values: weekly_subscription_grant, full_video_success_debit, generation_failed_return |
| generationJobId | `String` | empty string when unrelated to a generation job |
| revenueCatTransactionId | `String` | empty string when unrelated to purchase renewal |
| createdAtMillis | `Long` | epoch milliseconds |

## Features

### Photo upload and style selection

Lets the user choose a source photo and a local style/template before starting an AI sample generation.

- **Answers complaint:** baseline parity

- **Screens:** HomeUpload

- **Estimated hours:** 12

**Implementation notes:** Use ActivityResultContracts.PickVisualMedia with PickVisualMedia.ImageOnly so no READ_MEDIA_IMAGES permission is needed. After selection, open the returned content Uri through ContentResolver, decode bounds, downsample the longest side to 1536 px, compress to JPEG at quality 90, and upload to Firebase Storage at users/{uid}/inputs/{jobId}.jpg. Keep three static template IDs in code, for example cinematic_glow, fashion_reel, and retro_flash; only the ID and display label are needed by the client. Disable Generate free sample until both a photo Uri and templateId are present.

**Acceptance criteria:**
- On a fresh install, HomeUpload shows no subscription prompt before a photo is selected.
- Choosing an image through the system picker displays that image in the selected-photo preview card.
- Generate free sample remains disabled until a template is selected.
- The app requests INTERNET only and does not request READ_MEDIA_IMAGES.

### Free sample generation before paywall

Generates and displays one low-resolution or watermarked AI sample before any payment prompt can appear.

- **Answers complaint:** Charged before seeing anything

- **Screens:** HomeUpload, PreviewProgress, PreviewResult

- **Estimated hours:** 24

**Implementation notes:** After the input image upload completes, create a GenerationJob document with kind='preview', status='queued', creditsReserved=0, creditsDebited=0, then call Firebase callable function startPreviewGeneration with jobId, inputStoragePath, and templateId. The Cloud Function calls the image generation API using a low-resolution or watermark-capable setting and writes outputUrl plus status='succeeded' on success, or status='failed' and errorMessage on failure. The Android app listens to the job document with Firestore snapshot listeners. Navigate from PreviewProgress to PreviewResult only when status becomes succeeded. Do not navigate to PricingCredits from HomeUpload or PreviewProgress under any condition.

**Acceptance criteria:**
- Starting a sample generation creates a job with creditsReserved=0 and creditsDebited=0.
- No screen containing a Subscribe button is reachable before PreviewResult has displayed a generated outputUrl.
- PreviewResult states that the preview was free and that a full video costs 40 credits.
- If the preview Cloud Function returns failed, PreviewProgress shows Retry sample and still shows no payment prompt.

### Transparent subscription and credit math

Shows the weekly subscription price, the exact credits granted, and the exact full-video cost before purchase.

- **Answers complaint:** Credits don't match what's advertised

- **Screens:** PricingCredits, SubscriptionSettings

- **Estimated hours:** 22

**Implementation notes:** Use RevenueCat for the subscription product shown as '$5/week'. In v1, one active weekly subscription period grants exactly 40 credits, and one full video costs exactly 40 credits. PricingCredits must render these three facts as separate visible text rows: '$5/week subscription', 'Each weekly renewal grants 40 credits', and 'One full video costs 40 credits'. On successful RevenueCat purchase or restore, call Firebase callable function syncSubscriptionAndGrantCredits with the RevenueCat appUserId and latest CustomerInfo entitlement status. The backend updates UserProfile.subscriptionActive and creates a CreditLedgerEntry with delta=40 and reason='weekly_subscription_grant' only once per transaction/renewal id.

**Acceptance criteria:**
- PricingCredits displays '$5/week subscription' before the Subscribe button.
- PricingCredits displays 'Each weekly renewal grants 40 credits' before the Subscribe button.
- PricingCredits displays 'One full video costs 40 credits' before the Subscribe button.
- A restored active subscription updates subscriptionActive=true without duplicating an existing weekly_subscription_grant ledger entry for the same transaction id.

### Full video generation with no credit burn on failure

Creates a paid full video only after preview approval and returns reserved credits if generation fails.

- **Answers complaint:** Generation failures burn paid credits

- **Screens:** PreviewResult, PricingCredits, FullGenerationProgress, FinalResult, SubscriptionSettings

- **Estimated hours:** 34

**Implementation notes:** When the user taps Create full video from PreviewResult, first read UserProfile. If subscriptionActive is false or creditBalance < 40, navigate to PricingCredits. If eligible, call Firebase callable function startFullVideoGeneration with source preview jobId. The backend must run a Firestore transaction that verifies creditBalance >= 40, subtracts 40 into a reserved state by creating a full_video GenerationJob with creditsReserved=40 and creditsDebited=0, then starts the video generation API. If the API succeeds, backend sets status='succeeded', outputUrl, creditsDebited=40, creditsReserved=0, and creates CreditLedgerEntry delta=-40 reason='full_video_success_debit'. If the API fails, backend sets status='failed', creditsReserved=0, creditsDebited=0, restores the 40 credits to UserProfile.creditBalance, and creates CreditLedgerEntry delta=40 reason='generation_failed_return'. The client shows the reserved-credit message while queued/running and the returned-credit message on failure.

**Acceptance criteria:**
- A user with subscriptionActive=false is sent to PricingCredits instead of starting a full_video job.
- A user with subscriptionActive=true and creditBalance=40 can start a full_video job from PreviewResult.
- If the mocked full generation API fails, the final UserProfile.creditBalance equals the starting balance and the job has creditsDebited=0.
- If the mocked full generation API succeeds, the final UserProfile.creditBalance is reduced by exactly 40 and the job has creditsDebited=40.
- FullGenerationProgress visibly states that 40 credits are reserved and returned if generation fails.

### In-app cancel subscription link

Places a clear Cancel subscription button inside the app that opens the platform subscription-management page.

- **Answers complaint:** Cancellation buried, not blocked

- **Screens:** SubscriptionSettings

- **Estimated hours:** 6

**Implementation notes:** On SubscriptionSettings, show a primary outlined button labeled exactly 'Cancel subscription'. On tap, launch an ACTION_VIEW Intent to Uri.parse('https://play.google.com/store/account/subscriptions?sku=weekly_5_usd&package=' + context.packageName). If no Activity can handle it, launch fallback Uri.parse('https://play.google.com/store/account/subscriptions'). Catch ActivityNotFoundException and show open_cancel_link_error state with text explaining that cancellation is managed by Google Play subscriptions.

**Acceptance criteria:**
- SubscriptionSettings shows a visible button labeled 'Cancel subscription' without requiring a menu overflow.
- Tapping Cancel subscription sends an ACTION_VIEW intent containing '/store/account/subscriptions'.
- The intent URL includes sku=weekly_5_usd and the runtime package name when the product-specific URL is used.
- If intent launch fails, the screen shows a visible error explaining to open Google Play subscriptions.

### Generated result display and sharing

Displays the completed full video and lets the user share or download it after successful generation.

- **Answers complaint:** baseline parity

- **Screens:** FinalResult

- **Estimated hours:** 8

**Implementation notes:** FinalResult observes the GenerationJob document by jobId. When status='succeeded' and outputUrl is non-empty, load the media URL in a result card. For v1, use an ACTION_SEND intent with type 'video/*' and EXTRA_TEXT set to outputUrl for sharing; do not implement custom gallery saving because that would require additional media-store handling and is not required by the report. The receipt text must read '40 credits used for this video.' exactly when creditsDebited=40.

**Acceptance criteria:**
- FinalResult shows result_available only when the job status is succeeded and outputUrl is non-empty.
- The share button opens an ACTION_SEND chooser with type video/*.
- A successful full video result displays the text '40 credits used for this video.'
- If outputUrl is empty for a succeeded job, FinalResult shows result_url_error instead of a blank media card.

### Credit ledger visibility

Shows recent credit grants, debits, and returned credits so users can see what they received and spent.

- **Answers complaint:** Did Not Get What I Paid For

- **Screens:** SubscriptionSettings

- **Estimated hours:** 10

**Implementation notes:** SubscriptionSettings queries CreditLedgerEntry documents for the current uid ordered by createdAtMillis descending and limited to 20. Render each entry with signed delta, reason label, and timestamp. Map weekly_subscription_grant to 'Weekly subscription grant', full_video_success_debit to 'Full video created', and generation_failed_return to 'Generation failed: credits returned'. Do not allow client-side writes to ledger entries except through Firebase Cloud Functions; Firestore rules must deny direct ledger writes from mobile clients.

**Acceptance criteria:**
- After a subscription grant, SubscriptionSettings shows a +40 row labeled 'Weekly subscription grant'.
- After a successful full video, SubscriptionSettings shows a -40 row labeled 'Full video created'.
- After a failed full video, SubscriptionSettings shows a +40 row labeled 'Generation failed: credits returned'.
- The ledger list is visible from SubscriptionSettings without contacting human support.

## Store listing

- **Title:** PreviewForge AI
- **Short description:** See an AI sample before paying. Clear $5/week credits.
- **Category:** Photography
- **Keywords:** AI photo video, photo animation, AI video generator, watermarked preview, transparent credits, photo effects, subscription cancel
- **Icon prompt:** Create a modern Android app icon for an AI photo-to-video preview app. Dark purple gradient background, centered sparkling camera/video glyph, subtle glow, rounded square adaptive icon style, clean high-contrast shapes, no text, no brand names, no realistic faces.

**Long description:**

PreviewForge AI turns your photo into AI-styled video results with a simple promise: you see a watermarked sample before any payment prompt. If you like the sample, the weekly plan is explained in plain language before purchase: $5/week, 40 credits per weekly renewal, and 40 credits for one full video.

No mystery credit math. No surprise paywall before seeing a result. No hunting for cancellation instructions: the app includes a Cancel subscription button that opens Google Play subscription settings.

V1 focuses on a transparent photo-to-video flow: choose a photo, pick a style, generate a free sample, then decide whether to create the full video.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://previewforge-ai.example.com/privacy (privacy claims verified: no)
- **Data collected:** Uploaded source photos for AI generation; Generated preview images or generated video URLs; Anonymous Firebase user identifier; Subscription entitlement status; Credit balance and credit ledger entries; Generation job status and error messages

## Test plan

### 1. Charged before seeing anything (instrumented)

1. Install a fresh debug build with mocked Firebase and mocked RevenueCat returning not_subscribed.
2. Launch the app and wait for HomeUpload loading_user to finish.
3. Use the test Photo Picker contract to return a local test image Uri.
4. Select template cinematic_glow.
5. Tap Generate free sample.
6. Have the mocked startPreviewGeneration function create a preview job and then update it to status=succeeded with outputUrl='https://example.com/preview.jpg'.
7. Wait until PreviewResult is displayed.

**Expected:** No Subscribe button or PricingCredits screen appears before PreviewResult displays the generated preview URL; PreviewResult shows the text 'Preview was free. Full video costs 40 credits.'

### 2. Credits don't match what's advertised (unit)

1. Create PricingCredits view state with subscriptionActive=false, creditBalance=0, weeklyPriceLabel='$5/week subscription', weeklyGrantCredits=40, fullVideoCostCredits=40.
2. Render PricingCredits with Compose UI test rule.
3. Query for text '$5/week subscription'.
4. Query for text 'Each weekly renewal grants 40 credits'.
5. Query for text 'One full video costs 40 credits'.
6. Query for the Subscribe button.

**Expected:** All three credit/price statements are visible before the Subscribe button is tapped.

### 3. Generation failures burn paid credits (unit)

1. Seed fake Firestore UserProfile with subscriptionActive=true and creditBalance=40.
2. Call the repository method that starts a full video job for previewJobId='preview123'.
3. Simulate backend job transition to status=failed with errorMessage='network error'.
4. Apply the same transaction rules used by startFullVideoGeneration failure handling.
5. Read UserProfile, GenerationJob, and latest CreditLedgerEntry from the fake store.

**Expected:** UserProfile.creditBalance is 40, GenerationJob.creditsDebited is 0, GenerationJob.status is failed, and the latest ledger entry has delta=40 and reason='generation_failed_return'.

### 4. Successful full generation debits exactly the visible cost (unit)

1. Seed fake Firestore UserProfile with subscriptionActive=true and creditBalance=40.
2. Create a full_video GenerationJob with creditsReserved=40 and creditsDebited=0.
3. Simulate backend success with outputUrl='https://example.com/final.mp4'.
4. Apply the success transaction rules.
5. Read UserProfile, GenerationJob, and latest CreditLedgerEntry.

**Expected:** UserProfile.creditBalance is 0, GenerationJob.creditsDebited is 40, outputUrl is non-empty, and the latest ledger entry has delta=-40 and reason='full_video_success_debit'.

### 5. Cancellation buried, not blocked (instrumented)

1. Launch the app with mocked UserProfile subscriptionActive=true and creditBalance=40.
2. Navigate to SubscriptionSettings by tapping the subscription/settings icon on HomeUpload.
3. Verify the Cancel subscription button is visible without opening an overflow menu.
4. Tap Cancel subscription while using an Espresso Intents capture rule.

**Expected:** An ACTION_VIEW intent is fired with a URI containing 'https://play.google.com/store/account/subscriptions' and 'sku=weekly_5_usd'.

### 6. Did Not Get What I Paid For (instrumented)

1. Seed fake Firestore with three CreditLedgerEntry documents for the current uid: +40 weekly_subscription_grant, -40 full_video_success_debit, and +40 generation_failed_return.
2. Launch SubscriptionSettings.
3. Wait for the credit ledger list to populate.
4. Find visible ledger rows by their mapped labels.

**Expected:** SubscriptionSettings shows '+40 Weekly subscription grant', '-40 Full video created', and '+40 Generation failed: credits returned' rows.

### 7. baseline parity (manual)

1. Install a release build on a physical Android device.
2. Launch the app.
3. Pick a real portrait photo from the system picker.
4. Select any style.
5. Generate a free sample.
6. After the sample appears, use a subscribed test account with 40 credits to create a full video.
7. Wait for FinalResult.
8. Tap Share.

**Expected:** The app completes the photo-to-preview-to-full-video flow, FinalResult displays a generated media URL/card, and Android opens a system share sheet for the video URL.

## Build instructions

```sh
set -e
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
rm -f release-upload.keystore
keytool -genkeypair -v -keystore release-upload.keystore -storepass changeit -keypass changeit -alias previewforge_upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=PreviewForge AI,O=Solo Builder,C=US"
ORG_GRADLE_PROJECT_RELEASE_STORE_FILE=$PWD/release-upload.keystore ORG_GRADLE_PROJECT_RELEASE_STORE_PASSWORD=changeit ORG_GRADLE_PROJECT_RELEASE_KEY_ALIAS=previewforge_upload ORG_GRADLE_PROJECT_RELEASE_KEY_PASSWORD=changeit ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
