# ClearReel Drama — build spec

A short-drama viewer that makes the exact advertised story, free limits, season completion status, and Google Play cancellation path visible before viewers pay.

## 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:** StoryReel: Exclusive Drama
- **Package id:** `com.storyreel.tv.android`
- **Google Play:** https://play.google.com/store/apps/details?id=com.storyreel.tv.android
- **appy.fyi report:** https://appy.fyi/report/com.storyreel.tv.android
- **Category:** Entertainment

## Overview

- **Working name:** ClearReel Drama (trademark cleared: no)
- **Package id:** `fyi.appy.clearreeldrama`
- **Min / target SDK:** 26 / 35
- **Backend:** firebase
- **Estimated build time:** 12 weeks
- **Pricing:** subscription, $12.99 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** `INTERNET`, `POST_NOTIFICATIONS`

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

- No off-store payments, coin packs, hidden credit balances, or external subscription management in v1.
- No weekly subscription plan; v1 offers only the report’s $12.99/month all-access subscription.
- No promise that every episode or season is free; v1 explicitly labels the free episode count before playback.
- No giant drama studio or user-generated content platform; v1 only supports a curated catalog managed by an internal admin flow.
- No child-targeted experience, health advice, financial advice, or legal/evidence record keeping.
- No runtime AI continuity classifier in v1; content trust checks are enforced through a mandatory human admin checklist.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Android app compatibility and Kotlin extensions | `androidx.core:core-ktx:1.13.1` |
| Compose activity host | `androidx.activity:activity-compose:1.9.3` |
| Material 3 Compose UI components | `androidx.compose.material3:material3:1.3.0` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware ViewModel state collection in Compose | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Media playback for short-drama episodes | `androidx.media3:media3-exoplayer:1.4.1` |
| HLS playback support for hosted episode streams | `androidx.media3:media3-exoplayer-hls:1.4.1` |
| Media3 PlayerView integration for the Compose player screen | `androidx.media3:media3-ui:1.4.1` |
| Image loading for story covers and episode thumbnails | `io.coil-kt:coil-compose:2.7.0` |
| Firestore catalog, story, episode, entitlement, and admin-review storage | `com.google.firebase:firebase-firestore:25.1.1` |
| Firebase anonymous auth and admin custom-claim checks | `com.google.firebase:firebase-auth:23.1.0` |
| Firebase Storage URL access for cover images and video assets when not using external CDN URLs | `com.google.firebase:firebase-storage:21.0.1` |
| Trust-funnel analytics events for deep links, paywalls, billing screen opens, and cancellation opens | `com.google.firebase:firebase-analytics:22.1.2` |
| Google Play subscription purchase flow | `com.android.billingclient:billing-ktx:7.1.1` |
| Rewarded ad viewing for transparent free episode unlocks | `com.google.android.gms:play-services-ads:23.6.0` |
| Offline episode download jobs | `androidx.work:work-runtime-ktx:2.9.1` |
| Local persistence for viewing progress, recent searches, and downloads | `androidx.room:room-runtime:2.6.1` |
| Kotlin coroutine Room APIs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor via KSP | `androidx.room:room-compiler:2.6.1` |
| KSP Gradle plugin for Room code generation | `com.google.devtools.ksp:symbol-processing-gradle-plugin:2.0.21-1.0.28` |
| Preferences for first-run flags and non-sensitive app settings | `androidx.datastore:datastore-preferences:1.1.1` |
| Coroutine Tasks await support for Firebase APIs | `org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.9.0` |
| JSON parsing for BillingClient purchase payload snapshots stored locally for receipt display | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |

## Design system

- **Primary color:** `#6D3DF2`
- **Background color:** `#0F1020`
- **Error color:** `#D92D20`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `play-circle` (regular weight)
- **Theme notes:** Default to dark theme because the app is video-first. Use #0F1020 for dark background, #FFFFFF primary text, #C8C7D9 secondary text, #6D3DF2 primary buttons, and #18A058 success labels for Complete/Playable status. Light theme uses #FFFFFF background, #151522 text, and the same primary/error colors. Do not use red for paywalls; red is reserved only for errors or blocked/unavailable content.

## Screens

### Home Feed
- **Route:** `home`
- **Purpose:** Browse live short-drama stories and resume the last watched episode.
- **Reached via:** app launch; bottom navigation Home; back from Story Detail; back from Player
- **Key UI elements:** Top app bar with app name, search icon, settings icon; Vertical list of story cards with cover image, title, completion badge, free episode count, and resume button; Continue Watching carousel when local progress exists; Bottom action area on each card: Watch, Details
- **States:** loading, empty, error, populated

### Search
- **Route:** `search?initialQuery={initialQuery}`
- **Purpose:** Find a story by the exact title named in an ad or by catalog title keywords.
- **Reached via:** tap search icon on Home Feed; receive ad deep link with missing or unmatched slug; tap Search from Story Detail
- **Key UI elements:** Search text field focused on entry; Result list with title, cover, completion badge, and free episode count; No-result panel that says the title is not currently live instead of hiding the failure; Clear query button
- **States:** idle, loading, empty, error, populated

### Story Detail
- **Route:** `story/{storyId}`
- **Purpose:** Show the viewer exactly what is available before episode one: season status, total episodes, free episodes, and all-access price.
- **Reached via:** tap story card on Home Feed; tap result on Search; open clearreel://story/{slug} or HTTPS ad deep link; back from Paywall
- **Key UI elements:** Cover image and title; Season status badge: Complete, Incomplete, or In review; Total episode count; Free episode count label using the exact number from Firestore; Monthly all-access price label: $12.99/month through Google Play; Episode list with locked/unlocked status; Watch Episode 1 button; Subscribe button when entitlement is inactive
- **States:** loading, not_found, blocked_unavailable, error, populated

### Episode Player
- **Route:** `story/{storyId}/episode/{episodeNumber}`
- **Purpose:** Play an unlocked episode and route the viewer to the transparent paywall only when free or rewarded access is exhausted.
- **Reached via:** tap Watch on Home Feed; tap episode row on Story Detail; tap Next episode in Episode Player; tap downloaded episode on Downloads
- **Key UI elements:** Media3 video player; Episode title and number overlay; Next episode button; Completion label on final episode; Download button for eligible episodes; Paywall interstitial when episode is locked
- **States:** loading, buffering, playing, ended, locked, error

### Transparent Paywall
- **Route:** `paywall/{storyId}/{episodeNumber}`
- **Purpose:** Explain why the next episode is locked and offer only the supported unlock paths: rewarded ad or $12.99/month Google Play subscription.
- **Reached via:** attempt to open a locked episode from Episode Player; attempt to open a locked episode from Story Detail
- **Key UI elements:** Story title and requested episode number; Text stating how many episodes were free before the paywall; Rewarded ad button for one-episode unlock when an ad is available; Subscribe for $12.99/month button; Google Play only billing notice; Not now button returning to Story Detail
- **States:** loading_product, ad_available, ad_unavailable, purchase_in_progress, purchase_error, entitled

### Settings
- **Route:** `settings`
- **Purpose:** Provide entry points to billing, downloads, privacy information, and admin review for admin users.
- **Reached via:** tap settings icon on Home Feed
- **Key UI elements:** Billing and cancellation row; Downloads row; Privacy policy row; Admin review row shown only when Firebase custom claim isAdmin=true; App version text
- **States:** loading_user, regular_user, admin_user, error

### Billing Center
- **Route:** `billing`
- **Purpose:** Make subscription status, Google Play cancellation, and receipt/refund information visible in-app.
- **Reached via:** tap Billing and cancellation in Settings; tap billing notice from Transparent Paywall; successful purchase from Transparent Paywall
- **Key UI elements:** Current entitlement status: Active, Expired, or None; Product label: Monthly all-access, $12.99/month; Open Google Play cancellation button; Latest receipt row; Refund help text directing users to Google Play order history; Restore purchases button
- **States:** loading, no_subscription, active_subscription, expired_subscription, error

### Receipt Detail
- **Route:** `receipt/{purchaseTokenHash}`
- **Purpose:** Show the locally available purchase record without pretending support can cancel outside Google Play.
- **Reached via:** tap latest receipt row on Billing Center
- **Key UI elements:** Product id; Purchase date; Entitlement period end when known; Purchase token hash, never the raw token; Open Google Play order history button; Back to Billing Center button
- **States:** loading, not_found, populated

### Downloads
- **Route:** `downloads`
- **Purpose:** Show episodes downloaded into app-specific storage for offline playback.
- **Reached via:** tap Downloads in Settings; tap download-complete notification; tap Download button in Episode Player
- **Key UI elements:** Download list grouped by story; Progress indicator for active WorkManager downloads; Delete download button; Open episode button; Empty state explaining downloads are stored only inside this app
- **States:** loading, empty, downloading, error, populated

### Admin Review Queue
- **Route:** `admin/review`
- **Purpose:** Allow an internal reviewer to see draft stories, incomplete stories, blocked ad creatives, and items failing the required trust checklist before publishing.
- **Reached via:** tap Admin review in Settings when isAdmin=true
- **Key UI elements:** Admin-only access guard; Tabs: Stories, Episodes, Ad creatives; Rows showing status: draft, in_review, blocked, live; Open editor button; Publish-blocking issue count
- **States:** checking_admin, unauthorized, loading, empty, error, populated

### Admin Story Editor
- **Route:** `admin/story/{storyId}`
- **Purpose:** Edit story metadata and complete the exact-title, free-limit, season-completion, and continuity checklist required before publishing.
- **Reached via:** tap story row in Admin Review Queue; tap create story in Admin Review Queue
- **Key UI elements:** Title field; Slug field used by ad deep links; Synopsis field; Season completion selector; Total episodes field; Free episode count field; Episode checklist with published/ending status; Ad creative checklist with promised title and linked story; Save draft button; Publish button disabled until all required checks pass
- **States:** checking_admin, unauthorized, loading, validation_error, saving, error, populated

## Data model

### UserProfile (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | primary key, Firebase Auth uid |
| createdAt | `Instant` | server timestamp |
| isAdmin | `Boolean` | mirrors Firebase custom claim for UI gating only; security rules must enforce the claim |
| lastSeenPrivacyVersion | `String` | nullable |

### Story (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id |
| slug | `String` | unique, lowercase, used by ad deep links |
| title | `String` | searchable exact title |
| synopsis | `String` |  |
| coverImageUrl | `String` | HTTPS or Firebase Storage URL |
| status | `String` | one of draft, in_review, live, blocked |
| seasonCompletionStatus | `String` | one of complete, incomplete, in_review |
| totalEpisodes | `Int` | must equal count of published Episode rows before story can be marked complete |
| freeEpisodeCount | `Int` | shown before playback and used by paywall rules |
| monthlyPriceLabel | `String` | literal display label for v1: $12.99/month |
| updatedAt | `Instant` | server timestamp |

### Episode (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id |
| storyId | `String` | foreign key to Story.id |
| episodeNumber | `Int` | 1-based, unique within story |
| title | `String` |  |
| videoUrl | `String` | HLS .m3u8 URL preferred |
| thumbnailUrl | `String` | HTTPS or Firebase Storage URL |
| durationMs | `Long` |  |
| isPublished | `Boolean` | unpublished episodes never appear to viewers |
| endingStatus | `String` | one of continues, final_complete, abrupt_flagged |
| continuityNotes | `String` | nullable admin notes |
| createdAt | `Instant` | server timestamp |

### AdCreative (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id |
| storyId | `String` | foreign key to Story.id |
| promisedTitle | `String` | exact title shown in the social ad |
| deepLinkUrl | `String` | clearreel://story/{slug} or HTTPS equivalent |
| status | `String` | one of draft, eligible, blocked, retired |
| blockReason | `String` | nullable; required when status is blocked |
| lastReviewedAt | `Instant` | nullable server timestamp |

### ContentReview (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id |
| storyId | `String` | foreign key to Story.id |
| reviewerUid | `String` | Firebase Auth uid of admin reviewer |
| exactAdTitleMatchesStory | `Boolean` | required true before ad creative can be eligible |
| seasonMarkedCompleteOnlyIfAllEpisodesLive | `Boolean` | required true before complete badge can be shown |
| freeLimitConfirmed | `Boolean` | required true before story can be live |
| continuityNamesAndVisualsChecked | `Boolean` | required true before story can be live |
| notes | `String` | nullable |
| updatedAt | `Instant` | server timestamp |

### Entitlement (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| uid | `String` | Firebase Auth uid |
| productId | `String` | v1 value: monthly_all_access_1299 |
| active | `Boolean` | true only while Google Play subscription is active according to latest client restore or backend verification |
| purchaseTokenHash | `String` | SHA-256 hash of purchase token; raw token is not displayed |
| source | `String` | v1 value: GOOGLE_PLAY |
| currentPeriodEnd | `Instant` | nullable if not returned by verification yet |
| updatedAt | `Instant` | server timestamp |

### AdRewardReceipt (`firestore`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | document id |
| uid | `String` | Firebase Auth uid |
| storyId | `String` | foreign key to Story.id |
| episodeNumberUnlocked | `Int` | one rewarded ad unlocks one episode |
| rewardEarnedAt | `Instant` | server timestamp |
| expiresAt | `Instant` | nullable; null means no expiry in v1 |

### ViewingProgress (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| storyId | `String` | primary key part |
| episodeNumber | `Int` | primary key part |
| positionMs | `Long` | last playback position |
| completed | `Boolean` |  |
| updatedAtEpochMs | `Long` |  |

### DownloadItem (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key, storyId_episodeNumber |
| storyId | `String` |  |
| episodeNumber | `Int` |  |
| title | `String` |  |
| localFilePath | `String` | path under app-specific files directory |
| thumbnailUrl | `String` |  |
| status | `String` | one of queued, downloading, downloaded, failed |
| progressPercent | `Int` | 0 to 100 |
| updatedAtEpochMs | `Long` |  |

### RecentSearch (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| query | `String` | primary key |
| lastUsedAtEpochMs | `Long` |  |

## Features

### Short-drama feed and player

Lets viewers browse live stories and watch unlocked short episodes in a vertical video-first player.

- **Answers complaint:** baseline parity

- **Screens:** Home Feed, Story Detail, Episode Player

- **Estimated hours:** 75

**Implementation notes:** Load live Story documents from Firestore with query status == 'live', ordered by updatedAt descending, and render them on Home Feed. On episode open, fetch Episode by storyId and episodeNumber; if unlocked, create a Media3 ExoPlayer with MediaItem.fromUri(videoUrl), prepare it, and bind it to PlayerView inside Compose AndroidView. Persist playback position to Room ViewingProgress every 5 seconds and on pause. If ExoPlayer reports Player.STATE_ENDED, mark completed=true and show the Next Episode button. If videoUrl is empty, Episode.isPublished=false, or Firestore read fails, show the screen’s error state instead of a blank player.

**Acceptance criteria:**
- Home Feed displays only Story records whose status is live.
- Tapping Watch on a live story opens episode 1 and starts buffering with Media3.
- Leaving the player after at least 5 seconds creates or updates a ViewingProgress row with the current position.
- Returning to the same episode resumes within 2 seconds of the saved position.
- An unpublished episode never plays and produces the locked or error state instead.

### Upfront season completion and free-limit labels

Shows whether a season is complete, how many episodes exist, and exactly how many episodes are free before the viewer starts episode one.

- **Answers complaint:** Sell complete seasons with upfront limits.

- **Screens:** Home Feed, Story Detail, Episode Player, Transparent Paywall

- **Estimated hours:** 55

**Implementation notes:** On Story Detail, always render Story.seasonCompletionStatus, Story.totalEpisodes, Story.freeEpisodeCount, and the literal monthlyPriceLabel before the episode list. Use green Complete badge only when seasonCompletionStatus == 'complete'. Use amber Incomplete or In review badges otherwise. Episode rows are unlocked when episodeNumber <= freeEpisodeCount, an active Entitlement exists, or an AdRewardReceipt exists for that uid/storyId/episodeNumber. The Watch Episode 1 button is disabled only if episode 1 is unpublished or the story status is not live. Do not use marketing copy such as 'unlimited free' anywhere in the UI.

**Acceptance criteria:**
- A story with freeEpisodeCount=3 displays 'First 3 episodes free' on Home Feed and Story Detail.
- A story with seasonCompletionStatus='incomplete' never displays the green Complete badge.
- The $12.99/month all-access label is visible on Story Detail before the user starts playback.
- Episode 4 of a story with freeEpisodeCount=3 routes to Transparent Paywall when the user has no entitlement or reward receipt.

### Exact advertised story deep links and search

Routes every ad deep link to the exact live story slug and makes the advertised title searchable after install.

- **Answers complaint:** Guarantee every ad opens the exact story shown.

- **Screens:** Search, Story Detail, Admin Review Queue, Admin Story Editor

- **Estimated hours:** 60

**Implementation notes:** Register Android intent filters for clearreel://story/{slug} and an HTTPS app link path /story/{slug}. When an Intent arrives, parse slug, query Firestore Story where slug == parsed slug and status == 'live'. If exactly one live Story is found, navigate to Story Detail for that storyId. If none is found, navigate to Search with initialQuery set to the slug converted from hyphen case to spaces and display the no-result panel saying the title is not currently live. In Admin Story Editor, disable AdCreative.status='eligible' unless AdCreative.promisedTitle.trim().equals(Story.title.trim(), ignoreCase=true), Story.status='live', and Story.seasonCompletionStatus is not 'in_review'.

**Acceptance criteria:**
- Opening clearreel://story/promised-title-a navigates to the matching live Story Detail when Story.slug='promised-title-a'.
- Opening clearreel://story/missing-title navigates to Search and shows an empty state explaining the title is not currently live.
- Search for an exact live story title returns that story as the first result.
- Admin cannot mark an AdCreative eligible when promisedTitle differs from Story.title ignoring case and surrounding whitespace.

### Transparent free episodes, rewarded viewing, and paywall rules

Allows the configured free episodes, offers a rewarded ad for a one-episode unlock when available, and otherwise shows the $12.99/month Google Play subscription clearly.

- **Answers complaint:** Free promise becomes paywall

- **Screens:** Episode Player, Transparent Paywall, Story Detail

- **Estimated hours:** 50

**Implementation notes:** Implement an AccessResolver function that returns UNLOCKED_FREE when episodeNumber <= Story.freeEpisodeCount, UNLOCKED_SUBSCRIPTION when Entitlement.active=true for productId monthly_all_access_1299, UNLOCKED_REWARDED when an AdRewardReceipt exists for the requested episode, and LOCKED otherwise. When LOCKED, navigate to Transparent Paywall. Load a Google Mobile Ads rewarded ad on Paywall entry using the configured rewarded ad unit id from remote config or BuildConfig. After OnUserEarnedRewardListener fires, write AdRewardReceipt(uid, storyId, episodeNumberUnlocked) to Firestore, then navigate back to Episode Player. If the ad fails to load, keep the subscription button visible and show 'Rewarded viewing is unavailable right now' without blocking the Not now button.

**Acceptance criteria:**
- Episodes numbered less than or equal to freeEpisodeCount play without showing a paywall.
- The first locked episode shows text stating the number of free episodes already included.
- If a rewarded ad completes, exactly one AdRewardReceipt is created for the requested episode and that episode becomes playable.
- If a rewarded ad fails to load, the screen remains usable and still shows the $12.99/month subscription option.
- No screen claims unlimited free viewing.

### Google Play-only monthly subscription

Sells the report’s $12.99/month all-access subscription through Google Play Billing and restores entitlement without any off-store payment path.

- **Answers complaint:** Use Google Play billing only, with cancel visible in-app.

- **Screens:** Transparent Paywall, Billing Center, Receipt Detail

- **Estimated hours:** 65

**Implementation notes:** Use BillingClient 7.1.1. Define a single subscription product id monthly_all_access_1299 in Play Console with a $12.99/month base plan. On Paywall entry, call queryProductDetailsAsync for ProductType.SUBS and render the returned ProductDetails pricing phase; if unavailable, fall back to the fixed label '$12.99/month' and disable the subscribe button. Start purchase with BillingFlowParams using the selected offerToken. On PurchasesUpdatedListener success, acknowledge purchases with acknowledgePurchase when purchaseState == PURCHASED and isAcknowledged=false. Hash the raw purchaseToken with SHA-256 for display/storage, write Entitlement(active=true, productId, purchaseTokenHash, source='GOOGLE_PLAY') to Firestore, and call queryPurchasesAsync on app launch and Billing Center restore. Do not implement credit cards, PayPal, web checkout, coin packs, or support-managed cancellation.

**Acceptance criteria:**
- The only purchase product requested from BillingClient is monthly_all_access_1299 with ProductType.SUBS.
- A successful Play test purchase creates an active Entitlement for the Firebase uid.
- After purchase, attempting to play a previously locked episode succeeds without rewarded ad.
- Restore purchases calls queryPurchasesAsync and updates Entitlement active status from returned Play purchases.
- There is no UI entry point for off-store payment or support-managed cancellation.

### Visible cancellation, receipt, and refund path

Gives viewers an in-app Billing Center with subscription status, latest receipt details, and a direct Google Play cancellation/order-history link.

- **Answers complaint:** Cancellation and billing fear

- **Screens:** Settings, Billing Center, Receipt Detail

- **Estimated hours:** 35

**Implementation notes:** Billing Center reads active Entitlement from Firestore and latest local BillingClient purchase snapshot. The Open Google Play cancellation button launches ACTION_VIEW for https://play.google.com/store/account/subscriptions?sku=monthly_all_access_1299&package=<applicationId>. If no activity resolves the HTTPS intent, launch market://details?id=<applicationId> as a fallback. Receipt Detail displays productId, purchase time from BillingClient Purchase when available, currentPeriodEnd when present in Entitlement, and purchaseTokenHash only; never display raw purchaseToken. Refund help text must say refunds are handled through Google Play order history and include a button to https://play.google.com/store/account/orderhistory.

**Acceptance criteria:**
- Billing Center is reachable from Settings without making a purchase first.
- For an active subscription, the cancellation button opens a Google Play subscriptions URL containing sku=monthly_all_access_1299 and the app package id.
- Receipt Detail never renders the raw purchase token.
- Refund help opens Google Play order history rather than an email composer.
- If no subscription exists, Billing Center shows 'No active Google Play subscription' and still offers Restore purchases.

### Offline downloads shell

Lets entitled or free episodes be saved to app-specific storage and played from the Downloads screen.

- **Answers complaint:** baseline parity

- **Screens:** Episode Player, Downloads

- **Estimated hours:** 40

**Implementation notes:** Show the Download button only when AccessResolver returns an unlocked state. Enqueue a unique WorkManager OneTimeWorkRequest named download_<storyId>_<episodeNumber>. The worker streams episode.videoUrl with OkHttp-compatible URLConnection into context.filesDir/downloads/<storyId>_<episodeNumber>.mp4 or the HLS asset file produced by the resolved media URL; update DownloadItem.progressPercent in Room after each 256 KB chunk. On success set status='downloaded' and localFilePath. On failure set status='failed' and preserve the row for retry. Playback from Downloads creates a MediaItem from the local file Uri. Files are deleted when the user taps Delete, and the Room row is removed in the same transaction.

**Acceptance criteria:**
- Locked episodes do not show the Download button.
- Tapping Download on an unlocked episode creates a DownloadItem with status queued, then downloading, then downloaded or failed.
- Downloads screen shows progress from 0 to 100 for an active download.
- A downloaded episode plays when the device is offline and the local file exists.
- Deleting a download removes both the local file and the DownloadItem row.

### Mandatory content-admin trust checklist

Prevents publishing stories or ad creatives unless an admin confirms exact-title matching, free-limit accuracy, completion labeling, and continuity checks.

- **Answers complaint:** Incomplete or poor continuity

- **Screens:** Admin Review Queue, Admin Story Editor

- **Estimated hours:** 75

**Implementation notes:** Use Firebase Auth anonymous sign-in for all users, but show Admin Review Queue and Admin Story Editor only when the signed-in user has isAdmin=true from a Firebase custom claim and Firestore security rules allow admin writes. Admin Story Editor validates before save and before publish. Publish is disabled unless: Story.title and slug are nonblank; freeEpisodeCount >= 0 and <= totalEpisodes; count of Episode where storyId matches and isPublished=true is at least totalEpisodes when seasonCompletionStatus='complete'; every published Episode has endingStatus not equal to 'abrupt_flagged'; a ContentReview exists with exactAdTitleMatchesStory, seasonMarkedCompleteOnlyIfAllEpisodesLive, freeLimitConfirmed, and continuityNamesAndVisualsChecked all true. AdCreative can become eligible only when promisedTitle exactly matches the linked Story.title ignoring case and the linked story is live.

**Acceptance criteria:**
- A non-admin user opening admin/review sees the unauthorized state and no editor controls.
- Publish is disabled when seasonCompletionStatus='complete' but fewer published episodes exist than totalEpisodes.
- Publish is disabled when any published episode has endingStatus='abrupt_flagged'.
- Publish is disabled until all four ContentReview booleans are true.
- An ad creative with a promised title that does not match the story title cannot be marked eligible.

### Trust-funnel analytics and launch QA events

Records minimal non-sensitive events that verify the trust promises: deep link matched or missing, paywall shown, subscription started, billing center opened, and cancellation link opened.

- **Answers complaint:** baseline parity

- **Screens:** none (not screen-bound)

- **Estimated hours:** 25

**Implementation notes:** Use Firebase Analytics with event names deep_link_story_opened, deep_link_story_missing, paywall_shown, rewarded_unlock_completed, subscription_purchase_completed, billing_center_opened, and cancellation_link_opened. Event parameters must use storyId, episodeNumber, and boolean status fields only; do not log search query text, raw purchase token, email, or user-entered admin notes. Add unit tests around AccessResolver and admin publish validation, and instrumented tests for deep-link routing and paywall display. This feature does not add any marketing claims; it exists to catch the specific trust failures named in the report before launch.

**Acceptance criteria:**
- Opening a valid story deep link logs deep_link_story_opened with storyId.
- Opening a missing story deep link logs deep_link_story_missing without logging the raw query text.
- Showing Transparent Paywall logs paywall_shown with storyId and episodeNumber.
- Tapping the cancellation button logs cancellation_link_opened before launching the Play URL.
- No analytics event contains a raw purchase token or search query string.

## Store listing

- **Title:** ClearReel Drama
- **Short description:** Short dramas with clear prices, exact ads, and complete-season labels.
- **Category:** Entertainment
- **Keywords:** short drama, vertical drama, short reels, mini series, romance drama, episode viewer, Google Play subscription, rewarded episodes
- **Icon prompt:** Create a modern Android app icon for a trustworthy short-drama video viewer. Dark navy rounded-square background (#0F1020), centered play-circle glyph in violet (#6D3DF2), small green check badge (#18A058) at the lower-right of the play circle to suggest verified/complete stories. Flat vector style, high contrast, no text, no gradients, no brand references.

**Long description:**

Watch short drama episodes without guessing what happens after install. ClearReel Drama shows the exact story title, the number of free episodes, whether the season is complete, and the $12.99/month Google Play all-access price before you commit.

What v1 promises:
• Social ad links open the exact live story they promote.
• Story pages show complete or incomplete season status up front.
• Free episode limits are visible before episode one.
• Locked episodes clearly offer rewarded viewing when available or the monthly Google Play subscription.
• Billing Center links directly to Google Play subscription cancellation and order history.

No off-store subscription maze. No hidden coin packs. No claim that every story is unlimited free.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://clearreel.example.com/privacy (privacy claims verified: no)
- **Data collected:** Firebase anonymous user identifier; Viewing progress stored locally on device; Recent searches stored locally on device; Downloaded episode metadata stored locally on device; Subscription entitlement status and Google Play product id; SHA-256 hash of Google Play purchase token; Rewarded ad unlock receipts; Admin content review checklist values for admin users; Firebase Analytics events for deep-link, paywall, billing, cancellation, and playback trust flows

## Test plan

### 1. Advertised story not available (instrumented)

1. Seed the test Firestore emulator with one Story document: id='story_live_a', slug='promised-title-a', title='Promised Title A', status='live', seasonCompletionStatus='complete', totalEpisodes=5, freeEpisodeCount=3.
2. Launch MainActivity with Intent.ACTION_VIEW and Uri.parse('clearreel://story/promised-title-a').
3. Wait for navigation to settle.
4. Assert that the Story Detail screen is displayed and contains text 'Promised Title A'.
5. Launch MainActivity again with Intent.ACTION_VIEW and Uri.parse('clearreel://story/missing-title').
6. Wait for navigation to settle.

**Expected:** The valid deep link opens Story Detail for Promised Title A; the missing deep link opens Search and shows a no-result message saying the title is not currently live.

### 2. Free promise becomes paywall (unit)

1. Create a Story fixture with id='s1', totalEpisodes=8, freeEpisodeCount=3, status='live'.
2. Create no Entitlement and no AdRewardReceipt for the test uid.
3. Call AccessResolver.resolve(story=s1, episodeNumber=1, entitlement=null, rewardReceipts=[]).
4. Call AccessResolver.resolve(story=s1, episodeNumber=3, entitlement=null, rewardReceipts=[]).
5. Call AccessResolver.resolve(story=s1, episodeNumber=4, entitlement=null, rewardReceipts=[]).
6. Create one AdRewardReceipt for storyId='s1', episodeNumberUnlocked=4 and call the resolver for episode 4 again.

**Expected:** Episodes 1 and 3 return UNLOCKED_FREE; episode 4 initially returns LOCKED; episode 4 returns UNLOCKED_REWARDED only after the matching reward receipt exists.

### 3. Cancellation and billing fear (manual)

1. Install an internal test build signed with the release key on a tester account licensed in Play Console.
2. Configure Play Console subscription product monthly_all_access_1299 with a $12.99/month base plan.
3. Open the app, navigate to a locked episode, and tap Subscribe for $12.99/month.
4. Complete purchase using a Play test payment method.
5. Open Settings, then Billing and cancellation.
6. Tap Open Google Play cancellation.
7. Return to the app, open the latest receipt row.

**Expected:** The purchase is completed by Google Play UI only; Billing Center shows an active monthly all-access subscription; the cancellation button opens Google Play subscriptions for monthly_all_access_1299; Receipt Detail shows product id and purchase token hash but not the raw token.

### 4. Incomplete or poor continuity (unit)

1. Create a Story fixture with totalEpisodes=6 and seasonCompletionStatus='complete'.
2. Create only five published Episode fixtures for that story.
3. Create a ContentReview fixture with all four checklist booleans true.
4. Call AdminPublishValidator.canPublish(story, episodes, contentReview).
5. Add a sixth published Episode with endingStatus='abrupt_flagged' and call the validator again.
6. Change the sixth episode endingStatus to 'final_complete' and call the validator again.

**Expected:** The validator rejects publishing with only five published episodes; rejects publishing when any episode is abrupt_flagged; allows publishing only when the published episode count reaches totalEpisodes and no episode is flagged abrupt.

### 5. Guarantee every ad opens the exact story shown. (unit)

1. Create Story(title='Promised Title A', slug='promised-title-a', status='live', seasonCompletionStatus='complete').
2. Create AdCreative(promisedTitle='Different Title', storyId for the story, status='draft').
3. Call AdminAdValidator.canMarkEligible(adCreative, story).
4. Change promisedTitle to ' promised title a ' and call the validator again.
5. Change story.status to 'draft' and call the validator again.

**Expected:** The ad cannot be eligible when the promised title differs; it can be eligible when the title matches ignoring case and whitespace; it cannot be eligible when the linked story is not live.

### 6. baseline parity (instrumented)

1. Seed Firestore with one live Story and one published Episode whose videoUrl points to a short test MP4 or HLS asset served by the test server.
2. Launch the app to Home Feed.
3. Tap the seeded story card Watch button.
4. Wait until Episode Player is visible.
5. Let playback run for at least 6 seconds, then press Home or navigate back.
6. Relaunch the app and open the same episode.

**Expected:** The episode plays in the Media3 player, a ViewingProgress row is saved locally, and relaunching the same episode resumes near the saved position instead of starting from 0.

### 7. baseline parity (instrumented)

1. Seed a live Story with freeEpisodeCount=1 and one published Episode.
2. Open Episode Player for episode 1 with network available.
3. Tap Download.
4. Wait until Downloads screen shows the item status as downloaded or use a fake worker result in the test build.
5. Disable network on the test device or use the test build's offline mode flag.
6. Tap the downloaded episode in Downloads.

**Expected:** The downloaded episode opens from the local file path and does not require a network fetch.

## Build instructions

```sh
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
mkdir -p keystore
keytool -genkeypair -v -keystore keystore/release.jks -storepass changeit123 -keypass changeit123 -alias clearreel -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=ClearReel Drama,O=AutoApp,C=US"
AUTOAPP_KEYSTORE_FILE="$PWD/keystore/release.jks" AUTOAPP_KEYSTORE_PASSWORD="changeit123" AUTOAPP_KEY_ALIAS="clearreel" AUTOAPP_KEY_PASSWORD="changeit123" ./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
