# DramaTrust Shorts — build spec

A short-drama streaming app that makes every advertised title searchable and deep-linkable, labels completion status before payment, and uses only normal Google Play subscription management.

## 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:** VibeShort: AI Dramas & Reels
- **Package id:** `com.vibeshort.visualnovel.android`
- **Google Play:** https://play.google.com/store/apps/details?id=com.vibeshort.visualnovel.android
- **appy.fyi report:** https://appy.fyi/report/com.vibeshort.visualnovel.android
- **Category:** Entertainment

## Overview

- **Working name:** DramaTrust Shorts (trademark cleared: no)
- **Package id:** `fyi.appy.dramatrustshorts`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 12 weeks
- **Pricing:** subscription, $5 via `play_billing_direct`
- **Runtime AI:** none
- **Permissions:** `INTERNET`

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

- No web checkout, external merchant billing, or payment redirect of any kind.
- No coin packs, ad-reward currency, or pay-per-episode unlock economy in v1.
- No giant open-ended catalog; v1 prioritizes a smaller catalog where completion status and endings are verified.
- No user accounts, social login, cross-device sync, comments, ratings, or creator uploads.
- No runtime AI story generation inside the Android app.
- No iOS app, web player, or TV app in v1.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Android Kotlin extensions and compatibility APIs | `androidx.core:core-ktx:1.13.1` |
| Single-activity Compose host | `androidx.activity:activity-compose:1.9.3` |
| Compose UI primitives | `androidx.compose.ui:ui:1.7.4` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.0` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware Compose state collection | `androidx.lifecycle:lifecycle-runtime-compose:2.8.6` |
| Local catalog, watch history, downloads, entitlement cache, and ad-route persistence | `androidx.room:room-runtime:2.6.1` |
| Coroutine support for Room DAOs | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor through KSP | `androidx.room:room-compiler:2.6.1` |
| Video playback engine | `androidx.media3:media3-exoplayer:1.4.1` |
| HLS video stream support | `androidx.media3:media3-exoplayer-hls:1.4.1` |
| Compose-hosted PlayerView interop and media controls | `androidx.media3:media3-ui:1.4.1` |
| Media3 download manager, cache, and offline playback primitives | `androidx.media3:media3-database:1.4.1` |
| Media3 OkHttp data source for streaming and downloading episode media | `androidx.media3:media3-datasource-okhttp:1.4.1` |
| Direct Google Play Billing subscription purchase, restore, acknowledgement, and purchase-state query | `com.android.billingclient:billing-ktx:7.1.1` |
| Poster and thumbnail image loading in Compose | `io.coil-kt:coil-compose:2.7.0` |
| Parsing bundled catalog and ad-route JSON assets | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| App coroutine dispatchers and structured background work | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| HTTP client used by Media3 OkHttp data source | `com.squareup.okhttp3:okhttp:4.12.0` |
| Local JVM unit tests | `junit:junit:4.13.2` |
| Readable assertions for catalog-integrity and billing-state unit tests | `com.google.truth:truth:1.4.4` |
| Android instrumented JUnit runner integration | `androidx.test.ext:junit:1.2.1` |
| Compose instrumented UI tests | `androidx.compose.ui:ui-test-junit4:1.7.4` |

## Design system

- **Primary color:** `#7C3AED`
- **Background color:** `#0F1020`
- **Error color:** `#DC2626`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `play-circle` (regular weight)
- **Theme notes:** Use a dark-first streaming UI. Dark theme background is #0F1020 with white text at 87% opacity and cards at #1B1B33. Light theme background is #FAFAFF with primary #7C3AED and cards #FFFFFF. Completion badges use green #16A34A for Complete, amber #F59E0B for Updating, and gray #6B7280 for Preview only. Do not use gradients behind readable text.

## Screens

### Home
- **Route:** `home`
- **Purpose:** Browse the short-drama catalog, continue watching, and enter search or completion filters.
- **Reached via:** app launch; bottom navigation Home; back from Series Detail; successful subscription purchase return
- **Key UI elements:** Top app bar with app title and Subscription status chip; Search field that navigates to Search with the typed query; Filter chips: All, Complete, Updating, Preview only; Continue Watching horizontal carousel; Catalog poster grid with completion badges; Bottom navigation items: Home, Downloads, Subscription
- **States:** loading catalog from Room after first asset seed, empty when no series exist in Room, error when catalog seed JSON cannot be parsed, populated with catalog grid and optional Continue Watching row

### Search
- **Route:** `search?query={query}&status={status}`
- **Purpose:** Find a drama by canonical title, advertised title alias, or trope tag, with a visible completed-series filter.
- **Reached via:** tap Home search field; submit search from keyboard; tap advertised deep link fallback that opens search; tap completed-series filter on Home
- **Key UI elements:** Search text field with current query; Status filter chips: All, Complete, Updating, Preview only; Result count text; Result list showing poster, canonical title, matching alias if any, and completion badge; Empty-state text explaining no matching advertised title was found
- **States:** idle with empty query and popular catalog results, searching while debounce is active, empty when no series, alias, or tag matches, error when local search query fails, populated with matching series

### Ad Link Resolver
- **Route:** `ad/{adSlug}`
- **Purpose:** Resolve a social-ad deep link to the exact in-app series promised by the ad.
- **Reached via:** Android App Link https://dramatrust.example/ad/{adSlug}; internal QA deep-link test button in debug builds
- **Key UI elements:** Loading indicator while adSlug is resolved locally; Resolved title text before navigating to Series Detail; Error card naming the missing adSlug when no route exists; Button to open Search prefilled with the adSlug
- **States:** loading while querying AdCreativeRoute by adSlug, resolved and immediately navigating to Series Detail with fromAd=true, error when adSlug is not present in the catalog route table

### Series Detail
- **Route:** `series/{seriesId}?fromAd={fromAd}`
- **Purpose:** Show exactly what a drama contains before payment: title, aliases, completion status, episode list, previews, and subscription-gated episodes.
- **Reached via:** tap Home catalog tile; tap Search result; successful Ad Link Resolver route; tap Continue Watching item; tap Downloads item linked to a series
- **Key UI elements:** Poster hero image; Canonical title and completion badge; Advertised-as alias row when opened from an ad; Synopsis and trope tags; Episode count and final-episode marker; Episode list with Preview, Included, Locked, Downloaded, or Downloadable labels; Primary button: Watch first preview, Continue watching, or Subscribe to unlock; Download complete series button for active subscribers
- **States:** loading series and episodes, not_found when seriesId does not exist, error when entitlement state cannot be read, populated for unsubscribed preview user, populated for active subscriber, populated with fromAd banner confirming the ad resolved to this exact title

### Player
- **Route:** `player/{seriesId}/{episodeId}`
- **Purpose:** Play preview, subscribed, or downloaded episodes and persist watch progress.
- **Reached via:** tap episode row on Series Detail; tap Continue Watching on Home; tap downloaded episode on Downloads; tap Next Episode in Player
- **Key UI elements:** Media3 PlayerView hosted in Compose AndroidView; Episode title and series title; Completion badge visible below player; Previous and Next episode buttons; Subscribe button when the next episode is locked; Offline playback banner when playing from local download; Retry button for playback errors
- **States:** loading episode metadata, buffering stream or local file, playing, paused, ended with next episode available, locked because entitlement is missing, error when Media3 reports playback failure, offline_unavailable when an undownloaded paid episode is requested without current entitlement

### Subscription
- **Route:** `subscription`
- **Purpose:** Make billing boring and transparent: show Play subscription state, buy through Google Play Billing, restore purchases, and explain cancellation through Play Store.
- **Reached via:** tap Subscription status chip on Home; bottom navigation Subscription; tap Subscribe from Series Detail; tap Subscribe from locked Player state
- **Key UI elements:** Current plan card for $5/month; Status text: Free preview, Active, Pending, Grace period, Expired, or Unavailable; Subscribe with Google Play button; Restore purchases button; Last verified timestamp; Open Google Play subscriptions button; Plain-language cancellation note: Manage and cancel in Google Play
- **States:** loading BillingClient connection, free_preview when no active purchase exists, active when Play Billing reports an active subscription, pending when Play Billing purchase state is pending, expired when cached purchase is no longer returned by Play Billing, error when BillingClient setup or product query fails

### Downloads
- **Route:** `downloads`
- **Purpose:** Manage offline episode downloads and make entitlement limits visible.
- **Reached via:** bottom navigation Downloads; tap Download episode on Series Detail; tap offline banner in Player
- **Key UI elements:** Downloaded episode list grouped by series; Progress bars for active downloads; State labels: Queued, Downloading, Downloaded, Failed, Expired; Retry failed download button; Delete download button; Subscribe or Restore button when downloads require active entitlement
- **States:** loading downloads from Room and Media3 DownloadManager, empty when no episodes are downloaded or queued, populated with queued/downloading/completed items, error when local download database cannot be opened, entitlement_expired when paid downloads exist but subscription has not been verified within the allowed window

### Settings
- **Route:** `settings`
- **Purpose:** Provide support, privacy, and purchase-management links without creating a separate account system.
- **Reached via:** overflow menu from Home; overflow menu from Subscription
- **Key UI elements:** Open Subscription screen row; Restore purchases row; Privacy policy link; Terms link; Support email link; App version text
- **States:** populated, error when external browser intent cannot be opened

## Data model

### Series (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; stable catalog id used by navigation and deep links |
| canonicalTitle | `String` | display title shown everywhere in the app |
| synopsis | `String` | short description for Series Detail |
| status | `String` | enum stored as text: COMPLETE, UPDATING, PREVIEW_ONLY |
| posterUrl | `String` | HTTPS image URL loaded by Coil |
| titleAliasesJson | `String` | JSON array of advertised names and alternate titles for local search |
| tropeTagsJson | `String` | JSON array of searchable tags |
| sortOrder | `Int` | ascending order for Home catalog |
| totalEpisodes | `Int` | must equal count of Episode rows for COMPLETE series |

### Episode (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; stable episode id |
| seriesId | `String` | foreign key to Series.id |
| episodeNumber | `Int` | 1-based display and playback order |
| title | `String` | episode display title |
| durationMs | `Long` | expected duration in milliseconds |
| videoUrl | `String` | HTTPS HLS or MP4 media URL |
| thumbnailUrl | `String` | HTTPS image URL for episode row |
| isPreview | `Boolean` | true when playable without subscription |
| isFinale | `Boolean` | true only for the ending episode of a COMPLETE series |

### WatchProgress (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| episodeId | `String` | primary key; foreign key to Episode.id |
| seriesId | `String` | foreign key to Series.id for Continue Watching queries |
| positionMs | `Long` | last playback position |
| durationMs | `Long` | last known media duration |
| completed | `Boolean` | true when positionMs is at least 90 percent of durationMs |
| updatedAtEpochMs | `Long` | System.currentTimeMillis at last save |

### DownloadItem (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; use episodeId |
| episodeId | `String` | foreign key to Episode.id |
| seriesId | `String` | foreign key to Series.id |
| localUri | `String?` | nullable until Media3 download completes |
| state | `String` | enum stored as text: QUEUED, DOWNLOADING, COMPLETED, FAILED, EXPIRED |
| bytesDownloaded | `Long` | updated from Media3 Download state |
| percentDownloaded | `Float` | 0.0 to 100.0 from Media3 Download state |
| entitlementCheckedAtEpochMs | `Long` | last time Play subscription entitlement was verified for this paid download |

### SubscriptionState (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; always PLAY_MONTHLY |
| productId | `String` | Google Play subscription product id: dramatrust_monthly |
| status | `String` | enum stored as text: FREE_PREVIEW, ACTIVE, PENDING, EXPIRED, ERROR |
| isActive | `Boolean` | true only when Play Billing currently returns an active purchased subscription |
| lastVerifiedEpochMs | `Long` | System.currentTimeMillis when queryPurchasesAsync last succeeded |
| purchaseTokenHash | `String?` | nullable SHA-256 hash of Play purchase token; do not store raw token |

### AdCreativeRoute (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| adSlug | `String` | primary key; path segment from https://dramatrust.example/ad/{adSlug} |
| seriesId | `String` | foreign key to Series.id; must resolve to an existing series |
| advertisedTitle | `String` | title text used in the social ad |
| createdAtEpochMs | `Long` | catalog build timestamp |

## Features

### Seeded trustworthy catalog with completion badges

Load a small local catalog from bundled JSON into Room and show each series as Complete, Updating, or Preview only before the user pays.

- **Answers complaint:** Incomplete or locked paid stories

- **Screens:** Home, Search, Series Detail

- **Estimated hours:** 70

**Implementation notes:** Place two asset files at app/src/main/assets/catalog.json and app/src/main/assets/ad_routes.json. On first launch, parse catalog.json with kotlinx.serialization into Series and Episode DTOs, run validation, then upsert into Room in a single transaction. The Series.status string must be displayed on Home, Search, and Series Detail. For status COMPLETE, validation must require at least one Episode where isFinale=true and require totalEpisodes to equal the number of Episode rows for that series. For status PREVIEW_ONLY, all episodes must have isPreview=true and locked subscription CTAs must not claim the ending exists. If validation fails, do not partially seed; show Home error state with the failing validation message.

**Acceptance criteria:**
- A COMPLETE series tile displays a green Complete badge on Home, Search, and Series Detail.
- An UPDATING series displays an amber Updating badge before any subscription CTA is shown.
- A PREVIEW_ONLY series displays a gray Preview only badge and does not show a Subscribe to finish CTA.
- If catalog.json contains a COMPLETE series without an isFinale=true episode, the app shows the Home error state and no rows from that invalid seed are inserted.

### Search by canonical title, advertised alias, and completion filter

Let users find dramas by the name they saw in an ad and filter results to completed series.

- **Answers complaint:** Advertised titles missing

- **Screens:** Home, Search

- **Estimated hours:** 50

**Implementation notes:** Implement a Room DAO query that loads all Series rows and filters in Kotlin using lowercase normalized strings because aliases and tags are stored as JSON arrays. Normalization must trim, lowercase with Locale.US, collapse multiple spaces to one, and remove punctuation characters [.,!?;:'"-]. Search must match canonicalTitle, any titleAliasesJson element, and any tropeTagsJson element. The Search screen must debounce text input by 250 ms using coroutines, preserve the query in the route query parameter, and apply the selected status filter before rendering. When a match occurs only through an alias, show 'Advertised as: {alias}' under the canonical title.

**Acceptance criteria:**
- Typing an alias present in titleAliasesJson returns the correct canonical series.
- Selecting the Complete filter hides UPDATING and PREVIEW_ONLY series.
- Search with different punctuation or capitalization still returns the same alias match.
- An empty result shows a message that the title is not in the catalog instead of an empty blank screen.

### Exact social-ad deep-link routing

Every advertised drama link opens the exact in-app title promised by the ad or shows a visible failure instead of silently dropping the user.

- **Answers complaint:** Advertised titles missing

- **Screens:** Ad Link Resolver, Series Detail, Search, Subscription

- **Estimated hours:** 50

**Implementation notes:** Add an Android App Link intent filter for https://dramatrust.example/ad/{adSlug}. Route incoming links to Ad Link Resolver. Query AdCreativeRoute by adSlug; if found, navigate to series/{seriesId}?fromAd=true and pass advertisedTitle through the destination state. Series Detail must show a banner reading 'Opened from ad: {advertisedTitle}' when fromAd=true. If the adSlug is not found, show the resolver error state with the missing slug and a button that opens Search with query set to the slug. Store the last successful adSlug and seriesId in SavedStateHandle so returning from the Play Billing purchase sheet goes back to the same Series Detail.

**Acceptance criteria:**
- Opening https://dramatrust.example/ad/test-slug where ad_routes.json maps test-slug to a series navigates to that exact Series Detail.
- The Series Detail opened from an ad displays the advertisedTitle from AdCreativeRoute.
- Opening an unknown adSlug shows an error card naming the unknown slug and offers Search.
- Starting a subscription purchase from an ad-opened Series Detail and returning from billing keeps the user on the same series.

### Episode player with watch history

Play short-drama episodes and save progress for Continue Watching.

- **Answers complaint:** baseline parity

- **Screens:** Player, Home, Series Detail

- **Estimated hours:** 70

**Implementation notes:** Use Media3 ExoPlayer in a lifecycle-aware PlayerView inside Compose AndroidView. Build a MediaItem from Episode.videoUrl for streaming or from DownloadItem.localUri for completed downloads. Save WatchProgress every 5 seconds while playback is active, on pause, and on player release. Mark completed=true when positionMs >= durationMs * 0.9. Home Continue Watching must query the newest incomplete WatchProgress rows and show the related Series poster plus episode number. Player Next Episode must load the next Episode by seriesId and episodeNumber only if entitlement rules allow it; otherwise show locked state with a Subscribe button.

**Acceptance criteria:**
- Playing an episode for at least 6 seconds creates or updates a WatchProgress row.
- Pausing and reopening the same episode resumes within 2 seconds of the saved position.
- After watching at least 90 percent of an episode, completed is stored as true.
- Home shows an incomplete watched episode in Continue Watching and hides it after it is marked completed.

### Google Play Billing subscription only

Sell the $5/month subscription through direct Google Play Billing and show a clear status and cancellation path.

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

- **Screens:** Subscription, Series Detail, Player, Settings

- **Estimated hours:** 70

**Implementation notes:** Use BillingClient from com.android.billingclient:billing-ktx with product id dramatrust_monthly and ProductType.SUBS. On Subscription screen startup, call startConnection, queryProductDetailsAsync, then queryPurchasesAsync for SUBS. Launch purchases only with launchBillingFlow using the queried ProductDetails offer token. When purchases are returned, acknowledge any PURCHASED purchase that is not acknowledged, store SubscriptionState with isActive=true and status ACTIVE, and hash purchaseToken with SHA-256 before storing. Do not add any WebView, browser checkout URL, card form, or non-Play payment SDK. The Open Google Play subscriptions button must launch Intent ACTION_VIEW with Uri https://play.google.com/store/account/subscriptions?package={applicationId}&sku=dramatrust_monthly.

**Acceptance criteria:**
- The only purchase button launches the Google Play Billing purchase sheet.
- No WebView or external checkout URL is used for subscription purchase.
- An active Play subscription displays Active and a last verified timestamp on Subscription screen.
- The cancellation-management button opens the Google Play subscriptions URL for sku dramatrust_monthly.
- Restore purchases refreshes state using queryPurchasesAsync and never asks for an email or app account.

### Simple entitlement rules with no coins or ad rewards

Keep access understandable: previews are free, active subscribers can watch subscribed episodes, and there is no coin or ad-reward gate.

- **Answers complaint:** Ads and coin friction

- **Screens:** Series Detail, Player, Subscription

- **Estimated hours:** 35

**Implementation notes:** Create an EntitlementRepository with function canPlay(episode): EntitlementResult. Return ALLOWED_FREE_PREVIEW when episode.isPreview is true. Return ALLOWED_SUBSCRIBER when SubscriptionState.isActive is true and status ACTIVE. Otherwise return LOCKED_NEEDS_SUBSCRIPTION. Series Detail episode rows must show Preview for isPreview episodes, Included for subscriber-accessible paid episodes, and Locked for paid episodes without entitlement. Do not include any rewarded-ad SDK, coin balance table, coin purchase product, ad countdown screen, or 'watch ad to unlock' button. For COMPLETE series with active subscription, every episode including isFinale=true must be playable.

**Acceptance criteria:**
- A free user can play every episode where isPreview=true.
- A free user opening a non-preview episode sees the Player locked state with Subscribe button.
- An active subscriber can play the final episode of a COMPLETE series.
- The app contains no UI label containing coin, coins, reward ad, or watch ad to unlock.

### Offline downloads with entitlement checks

Allow active subscribers to download episodes for offline playback while making expired entitlement states visible.

- **Answers complaint:** baseline parity

- **Screens:** Downloads, Series Detail, Player, Subscription

- **Estimated hours:** 70

**Implementation notes:** Use Media3 DownloadManager with a SimpleCache stored under context.filesDir/media_cache so no external storage permission is required. On Download button tap, first call EntitlementRepository.canPlay; allow downloads for preview episodes and active subscriber episodes only. Insert DownloadItem with QUEUED, update state from DownloadManager listener callbacks, and set localUri when completed. Before playing a paid downloaded episode, require SubscriptionState.isActive=true and lastVerifiedEpochMs no older than 7 days; if older or inactive, show Downloads entitlement_expired and route Restore to Subscription. Preview downloads remain playable without subscription.

**Acceptance criteria:**
- Tapping Download on a locked paid episode as a free user opens Subscription instead of queuing a download.
- Tapping Download on a preview episode queues and completes a Media3 download in app-private storage.
- A completed downloaded preview episode plays when network is disabled.
- A paid downloaded episode does not play if SubscriptionState.isActive=false.
- A paid downloaded episode asks to restore purchases if lastVerifiedEpochMs is more than 7 days old.

### Catalog integrity gate for ad promises and endings

Fail the build if a bundled ad route points to a missing series or a completed paid story lacks an ending.

- **Answers complaint:** Advertised titles missing

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

- **Estimated hours:** 45

**Implementation notes:** Add JVM unit tests under app/src/test/java named CatalogIntegrityTest. The tests must read app/src/main/assets/catalog.json and ad_routes.json from disk, parse them with kotlinx.serialization, and validate four rules: every AdCreativeRoute.seriesId exists in Series; every AdCreativeRoute.advertisedTitle appears either as Series.canonicalTitle or in titleAliasesJson for that series; every COMPLETE series has at least one Episode with isFinale=true; and every COMPLETE series has no missing episodeNumber gaps from 1 through totalEpisodes. This is the app-side enforcement of the report's ad-to-title matching discipline; aliases may be generated offline before being committed, but the Android app must not call an AI API at runtime.

**Acceptance criteria:**
- Unit tests fail when ad_routes.json references a nonexistent seriesId.
- Unit tests fail when an advertisedTitle is not searchable as the canonical title or alias of its mapped series.
- Unit tests fail when a COMPLETE series has no finale episode.
- Unit tests fail when a COMPLETE series has episode numbers 1 and 3 but no 2.

### Store-ready release hardening without hidden data collection

Prepare the Android release with deterministic navigation, user-visible errors, and no account or payment data collection beyond Play Billing state stored locally.

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

- **Screens:** Home, Search, Series Detail, Subscription, Downloads, Settings

- **Estimated hours:** 20

**Implementation notes:** Add Compose UI tests for the Home, Search, Series Detail, Subscription, and Downloads visible states. Add a release build type with minifyEnabled=true, shrinkResources=true, and ProGuard rules for Room, kotlinx.serialization DTOs, BillingClient, and Media3. Do not add Firebase Analytics or any third-party analytics SDK in v1 because the trust wedge depends on minimal data collection. The app version screen in Settings must show BuildConfig.VERSION_NAME and VERSION_CODE. All visible error states must include a Retry button when the underlying action can be retried.

**Acceptance criteria:**
- Release build completes with minification enabled.
- Settings displays the app version name and code.
- No Firebase, ad SDK, or web checkout dependency appears in the release dependency tree.
- Each loading screen listed in the screens array has a corresponding non-crashing error or empty state.

## Store listing

- **Title:** DramaTrust Shorts
- **Short description:** Completed short dramas, honest ads, Play Billing only.
- **Category:** Entertainment
- **Keywords:** short drama, mini drama, vertical drama, completed series, play billing, offline episodes, drama search
- **Icon prompt:** Create a modern Android launcher icon for an app named DramaTrust Shorts. Dark indigo rounded-square background (#0F1020), centered white play-circle glyph, small violet accent ring (#7C3AED), clean flat vector style, no text, no faces, no film stills, high contrast, suitable at 48dp and 512px.

**Long description:**

Watch short serialized dramas without guessing whether the advertised title exists or whether the ending is locked away. DramaTrust Shorts is built around a smaller, clearer catalog: every title is searchable, every social-ad link opens the exact drama it names, and every series is labeled Complete, Updating, or Preview only before you subscribe.

Free viewers can watch preview episodes. Subscribers unlock included episodes through a normal $5/month Google Play subscription. There are no coin packs, no hidden web checkout, and no ad-reward traps. Manage and cancel your subscription in Google Play.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://dramatrust.example/privacy (privacy claims verified: no)
- **Data collected:** Play Billing subscription status stored locally on device; Hashed Google Play purchase token stored locally on device; Watch progress stored locally on device; Offline download records stored locally on device

## Test plan

### 1. Advertised titles missing (unit)

1. Create a temporary catalog fixture with one Series id='s1', canonicalTitle='Hidden Heiress', titleAliasesJson='["The Billionaire Maid"]', status='COMPLETE', totalEpisodes=2.
2. Create Episode fixtures for s1 with episodeNumber 1 and 2, and set episode 2 isFinale=true.
3. Create ad_routes fixture with adSlug='billionaire-maid' and advertisedTitle='The Billionaire Maid' mapped to seriesId='s1'.
4. Run the CatalogIntegrityTest parser and validation function on those fixtures.

**Expected:** Validation succeeds because the advertised title resolves to an existing searchable alias and the completed series has a finale.

### 2. Advertised titles missing (unit)

1. Create a catalog fixture with Series id='s1' and no Series id='missing'.
2. Create ad_routes fixture with adSlug='bad-ad' mapped to seriesId='missing'.
3. Run CatalogIntegrityTest.validateAdRoutes.

**Expected:** Validation fails with a message containing bad-ad and missing.

### 3. Incomplete or locked paid stories (unit)

1. Create a catalog fixture with Series id='s1', status='COMPLETE', totalEpisodes=2.
2. Create two Episode rows for s1 with episodeNumber 1 and 2 and isFinale=false for both.
3. Run CatalogIntegrityTest.validateCompletedSeries.

**Expected:** Validation fails with a message containing s1 and finale.

### 4. Finding a completed drama is almost impossible (instrumented)

1. Seed Room with three Series rows: one status COMPLETE, one status UPDATING, and one status PREVIEW_ONLY.
2. Launch Home route.
3. Tap the Complete filter chip.
4. Read all visible catalog tiles.

**Expected:** Only the COMPLETE series tile is visible and it displays the green Complete badge.

### 5. Advertised titles missing (instrumented)

1. Seed Room with Series id='s1', canonicalTitle='Hidden Heiress', titleAliasesJson='["The Billionaire Maid"]'.
2. Launch Search route with query='billionaire maid'.
3. Wait for the 250 ms debounce.
4. Read the first result row.

**Expected:** The first result shows canonical title Hidden Heiress and subtext Advertised as: The Billionaire Maid.

### 6. Advertised titles missing (instrumented)

1. Seed Room with Series id='s1' and AdCreativeRoute adSlug='billionaire-maid', advertisedTitle='The Billionaire Maid', seriesId='s1'.
2. Launch the Activity with ACTION_VIEW data Uri https://dramatrust.example/ad/billionaire-maid.
3. Wait for navigation to finish.

**Expected:** Series Detail for s1 is displayed and contains the banner Opened from ad: The Billionaire Maid.

### 7. Incomplete or locked paid stories (instrumented)

1. Seed Room with a COMPLETE Series id='s1' and three Episode rows where episode 3 has isFinale=true and isPreview=false.
2. Insert SubscriptionState id='PLAY_MONTHLY' with status='ACTIVE' and isActive=true.
3. Launch Series Detail for s1.
4. Tap episode 3.

**Expected:** Player opens episode 3 instead of showing a locked state.

### 8. Ads and coin friction (instrumented)

1. Launch Home, Series Detail, Player locked state, Subscription, and Downloads screens using seeded data.
2. Collect all visible text from each screen.
3. Search the text for case-insensitive strings 'coin', 'coins', 'reward ad', and 'watch ad to unlock'.

**Expected:** None of those strings appear anywhere in the v1 UI.

### 9. Cancellation and billing trust (manual)

1. Install an internal test build signed for Play Billing testing.
2. Use a Google Play license tester account.
3. Open Subscription screen.
4. Tap Subscribe with Google Play.
5. Complete the test subscription purchase in the Google Play purchase sheet.
6. Return to the app.
7. Tap Open Google Play subscriptions.

**Expected:** The purchase is completed only through the Google Play sheet, Subscription screen shows Active with a last verified timestamp, and the management button opens the Google Play subscriptions page for sku dramatrust_monthly.

### 10. Cancellation and billing trust (manual)

1. With an active test subscription, cancel it from Google Play subscriptions.
2. Return to the app.
3. Open Subscription screen.
4. Tap Restore purchases.

**Expected:** The app refreshes from Play Billing and no web checkout, external cancellation page, email login, or card form is shown.

### 11. baseline parity (instrumented)

1. Seed Room with a preview Episode id='e1' and Series id='s1'.
2. Launch Player for s1/e1 with a fake Media3 player test double reporting durationMs=100000.
3. Advance playback position to 6000 ms and trigger the 5-second save tick.
4. Query WatchProgress for e1.

**Expected:** WatchProgress exists for e1 with positionMs at least 5000 and completed=false.

### 12. baseline parity (instrumented)

1. Seed Room with a paid non-preview Episode id='e2'.
2. Insert SubscriptionState with isActive=false.
3. Launch Series Detail for the episode's series.
4. Tap Download on e2.

**Expected:** No DownloadItem is inserted for e2 and the app navigates to Subscription.

## Build instructions

```sh
keytool -genkeypair -v -keystore "$PWD/release.keystore" -storepass changeit -keypass changeit -alias release -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=DramaTrust Shorts,O=Solo Builder,L=Internet,ST=NA,C=US"
export ANDROID_KEYSTORE_PATH="$PWD/release.keystore"
export ANDROID_KEYSTORE_PASSWORD="changeit"
export ANDROID_KEY_ALIAS="release"
export ANDROID_KEY_PASSWORD="changeit"
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew lintDebug
./gradlew bundleRelease
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
