# PrankTrust Studio — build spec

A prank video generator that gives users three successful ad-free generations before any subscription ask, and refunds failed outputs automatically.

## 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:** Mitu AI: Prank Video Generator
- **Package id:** `aiart.ai.photo.aiphotogenerator`
- **Google Play:** https://play.google.com/store/apps/details?id=aiart.ai.photo.aiphotogenerator
- **appy.fyi report:** https://appy.fyi/report/aiart.ai.photo.aiphotogenerator
- **Category:** Art & Design

## Overview

- **Working name:** PrankTrust Studio (trademark cleared: no)
- **Package id:** `fyi.appy.pranktruststudio`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 10 weeks
- **Pricing:** subscription, $4.99 via `revenuecat`
- **Runtime AI:** yes (image_gen_api): Preflight uploaded photo quality check for usable face, resolution, corruption, and template fit before credit use ≈ $0.01/call; AI prank video generation from selected template, language, and uploaded photo ≈ $0.08/call; Postflight quality check for identity drift, unreadable text, corrupted output, or failed render before consuming credit ≈ $0.01/call
- **Permissions:** `INTERNET`

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

- No interstitial, rewarded, banner, or third-party ad network integration in v1.
- No custom AI model training; v1 uses existing AI video generation APIs behind a Firebase backend.
- No public social feed, comments, follows, or creator marketplace.
- No advanced video editor beyond template selection, photo crop, preview, save, and share.
- No offline AI generation; uploaded photos and generation jobs require internet access.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Compose activity host, Activity Result photo picker, and edge-to-edge app setup | `androidx.activity:activity-compose:1.9.3` |
| Core Android Kotlin extensions | `androidx.core:core-ktx:1.15.0` |
| Compose dependency alignment | `androidx.compose:compose-bom:2024.10.01` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Compose Navigation routes for the full screen graph | `androidx.navigation:navigation-compose:2.8.3` |
| ViewModel integration with Compose state | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.6` |
| Lifecycle-aware Flow collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Local Room persistence for templates, generation history, and cached job state | `androidx.room:room-runtime:2.6.1` |
| Coroutine support for Room DAOs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| Load local thumbnails and generated video poster images in Compose | `io.coil-kt:coil-compose:2.7.0` |
| Generated video playback | `androidx.media3:media3-exoplayer:1.4.1` |
| AndroidView-backed PlayerView controls for Media3 video playback | `androidx.media3:media3-ui:1.4.1` |
| Anonymous Firebase identity for free-generation quota, purchases, and job ownership | `com.google.firebase:firebase-auth:23.1.0` |
| Firestore storage for user quota, generation jobs, and server-side refund state | `com.google.firebase:firebase-firestore:25.1.1` |
| Cloud Storage upload of cropped source photos and generated video assets | `com.google.firebase:firebase-storage:21.0.1` |
| Callable Cloud Functions for AI preflight, generation creation, retry, and refund actions | `com.google.firebase:firebase-functions:21.0.0` |
| Await Firebase Task results from Kotlin coroutines | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| Subscription purchase and entitlement handling for the $4.99/month plan | `com.revenuecat.purchases:purchases:8.9.0` |
| Minimal funnel analytics for activation, generation success, failure refund, and paywall display | `com.google.firebase:firebase-analytics:22.1.2` |

## Design system

- **Primary color:** `#6D28D9`
- **Background color:** `#0F1020`
- **Error color:** `#EF4444`
- **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 video previews are central; light theme uses the same primary color with #FFFBFE background. Cards use rounded 24dp corners, primary CTAs use filled Material 3 buttons, and all paywall/quota copy must be plain text with no countdown timers or fake urgency.

## Screens

### Onboarding
- **Route:** `onboarding`
- **Purpose:** Introduce the ad-free bargain, allow language selection, and start anonymous sign-in before creation.
- **Reached via:** app launch when onboardingComplete is false; Settings language edit
- **Key UI elements:** Headline: “Make 3 prank videos free”; Body copy explaining no ads before creation and paywall only after successful free videos; Language selector list; Continue button; Privacy link
- **States:** loading anonymous sign-in, sign-in error, language unselected, ready

### Template Gallery
- **Route:** `templates`
- **Purpose:** Show prank video templates and the user's remaining successful free generations.
- **Reached via:** Onboarding Continue; bottom navigation Templates tab; back from Result; back from Paywall
- **Key UI elements:** Quota chip showing “3 free successes”, “2 free successes”, “1 free success”, or “Plan required”; Template grid with thumbnail, title, duration, and language support badge; No ads placeholder-free layout; Library navigation button; Settings navigation button
- **States:** loading templates, empty template catalog, template load error, populated

### Photo Crop
- **Route:** `photo/{templateId}`
- **Purpose:** Pick, crop, and preview the source photo for a selected template.
- **Reached via:** tap template tile on Template Gallery
- **Key UI elements:** Pick photo button using Android Photo Picker; Square crop frame with drag-to-pan and pinch-to-zoom; Template preview card; Use this photo button; Change photo button
- **States:** no photo selected, photo loading, photo decode error, crop ready, uploading cropped photo, upload error

### Preflight Review
- **Route:** `preflight/{templateId}/{uploadId}`
- **Purpose:** Run AI quality checks before consuming a free generation or paid credit.
- **Reached via:** Photo Crop upload success
- **Key UI elements:** Uploaded photo preview; Checklist rows for usable face, resolution, template fit, and language fit; Generate button when checks pass; Retake photo button when checks fail; Plain-language explanation of why no credit was consumed
- **States:** checking, passed, failed missing face, failed low resolution, failed corrupted upload, failed template mismatch, check service error

### Generation Progress
- **Route:** `generation/{jobId}`
- **Purpose:** Show generation progress, retries, and refund status without ads or surprise payment prompts.
- **Reached via:** Preflight Review Generate button; Library tap on in-progress job
- **Key UI elements:** Progress indicator with stage label; Selected template thumbnail; Source photo thumbnail; Status text for queued, rendering, postflight checking, retrying, refunded, or failed; Cancel and return button
- **States:** loading job, queued, rendering, postflight checking, auto retrying, refunded after failure, generation failed without credit charge, job not found, network error

### Result
- **Route:** `result/{jobId}`
- **Purpose:** Play, save, and share a successful generated prank video.
- **Reached via:** Generation Progress success; Library tap on completed job
- **Key UI elements:** Video player; Template name; Save to device button; Share button; Create another button; Quota chip
- **States:** loading result, video ready, video playback error, save in progress, save success, save error, share sheet launching error

### Paywall
- **Route:** `paywall`
- **Purpose:** Present the clear $4.99/month plan only after the three successful free generations are used.
- **Reached via:** Preflight Review Generate button when server reports freeSuccessesRemaining == 0 and no active entitlement; Template Gallery quota chip when plan required
- **Key UI elements:** Headline: “Keep creating for $4.99/month”; Free successes used counter; Subscribe button; Restore purchases button; Terms and privacy links; No ads / no surprise charges bullet list
- **States:** loading offerings, offer available, purchase processing, purchase success, purchase cancelled, purchase error, restore processing, restore none found, restore success

### Library
- **Route:** `library`
- **Purpose:** List local and synced generation history, including failed/refunded jobs for transparency.
- **Reached via:** Template Gallery Library button; bottom navigation Library tab
- **Key UI elements:** Filter tabs: All, In progress, Complete, Refunded; Generation cards with thumbnail, template title, status, and created date; Empty state create button; Retry photo button for failed preflight items
- **States:** loading, empty, populated, sync error

### Settings
- **Route:** `settings`
- **Purpose:** Show privacy-forward trust information, language choice, subscription status, and support actions.
- **Reached via:** Template Gallery Settings button; bottom navigation Settings tab
- **Key UI elements:** Current language row; Subscription status row; Restore purchases button; Privacy promise card: no ad SDKs, photo used for generation, failed outputs refunded; Delete local history button; Privacy policy link
- **States:** loading account, loaded free user, loaded subscriber, restore processing, restore error, delete confirmation, delete success

## Data model

### Template (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, stable server template id |
| title | `String` |  |
| thumbnailUrl | `String` | HTTPS URL or local asset URI |
| durationSeconds | `Int` |  |
| supportedLanguages | `List<String>` | stored via Room TypeConverter as JSON array of BCP-47 tags |
| promptKey | `String` | server-side prompt/template key, not raw prompt text |
| isActive | `Boolean` |  |

### GenerationJob (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| jobId | `String` | primary key, mirrors Firestore generationJobs document id |
| templateId | `String` | foreign key to Template.id |
| sourcePhotoLocalUri | `String` | nullable after local deletion |
| sourcePhotoStoragePath | `String` | Firebase Storage path |
| resultVideoUrl | `String` | nullable until success |
| posterImageUrl | `String` | nullable |
| status | `String` | one of QUEUED, RENDERING, POSTFLIGHT, RETRYING, SUCCEEDED, FAILED, REFUNDED |
| failureReason | `String` | nullable; examples: missing_face, corrupted_upload, identity_drift, unreadable_text, render_failed |
| creditConsumed | `Boolean` | true only after a successful shareable output or paid successful generation |
| createdAtEpochMillis | `Long` |  |
| updatedAtEpochMillis | `Long` |  |

### UserQuota (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | document id from Firebase anonymous auth |
| successfulFreeGenerations | `Int` | server increments only when postflight marks output shareable |
| freeGenerationLimit | `Int` | constant value 3 for v1 |
| activeSubscription | `Boolean` | updated after RevenueCat entitlement verification |
| lastUpdatedEpochMillis | `Long` |  |

### RemoteGenerationJob (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| jobId | `String` | document id |
| uid | `String` | owner Firebase Auth uid |
| templateId | `String` |  |
| languageTag | `String` | BCP-47 language selected in onboarding |
| sourcePhotoStoragePath | `String` |  |
| status | `String` | QUEUED, RENDERING, POSTFLIGHT, RETRYING, SUCCEEDED, FAILED, REFUNDED |
| attemptCount | `Int` | max 2 attempts in v1 before refund/failure |
| resultVideoStoragePath | `String` | nullable until success |
| failureReason | `String` | nullable |
| creditConsumed | `Boolean` |  |
| createdAtEpochMillis | `Long` |  |
| updatedAtEpochMillis | `Long` |  |

## Features

### Ad-free onboarding and creation path

The user can select language, choose a template, upload a photo, and start generation without seeing ads.

- **Answers complaint:** Ads before value

- **Screens:** Onboarding, Template Gallery, Photo Crop, Preflight Review, Generation Progress

- **Estimated hours:** 24

**Implementation notes:** Do not include any mobile ads SDK dependency or ad placement code. On app start, route to Onboarding if onboardingComplete is false, then to Template Gallery. Language selection is a Compose LazyColumn of fixed BCP-47 tags supported by active templates. Every CTA in onboarding, template selection, photo crop, preflight, and generation must navigate directly to the next route with no interstitial screen. Add a debug-only Gradle dependency check test that fails if any resolved dependency group contains `com.google.android.gms.ads`, `com.google.ads`, `applovin`, `ironsource`, `unity3d.ads`, or `facebook.ads`.

**Acceptance criteria:**
- A fresh install reaches the Template Gallery after language selection without any ad UI.
- Tapping three different templates never displays an ad or ad placeholder.
- The release dependency graph contains no known ad SDK group names.
- No screen before Result contains a button or banner labeled as an advertisement.

### Template gallery with language-aware prompt keys

Users can browse prank templates and only select templates compatible with their selected language.

- **Answers complaint:** baseline parity

- **Screens:** Template Gallery, Settings

- **Estimated hours:** 40

**Implementation notes:** Seed Room with at least six Template rows from a bundled JSON asset on first launch. Each Template has id, title, thumbnailUrl, durationSeconds, supportedLanguages, promptKey, and isActive. The gallery filters active templates where supportedLanguages contains the selected onboarding language; if none match, show an empty state with a button to change language. Template cards navigate to `photo/{templateId}`. The app never stores raw AI prompts locally; it sends promptKey to the backend so prompt design can change without shipping a new app.

**Acceptance criteria:**
- When the selected language is supported by three templates, exactly those three active templates are visible.
- Inactive templates are not displayed.
- A template tap opens Photo Crop with the selected template id in the route.
- If the selected language has no templates, the empty state offers language change instead of showing a broken grid.

### Photo picker, crop, preview, and upload

Users can pick a source photo, crop it for the chosen template, preview it, and upload it for preflight.

- **Answers complaint:** baseline parity

- **Screens:** Photo Crop

- **Estimated hours:** 56

**Implementation notes:** Use `ActivityResultContracts.PickVisualMedia(ActivityResultContracts.PickVisualMedia.ImageOnly)` from activity-compose so no media permission is required. Decode the selected Uri with `ImageDecoder` on API 28+ and `BitmapFactory` fallback for API 26-27. Implement crop in Compose by displaying the bitmap inside a square Box with transformable pan/zoom state; on confirm, map the visible crop rectangle back to source bitmap coordinates and export a JPEG at 90 quality with max dimension 1280 px. Upload the cropped JPEG to Firebase Storage path `users/{uid}/uploads/{uuid}.jpg`, then navigate to `preflight/{templateId}/{uploadId}` where uploadId is the UUID saved in local Room with the Storage path.

**Acceptance criteria:**
- Photo selection uses Android Photo Picker and does not request READ_MEDIA_IMAGES.
- A 4000x3000 image is cropped and uploaded as a JPEG whose longest side is no more than 1280 px.
- If bitmap decoding fails, Photo Crop shows a photo decode error and does not upload anything.
- If Firebase Storage upload fails, the screen keeps the crop state and shows a retryable upload error.

### AI preflight checks before credit use

The app rejects missing-face, low-resolution, corrupted, or template-mismatched photos before charging a free generation or paid credit.

- **Answers complaint:** Bad or failed outputs

- **Screens:** Preflight Review

- **Estimated hours:** 52

**Implementation notes:** Call Firebase Callable Function `preflightSourcePhoto` with `{ templateId, uploadStoragePath, languageTag }`. The function returns `{ passed: Boolean, code: String?, details: String? }` where failure codes are `missing_face`, `low_resolution`, `corrupted_upload`, and `template_mismatch`. While waiting, show the checking state. If passed is false, display a checklist row marked failed and keep the Generate button disabled; show Retake Photo. Do not create a RemoteGenerationJob and do not increment successfulFreeGenerations on any preflight failure. If the function times out or throws, show `check service error` with a Retry check button.

**Acceptance criteria:**
- A preflight response with code `missing_face` shows the missing-face state and no generation job is created.
- A preflight response with code `corrupted_upload` tells the user to choose another photo and consumes no free generation.
- A passed preflight enables the Generate button.
- A network error keeps the user on Preflight Review with retry available and consumes no credit.

### AI video generation job integration

The app creates a server-side generation job, displays progress, and opens the result only after a successful render.

- **Answers complaint:** baseline parity

- **Screens:** Preflight Review, Generation Progress, Result, Library

- **Estimated hours:** 64

**Implementation notes:** On Generate, first read Firestore `userQuota/{uid}`. If `successfulFreeGenerations < 3` or RevenueCat entitlement `pro_monthly` is active, call Firebase Callable Function `createGenerationJob` with `{ templateId, uploadStoragePath, languageTag }`. The backend creates Firestore `generationJobs/{jobId}` with status QUEUED and starts the external AI video API call. The app writes/updates the local GenerationJob row and listens to the Firestore document using a snapshot listener. Map remote statuses QUEUED, RENDERING, POSTFLIGHT, RETRYING, SUCCEEDED, FAILED, REFUNDED to the Generation Progress states. Navigate to `result/{jobId}` only when status is SUCCEEDED and resultVideoStoragePath resolves to a download URL.

**Acceptance criteria:**
- A successful createGenerationJob response navigates to Generation Progress with the returned jobId.
- Progress UI updates within one snapshot after Firestore status changes from QUEUED to RENDERING.
- The Result screen is not reachable for a job without SUCCEEDED status.
- A missing Firestore job document shows job not found rather than crashing.

### Postflight retry and automatic refund

Failed, corrupted, identity-drifted, unreadable, or unshareable outputs are retried once and then refunded without consuming the free quota.

- **Answers complaint:** Refund failures automatically

- **Screens:** Generation Progress, Library, Template Gallery

- **Estimated hours:** 52

**Implementation notes:** The backend performs postflight quality control after the external AI API returns a video. The app must handle Firestore status `POSTFLIGHT` while checks run, `RETRYING` when attemptCount is 1 and the backend starts one automatic retry, `REFUNDED` when attemptCount reaches 2 or the failure is non-retryable, and `FAILED` only for technical failure where no credit was consumed. Display failureReason strings `identity_drift`, `unreadable_text`, `render_failed`, or `corrupted_output` in plain language. The app trusts the Firestore field `creditConsumed`; free quota UI must decrement only when `creditConsumed == true` and status == SUCCEEDED.

**Acceptance criteria:**
- When Firestore sets status RETRYING and attemptCount 1, the progress screen says the app is retrying automatically.
- When Firestore sets status REFUNDED with creditConsumed false, the quota chip remains unchanged.
- A refunded job appears in Library under the Refunded filter with its failure reason.
- A failed postflight never navigates to Result.

### Transparent free quota and subscription paywall

Users receive three successful free videos and only then see a clear $4.99/month subscription paywall.

- **Answers complaint:** Surprise payment at generation

- **Screens:** Template Gallery, Preflight Review, Paywall, Settings

- **Estimated hours:** 60

**Implementation notes:** Use Firestore `UserQuota.successfulFreeGenerations` with limit 3 and RevenueCat entitlement id `pro_monthly`. On every Generate tap after preflight passes, load quota and RevenueCat customer info. If free successes remain, create the job without showing Paywall. If free successes are exhausted and entitlement is inactive, navigate to Paywall before creating a job. The Paywall must state `$4.99/month`, include Restore purchases, and never appear before a user has three SUCCEEDED jobs with creditConsumed true. Configure RevenueCat purchase package lookup by monthly package identifier; on purchase success, refresh customer info and navigate back to Preflight Review to let the user tap Generate again.

**Acceptance criteria:**
- A user with 0, 1, or 2 successful free generations can start generation without seeing Paywall.
- A user with exactly 3 successful free generations and no entitlement is routed to Paywall when tapping Generate.
- The Paywall visibly contains the text `$4.99/month`.
- Restore purchases updates Settings to subscriber state when RevenueCat returns active `pro_monthly` entitlement.

### Save and share generated videos

Users can play, save to device media storage, and share each successful generated prank video.

- **Answers complaint:** baseline parity

- **Screens:** Result

- **Estimated hours:** 36

**Implementation notes:** Use Media3 ExoPlayer to stream the Firebase Storage download URL in Result. For Save, download the video bytes with `URL.openStream()` on Dispatchers.IO and insert into MediaStore.Video with DISPLAY_NAME `pranktrust_{jobId}.mp4`, MIME_TYPE `video/mp4`, and RELATIVE_PATH `Movies/PrankTrust Studio` on API 29+; on API 26-28, write to app-specific external files and trigger ACTION_MEDIA_SCANNER_SCAN_FILE. For Share, use ACTION_SEND with a content Uri from a FileProvider for the downloaded cached mp4. Save and share buttons are disabled until status is SUCCEEDED and the URL is resolved.

**Acceptance criteria:**
- A SUCCEEDED job plays in the Result video player from its download URL.
- Tapping Save creates an mp4 visible in the device Movies/PrankTrust Studio folder on API 29+.
- Tapping Share launches an ACTION_SEND chooser with MIME type video/mp4.
- If video download fails, Result shows save error and keeps playback controls visible.

### Generation library and status transparency

Users can revisit completed, in-progress, failed, and refunded generation jobs.

- **Answers complaint:** Trust and safety discomfort

- **Screens:** Library, Generation Progress, Result

- **Estimated hours:** 32

**Implementation notes:** Persist every job locally in Room immediately after createGenerationJob returns. Sync Firestore status changes into the same local row. Library reads Room via Flow and displays filter tabs All, In progress, Complete, and Refunded. In-progress includes QUEUED, RENDERING, POSTFLIGHT, and RETRYING. Complete includes SUCCEEDED. Refunded includes REFUNDED. Failed includes FAILED in All only with an explicit no-credit-consumed message. Tapping a SUCCEEDED row opens Result; tapping any active row opens Generation Progress; tapping a refunded or failed row opens Generation Progress in its terminal state.

**Acceptance criteria:**
- A newly created job appears in Library within one local database emission.
- Changing a remote job to REFUNDED moves it into the Refunded filter.
- Tapping a completed job opens Result.
- Empty Library shows a Create button that navigates to Template Gallery.

### Privacy-forward settings and no-ad trust messaging

The app clearly explains that it has no ad SDKs, uses photos for generation, and does not charge failed outputs.

- **Answers complaint:** Trust and safety discomfort

- **Screens:** Settings

- **Estimated hours:** 16

**Implementation notes:** Settings displays a static privacy promise card with three bullets: `No ad SDKs in the app`, `Your photo is uploaded only to create the selected video`, and `Failed or unshareable generations do not use a free success`. Add a Delete local history action that deletes all Room GenerationJob rows and cached local video files, but does not delete server-side subscription records. The privacy policy link opens `https://example.com/pranktrust-studio/privacy` in a browser Custom Tab or ACTION_VIEW fallback.

**Acceptance criteria:**
- Settings contains the exact text `No ad SDKs in the app`.
- Delete local history removes all local Library rows after confirmation.
- Privacy policy tap opens the configured privacy URL.
- The app remains signed in after local history deletion so subscription status can still restore.

### Minimal analytics for funnel QA

The app records non-ad funnel events for activation, generation success, generation refund, and paywall display.

- **Answers complaint:** baseline parity

- **Screens:** Onboarding, Preflight Review, Generation Progress, Paywall

- **Estimated hours:** 20

**Implementation notes:** Use Firebase Analytics only; do not add any ad attribution SDK. Log `onboarding_completed` with languageTag, `preflight_failed` with code, `generation_succeeded` with templateId, `generation_refunded` with failureReason, and `paywall_shown` with freeSuccessesUsed. Never log raw photo paths, download URLs, user-entered text, or face-check details beyond the fixed failure code. Wrap analytics logging behind an AnalyticsLogger interface so unit tests can assert event names without Firebase.

**Acceptance criteria:**
- Completing onboarding logs `onboarding_completed` once.
- A preflight failure logs `preflight_failed` with one of the documented fixed codes.
- Showing Paywall logs `paywall_shown` with freeSuccessesUsed equal to 3.
- No analytics event payload includes sourcePhotoStoragePath or resultVideoUrl.

## Store listing

- **Title:** PrankTrust Studio
- **Short description:** Ad-free AI prank videos with 3 successful free creations.
- **Category:** Entertainment
- **Keywords:** AI prank video, prank video generator, AI video, face video, photo to video, no ads, video templates
- **Icon prompt:** A modern Android app icon for an AI prank video generator: dark violet background, rounded square, centered white video camera glyph with a small playful sparkle near the lens, clean flat vector style, high contrast, no text, no watermark.

**Long description:**

Make AI prank videos without the usual first-run frustration. PrankTrust Studio gives you three successful free video generations before any subscription ask, with no ads blocking onboarding, template selection, photo upload, or creation.

Pick a prank template, add a photo, run a preflight quality check, and generate a short shareable video. If the upload is corrupted, the face is missing, the render fails, the result drifts too far from the person, or the output is not shareable, the app does not consume your free success.

After three successful free videos, continue with a clear $4.99/month plan. No surprise paid-video prompt after you already uploaded a photo. No interstitial ads after every tap. Just a cleaner, more transparent AI prank video workflow.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://example.com/pranktrust-studio/privacy (privacy claims verified: no)
- **Data collected:** Uploaded photos for AI video generation; Generated videos; Anonymous Firebase user identifier; Subscription and purchase status; Generation job status and failure reason; Selected app language; Basic app interaction analytics events

## Test plan

### 1. Ads before value (instrumented)

1. Install a fresh debug build with cleared app data.
2. Launch MainActivity.
3. On Onboarding, select English and tap Continue.
4. On Template Gallery, tap the first visible template.
5. On Photo Crop, press Back to return to Template Gallery.
6. Inspect the Compose tree text nodes after each step.

**Expected:** No visible node contains `Ad`, `Sponsored`, `Watch ad`, or any ad placeholder text, and the flow reaches Photo Crop without an intermediate screen.

### 2. Ads before value (unit)

1. Run a Gradle dependency report task for releaseRuntimeClasspath.
2. Parse the resolved dependency group and module names.
3. Compare each dependency string against blocked substrings: `com.google.android.gms.ads`, `com.google.ads`, `applovin`, `ironsource`, `unity3d.ads`, and `facebook.ads`.

**Expected:** The blocked substring list has zero matches in releaseRuntimeClasspath.

### 3. Surprise payment at generation (unit)

1. Create a fake UserQuota with successfulFreeGenerations = 0, freeGenerationLimit = 3, activeSubscription = false.
2. Call the Generate eligibility use case after a passed preflight.
3. Repeat with successfulFreeGenerations = 1 and 2.
4. Record the returned navigation/action for each case.

**Expected:** All three cases return CreateGenerationJob, not ShowPaywall.

### 4. Surprise payment at generation (unit)

1. Create a fake UserQuota with successfulFreeGenerations = 3, freeGenerationLimit = 3, activeSubscription = false.
2. Call the Generate eligibility use case after a passed preflight.
3. Create another fake UserQuota with successfulFreeGenerations = 3 and activeSubscription = true.
4. Call the same use case again.

**Expected:** The unsubscribed quota returns ShowPaywall; the subscribed quota returns CreateGenerationJob.

### 5. Bad or failed outputs (instrumented)

1. Open Preflight Review with a fake uploaded photo id.
2. Configure the fake Firebase Functions service to return `{ passed: false, code: 'missing_face' }` for `preflightSourcePhoto`.
3. Wait for the checking state to finish.
4. Inspect visible text and enabled state of the Generate button.

**Expected:** The screen shows a missing-face failure message, the Generate button is disabled, and no GenerationJob row exists in Room.

### 6. Bad or failed outputs (instrumented)

1. Open Preflight Review with a fake uploaded photo id.
2. Configure the fake Firebase Functions service to return `{ passed: false, code: 'corrupted_upload' }`.
3. Wait for the checking state to finish.
4. Tap Retake Photo.

**Expected:** The app returns to Photo Crop, no RemoteGenerationJob is created, and freeSuccessesRemaining remains unchanged.

### 7. Refund failures automatically (unit)

1. Insert a local GenerationJob with status RENDERING and creditConsumed false.
2. Apply a fake Firestore update with status RETRYING, attemptCount 1, failureReason `render_failed`, creditConsumed false.
3. Apply a second fake Firestore update with status REFUNDED, attemptCount 2, failureReason `identity_drift`, creditConsumed false.
4. Read the quota display model and library filter model.

**Expected:** The quota display model does not decrement, and the job appears in the Refunded filter with reason `identity_drift`.

### 8. Refund failures automatically (instrumented)

1. Open Generation Progress for a fake job id.
2. Emit Firestore snapshot status POSTFLIGHT.
3. Emit Firestore snapshot status RETRYING with attemptCount 1.
4. Emit Firestore snapshot status REFUNDED with failureReason `unreadable_text` and creditConsumed false.

**Expected:** The screen shows postflight checking, then automatic retrying, then a refunded terminal state; it never navigates to Result.

### 9. baseline parity (manual)

1. Install the app on an Android 13 or newer device.
2. Complete onboarding.
3. Choose any visible template.
4. Tap Pick photo and select a real portrait image from the Android Photo Picker.
5. Crop the image and tap Use this photo.
6. Confirm that Preflight Review appears.

**Expected:** The app uses the system Photo Picker without requesting READ_MEDIA_IMAGES, uploads the cropped photo, and opens Preflight Review.

### 10. baseline parity (manual)

1. Use a test backend job that returns SUCCEEDED with a valid mp4 download URL.
2. Open Result for that job.
3. Tap Play, then Save, then Share.
4. Open the device gallery or file browser and inspect Movies/PrankTrust Studio.

**Expected:** The video plays, an mp4 file named with the job id is saved under Movies/PrankTrust Studio, and the Android share sheet opens with MIME type video/mp4.

### 11. Trust and safety discomfort (instrumented)

1. Navigate to Settings.
2. Find the privacy promise card.
3. Tap Delete local history and confirm.
4. Navigate to Library.

**Expected:** Settings shows `No ad SDKs in the app`; after deletion, Library is empty while the signed-in subscription/status area still loads.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew lintRelease
./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
