# Channel Clarity Coach — build spec

A reliable low-cost YouTube SEO assistant for small creators with persistent YouTube login, real video sync, and deterministic explainable scoring instead of shifting black-box scores.

## 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:** vidIQ for YouTube & Instagram
- **Package id:** `com.vidiq.app`
- **Google Play:** https://play.google.com/store/apps/details?id=com.vidiq.app
- **appy.fyi report:** https://appy.fyi/report/com.vidiq.app
- **Category:** Video Players & Editors

## Overview

- **Working name:** Channel Clarity Coach (trademark cleared: no)
- **Package id:** `fyi.appy.channelclaritycoach`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 7 weeks
- **Pricing:** subscription, $4.99 via `revenuecat`
- **Runtime AI:** yes (ai_gateway_llm): Generate title, description, tag, and explanation suggestions from the user's own video metadata, analytics, target keyword, and deterministic score breakdown. ≈ $0.02/call
- **Permissions:** `INTERNET`

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

- No competitor-tracking tab, rival-video feed, or feature that sends the user to competing channels in v1.
- No full YouTube Studio replacement: v1 reads channel/video metadata and analytics only, and does not upload, publish, schedule, delete, or edit YouTube videos.
- No team accounts, cross-device sync, or shared workspaces in v1.
- No custom ML model training; v1 uses deterministic local scoring plus an LLM-backed suggestion call.

## 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 | `androidx.activity:activity-compose:1.9.3` |
| Compose UI runtime | `androidx.compose.ui:ui:1.7.5` |
| Material 3 Compose components | `androidx.compose.material3:material3:1.3.1` |
| Compose Navigation graph | `androidx.navigation:navigation-compose:2.8.3` |
| Lifecycle-aware Compose state collection | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| ViewModel integration for Compose screens | `androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7` |
| Local SQL persistence for channel, video, analytics, and score records | `androidx.room:room-runtime:2.6.1` |
| Coroutine extensions for Room DAO calls | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor | `androidx.room:room-compiler:2.6.1` |
| Encrypted local storage for OAuth refresh/access tokens | `androidx.security:security-crypto:1.1.0-alpha06` |
| OAuth authorization-code flow with Google using AppAuth | `net.openid:appauth:0.11.1` |
| HTTP client for YouTube Data API, YouTube Analytics API, token refresh, and AI gateway calls | `io.ktor:ktor-client-okhttp:2.3.12` |
| JSON request/response handling for Ktor | `io.ktor:ktor-client-content-negotiation:2.3.12` |
| Kotlinx serialization adapter for Ktor JSON | `io.ktor:ktor-serialization-kotlinx-json:2.3.12` |
| Kotlin JSON serialization for stored tags, score explanations, and AI suggestions | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Android coroutine dispatchers | `org.jetbrains.kotlinx:kotlinx-coroutines-android:1.9.0` |
| Video thumbnail image loading in Compose | `io.coil-kt:coil-compose:2.7.0` |
| RevenueCat subscription entitlement and Play Billing wrapper | `com.revenuecat.purchases:purchases:8.9.0` |
| Unit testing framework | `junit:junit:4.13.2` |
| AndroidX instrumented JUnit runner integration | `androidx.test.ext:junit:1.2.1` |
| Compose UI testing APIs | `androidx.compose.ui:ui-test-junit4:1.7.5` |
| Mock HTTP server for YouTube/token/AI API tests | `com.squareup.okhttp3:mockwebserver:4.12.0` |

## Design system

- **Primary color:** `#2563EB`
- **Background color:** `#F8FAFC`
- **Error color:** `#DC2626`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `chart-line-up` (regular weight)
- **Theme notes:** Use Material 3 light and dark color schemes. Light mode background is #F8FAFC with white cards; dark mode background is #0F172A with #1E293B cards. Primary action buttons use #2563EB in both modes. Use rounded 16dp cards and 8dp spacing increments. Avoid red YouTube-like branding as the primary identity color.

## Screens

### Connect YouTube
- **Route:** `connect`
- **Purpose:** Starts the Google OAuth flow, explains the read-only YouTube scopes, and recovers from authorization errors.
- **Reached via:** app launch when no encrypted OAuth refresh token exists; tap Disconnect YouTube in Settings; token refresh failure that requires user reauthorization
- **Key UI elements:** App logo and one-sentence value proposition; Read-only scope explanation list for channel videos and analytics; Connect YouTube button; Retry button shown after OAuth failure; Privacy note that tokens are stored encrypted on device
- **States:** signed_out, authorizing, auth_error, already_connected_redirecting

### Dashboard
- **Route:** `dashboard`
- **Purpose:** Shows the connected channel, sync status, and the user's recent YouTube videos.
- **Reached via:** app launch after valid YouTube session exists; successful OAuth completion from Connect YouTube; tap Dashboard in bottom navigation
- **Key UI elements:** Channel header with title, thumbnail, subscriber count, and video count; Last synced timestamp; Manual Sync Now button; Recent videos list with thumbnail, title, publish date, view count, and SEO score badge when available; Empty state CTA to sync videos; Bottom navigation to Dashboard, SEO Workspace, and Settings
- **States:** loading, empty, syncing, sync_error, populated

### SEO Workspace
- **Route:** `seo/{videoId}`
- **Purpose:** Lets the creator score a video title, description, tags, and target keyword with deterministic explanations and request AI suggestions.
- **Reached via:** tap a video row on Dashboard; tap SEO Workspace in bottom navigation; tap Recalculate Score after editing fields
- **Key UI elements:** Video selector when opened without a specific video; Editable title field; Editable description field; Comma-separated tags field; Target keyword field; Deterministic SEO score card with numeric score and weighted checklist; Recalculate Score button; AI Coach button; AI suggestions list with title, description, and tag alternatives; Paywall card when AI Coach is locked
- **States:** loading_video, no_video_selected, ready_no_score, scored, ai_loading, ai_error, ai_populated, paywalled

### Settings
- **Route:** `settings`
- **Purpose:** Shows YouTube connection health, subscription status, price, restore purchase, and disconnect controls.
- **Reached via:** tap Settings in bottom navigation; tap paywall card in SEO Workspace; purchase completion or restore completion
- **Key UI elements:** Connected YouTube channel ID and title; Session health status; Refresh session test button; $4.99/month subscription card; Subscribe button; Restore Purchases button; Current entitlement status; Disconnect YouTube button
- **States:** loading, free, subscribed, purchase_in_progress, purchase_error, session_error

## Data model

### OAuthSession (`encrypted_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; constant value "youtube" |
| accessToken | `String` | encrypted |
| refreshToken | `String` | encrypted; nullable only until first token response is received |
| accessTokenExpiresAt | `Instant` | stored as epoch milliseconds |
| grantedScopes | `String` | space-separated OAuth scope string |
| lastRefreshAttemptAt | `Instant` | nullable; stored as epoch milliseconds |

### ChannelProfile (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| channelId | `String` | primary key |
| title | `String` |  |
| thumbnailUrl | `String` | nullable |
| subscriberCount | `Long` | nullable because YouTube may hide subscriber count |
| videoCount | `Long` |  |
| uploadsPlaylistId | `String` | from channels.contentDetails.relatedPlaylists.uploads |
| lastSyncedAt | `Instant` | stored as epoch milliseconds |

### VideoSnapshot (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| videoId | `String` | primary key |
| channelId | `String` | foreign key to ChannelProfile.channelId |
| title | `String` |  |
| description | `String` |  |
| tagsJson | `String` | JSON array of strings; empty array when YouTube returns no tags |
| thumbnailUrl | `String` | nullable |
| publishedAt | `Instant` | stored as epoch milliseconds |
| viewCount | `Long` |  |
| likeCount | `Long` | nullable |
| commentCount | `Long` | nullable |
| lastSyncedAt | `Instant` | stored as epoch milliseconds |

### VideoAnalyticsSnapshot (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| videoId | `String` | foreign key to VideoSnapshot.videoId |
| startDate | `String` | ISO-8601 date, yyyy-MM-dd |
| endDate | `String` | ISO-8601 date, yyyy-MM-dd |
| views | `Long` |  |
| estimatedMinutesWatched | `Long` |  |
| averageViewDurationSeconds | `Double` |  |
| subscribersGained | `Long` |  |
| syncedAt | `Instant` | stored as epoch milliseconds |

### SeoScoreRecord (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| videoId | `String` | nullable; foreign key to VideoSnapshot.videoId when scoring an existing video |
| targetKeyword | `String` | lowercased and trimmed before scoring |
| title | `String` |  |
| description | `String` |  |
| tagsJson | `String` | JSON array of strings |
| score | `Int` | 0 to 100 |
| explanationJson | `String` | JSON array of weighted checklist items |
| inputHash | `String` | SHA-256 of formulaVersion, normalized keyword, title, description, and tagsJson |
| formulaVersion | `Int` | v1 constant is 1 |
| createdAt | `Instant` | stored as epoch milliseconds; not used in scoring |

### AiSuggestionRecord (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| videoId | `String` | nullable; foreign key to VideoSnapshot.videoId when available |
| targetKeyword | `String` |  |
| promptHash | `String` | SHA-256 of normalized prompt payload |
| suggestionsJson | `String` | JSON object containing titleIdeas, descriptionIdeas, tagIdeas, and explanation |
| createdAt | `Instant` | stored as epoch milliseconds |

### EntitlementCache (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| appUserId | `String` | primary key; RevenueCat app user id |
| isPremium | `Boolean` | true when RevenueCat active entitlement id is "premium" |
| expiresAt | `Instant` | nullable for non-expiring entitlement; stored as epoch milliseconds |
| lastCheckedAt | `Instant` | stored as epoch milliseconds |

## Features

### Persistent YouTube OAuth session

Keeps the creator connected to YouTube across app restarts and days without forcing daily re-login.

- **Answers complaint:** Forced daily re-login

- **Screens:** Connect YouTube, Dashboard, Settings

- **Estimated hours:** 32

**Implementation notes:** Use AppAuth with Google OAuth authorization-code flow and scopes `https://www.googleapis.com/auth/youtube.readonly https://www.googleapis.com/auth/yt-analytics.readonly`. Store access token, refresh token, expiry, and scopes in `EncryptedSharedPreferences` from `androidx.security:security-crypto`. Before every YouTube API call, check `Instant.now().plusSeconds(60) >= accessTokenExpiresAt`; if true, POST a refresh-token grant to `https://oauth2.googleapis.com/token` using Ktor. On refresh success, overwrite the encrypted access token and expiry and continue the original request. On refresh HTTP 400 invalid_grant, delete only `OAuthSession` and route to Connect YouTube with an auth_error state. Do not clear Room channel/video/score data on token expiry.

**Acceptance criteria:**
- After a successful OAuth connection, killing and relaunching the app opens Dashboard rather than Connect YouTube.
- When the stored access token is expired and the refresh token is valid, the next sync refreshes the token and completes without showing a login screen.
- When the refresh endpoint returns invalid_grant, the app routes to Connect YouTube and keeps existing locally stored video and score records.

### Reliable YouTube video and analytics sync

Fetches the connected channel's latest videos and basic performance metrics into local storage with visible sync status.

- **Answers complaint:** new videos not syncing into the app

- **Screens:** Dashboard, SEO Workspace

- **Estimated hours:** 48

**Implementation notes:** After token validation, call `GET https://www.googleapis.com/youtube/v3/channels?part=snippet,statistics,contentDetails&mine=true` to store `ChannelProfile` and the uploads playlist id. Fetch videos by paging `GET https://www.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails&playlistId={uploadsPlaylistId}&maxResults=50&pageToken={token}` until no nextPageToken or 200 items have been read for v1. Batch video IDs in groups of 50 and call `GET https://www.googleapis.com/youtube/v3/videos?part=snippet,statistics&id={commaSeparatedIds}` to upsert `VideoSnapshot`. For each visible video detail opened in SEO Workspace, call YouTube Analytics API `GET https://youtubeanalytics.googleapis.com/v2/reports?ids=channel==MINE&startDate={todayMinus28Days}&endDate={yesterday}&metrics=views,estimatedMinutesWatched,averageViewDuration,subscribersGained&dimensions=video&filters=video=={videoId}` and store `VideoAnalyticsSnapshot`. Use a single sync coroutine guarded by a Mutex so repeated taps on Sync Now do not start concurrent syncs. Show last successful sync and an error message containing the failed endpoint category, not raw tokens.

**Acceptance criteria:**
- A mocked uploads playlist response containing a video ID not present in Room results in a new `VideoSnapshot` row after sync.
- If YouTube pagination returns two pages, both pages are processed and the Dashboard displays videos from page one and page two.
- If a sync HTTP request fails, existing Dashboard data remains visible and the screen shows sync_error with a retry button.
- Tapping Sync Now three times quickly produces only one active network sync job.

### Baseline channel dashboard

Gives small creators a usable overview of their connected channel and recent videos before they enter SEO scoring.

- **Answers complaint:** baseline parity

- **Screens:** Dashboard

- **Estimated hours:** 24

**Implementation notes:** Render `ChannelProfile` from Room as the Dashboard header and `VideoSnapshot` sorted by `publishedAt DESC` as a lazy column. Use Coil to load `thumbnailUrl`; if null or load failure, show a Material 3 placeholder box with the first letter of the title. Each row shows title, publish date, view count, and the most recent `SeoScoreRecord.score` for that `videoId` if one exists. The empty state appears only when a valid OAuth session exists and Room contains zero videos; it must offer a Sync Now button. The loading state appears while the first Room query is running and no cached channel exists.

**Acceptance criteria:**
- With one `ChannelProfile` and three `VideoSnapshot` rows in Room, Dashboard shows the channel title and all three video titles in descending publish-date order.
- With a video that has no thumbnail URL, the row still renders with a placeholder and does not crash.
- With a valid OAuth session and zero videos, Dashboard shows an empty state and a Sync Now button.

### Deterministic explainable SEO scoring

Scores title, description, tags, and target keyword with a fixed formula that gives the same answer for the same inputs every day.

- **Answers complaint:** Inconsistent SEO scoring

- **Screens:** SEO Workspace, Dashboard

- **Estimated hours:** 64

**Implementation notes:** Implement a pure Kotlin `SeoScorer.score(input): SeoScoreResult` with formulaVersion 1 and no dependency on clock, network, random numbers, analytics freshness, or subscription state. Normalize keyword, title, description, and tags with lowercase, trim, Unicode NFKC normalization, and collapse internal whitespace. Score exactly 100 points: title contains exact keyword 20; title length 45-70 chars 10, else 5 for 30-44 or 71-85, else 0; keyword appears within first 60 title chars 10; description contains exact keyword in first 160 chars 15; description length at least 250 chars 10; at least 5 tags 10; at least one tag exactly equals keyword 10; at least 3 tags contain one non-stopword token from keyword 10; no repeated tag after normalization 5. Clamp total to 0..100. Return explanation items as JSON with `label`, `earned`, `possible`, and `detail`. Compute `inputHash` as SHA-256 over `formulaVersion|keyword|title|description|tagsJson` and store every recalculation in `SeoScoreRecord`.

**Acceptance criteria:**
- The same title, description, tags, and keyword produce the same numeric score and explanation JSON when the device date is changed.
- Changing only `createdAt` on a stored score record does not change the recalculated score.
- A title/tag combination that earns 80 once earns 80 again when recalculated from identical normalized inputs.
- The SEO Workspace shows the point breakdown whose earned values sum to the displayed score.

### Creator-safe no-rival workflow

Avoids the incumbent's trap-like competitor feature by keeping v1 suggestions grounded in the creator's own videos and entered keyword instead of showing rival videos.

- **Answers complaint:** A "trap" competitor feature Reviewers say the competitor-tracking feature functions as a trap that drives views to rivals rather than helping the user.

- **Screens:** Dashboard, SEO Workspace, Settings

- **Estimated hours:** 12

**Implementation notes:** Do not create any route, tab, DAO, or API request for competitor channels, competitor videos, rival URLs, or search-result browsing. Bottom navigation contains exactly Dashboard, SEO Workspace, and Settings. AI prompt construction may include the user's own `VideoSnapshot`, `VideoAnalyticsSnapshot`, current draft fields, and target keyword, but must not include competitor channel names or external video links. If future YouTube search data is added, keep it behind a separate feature flag defaulted off; v1 must compile and run with no competitor UI.

**Acceptance criteria:**
- No visible navigation item, button, or screen title contains `Competitor`, `Rival`, or `Track rivals`.
- The Compose navigation graph has no route containing `competitor` or `rival`.
- The AI request payload generated from SEO Workspace contains the user's own video fields and target keyword, and contains no external video URL field.

### LLM-backed AI coach

Generates title, description, and tag suggestions grounded in the creator's own YouTube metadata and analytics.

- **Answers complaint:** Pricing out smaller creators Reviewers in lower-income markets say the premium tier (and the AI chat specifically) is priced well above what a pre-revenue creator can justify.

- **Screens:** SEO Workspace, Settings

- **Estimated hours:** 48

**Implementation notes:** When the user taps AI Coach and RevenueCat entitlement `premium` is active, build a JSON prompt payload containing: current title, description, tags, target keyword, video publish date, views, subscribersGained, averageViewDurationSeconds, and the deterministic score explanation. Send it with Ktor as a POST to the configured `ai_gateway_llm` endpoint with temperature 0.2 and a response schema requiring `titleIdeas: String[5]`, `descriptionIdeas: String[3]`, `tagIdeas: String[15]`, and `explanation: String`. Cache successful responses in `AiSuggestionRecord` by `promptHash`; if the same promptHash exists, render the cached suggestion without a new API call. If entitlement is not active, show the paywall card but keep deterministic scoring usable. If the AI call fails or returns invalid JSON, show ai_error and leave the previous score visible.

**Acceptance criteria:**
- With active premium entitlement and no cached promptHash, tapping AI Coach sends one AI gateway request containing the deterministic score explanation.
- With the same promptHash already stored, tapping AI Coach renders cached suggestions and sends zero network requests.
- With no premium entitlement, tapping AI Coach shows the paywall card and does not call the AI gateway.
- When the AI gateway returns malformed JSON, SEO Workspace shows ai_error and the deterministic score card remains visible.

### $4.99/month subscription

Uses a low monthly subscription price for premium AI Coach access while leaving core sync and deterministic scoring usable without paying.

- **Answers complaint:** Pricing out smaller creators

- **Screens:** SEO Workspace, Settings

- **Estimated hours:** 40

**Implementation notes:** Configure RevenueCat with one entitlement id `premium` and one monthly package displayed in-app as `$4.99/month`. On app start and Settings open, call RevenueCat customer info and upsert `EntitlementCache`. Free users can connect YouTube, sync videos, view Dashboard, and run deterministic SEO scoring. Premium users additionally unlock AI Coach requests. Settings must show Subscribe, Restore Purchases, current entitlement state, and purchase errors. Use RevenueCat purchase callbacks to set purchase_in_progress, subscribed, or purchase_error; never unlock AI Coach from a local button state without confirmed active entitlement.

**Acceptance criteria:**
- Settings displays the subscription price as exactly `$4.99/month`.
- A free user can complete YouTube sync and calculate a deterministic SEO score without subscribing.
- A free user cannot send an AI Coach request and instead sees the paywall card.
- After a mocked RevenueCat customer info response with active `premium`, AI Coach becomes available.

### Session health and disconnect controls

Lets the user verify YouTube session health, restore purchases, and intentionally disconnect without accidental data loss.

- **Answers complaint:** Reliability/session bugs Forced daily re-login

- **Screens:** Settings, Connect YouTube

- **Estimated hours:** 12

**Implementation notes:** Settings reads `OAuthSession` and shows Connected when a refresh token exists. The Refresh session test button runs the same refresh-token path used before API calls and displays success time or session_error. Disconnect YouTube deletes `OAuthSession` only after a confirmation dialog; keep Room `ChannelProfile`, `VideoSnapshot`, `VideoAnalyticsSnapshot`, `SeoScoreRecord`, and `AiSuggestionRecord` so reconnecting can show cached data while syncing fresh data. Restore Purchases calls RevenueCat `restorePurchases` and updates `EntitlementCache` from returned customer info.

**Acceptance criteria:**
- Disconnect shows a confirmation dialog before deleting OAuth tokens.
- After disconnect, encrypted OAuth tokens are absent and local score/video records remain in Room.
- A successful Refresh session test updates the displayed session health timestamp.
- A failed Refresh session test shows session_error without deleting local video or score data.

## Store listing

- **Title:** TubeClarity SEO
- **Short description:** Reliable YouTube SEO scores and AI tips for small creators.
- **Category:** Productivity
- **Keywords:** YouTube SEO, creator tools, video tags, title optimizer, channel analytics, AI coach
- **Icon prompt:** Create a clean Android app icon for a YouTube creator SEO tool named TubeClarity SEO: rounded square background in deep blue #2563EB, centered white line-art upward chart with a small play-triangle inside the chart line, modern flat vector style, no text, no YouTube logo, high contrast, suitable for adaptive launcher icon.

**Long description:**

TubeClarity SEO helps small YouTube creators improve titles, descriptions, and tags without daily login headaches or mysterious score changes. Connect your YouTube channel, sync your recent videos, and get a deterministic SEO score with a clear point-by-point explanation. Premium unlocks an AI Coach that suggests titles, descriptions, and tags grounded in your own video metadata and analytics. No competitor-tracking trap, no rival-video feed, and no confusing score that changes just because the day changed.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://example.com/tubeclarity-seo/privacy (privacy claims verified: no)
- **Data collected:** YouTube OAuth access and refresh tokens stored encrypted on the user's device; YouTube channel ID, channel title, thumbnail URL, subscriber count, video count, and uploads playlist ID stored locally; YouTube video IDs, titles, descriptions, tags, thumbnails, publish dates, and public statistics stored locally; YouTube analytics metrics for the connected creator's own videos stored locally; SEO scoring inputs and score explanations stored locally; AI suggestion prompts and responses stored locally; RevenueCat subscription entitlement status

## Test plan

### 1. Inconsistent SEO scoring (unit)

1. Create a `SeoScoreInput` with title `How to Start a Budget Travel Channel in 2025`, description containing `budget travel` in the first 160 characters and more than 250 total characters, tags `["budget travel", "travel vlog", "cheap flights", "backpacking", "travel tips"]`, and targetKeyword `budget travel`.
2. Call `SeoScorer.score(input)` and store the numeric score and explanation JSON.
3. Run the same scorer call again after injecting a fake clock date 30 days later; the scorer should not read the clock.
4. Compare the two results byte-for-byte for score and explanation JSON.

**Expected:** Both calls return the same score and identical explanation JSON.

### 2. Forced daily re-login (unit)

1. Insert an encrypted `OAuthSession` with an expired access token, valid refresh token `refresh_ok`, and expiry one hour in the past.
2. Start a MockWebServer endpoint for `https://oauth2.googleapis.com/token` behavior that returns HTTP 200 JSON with `access_token: new_access`, `expires_in: 3600`, and the original scope string.
3. Call the repository method that prepares an authenticated YouTube request.
4. Read the encrypted session back from storage.

**Expected:** The repository returns an Authorization header `Bearer new_access`, updates `accessTokenExpiresAt` to a future time, and does not request UI reauthorization.

### 3. new videos not syncing into the app (unit)

1. Seed Room with one `VideoSnapshot` whose videoId is `old_1`.
2. Configure MockWebServer playlistItems response with video IDs `old_1` and `new_2` and no nextPageToken.
3. Configure MockWebServer videos response for both IDs with snippet and statistics fields.
4. Run `syncNow()` once.
5. Query Room for all `VideoSnapshot` rows sorted by publishedAt descending.

**Expected:** Room contains both `old_1` and `new_2`, and Dashboard state emitted by the ViewModel is populated rather than empty.

### 4. A trap competitor feature (instrumented)

1. Launch the app with a fake valid OAuth session and seeded Room data for one channel and one video.
2. Navigate through Dashboard, SEO Workspace, and Settings using the bottom navigation.
3. Search the Compose semantics tree on each screen for text containing `Competitor`, `Rival`, `Track rivals`, or `rival video`.
4. Inspect the test navigation graph route list exposed by the app test rule.

**Expected:** No matching competitor/rival text exists and no navigation route contains `competitor` or `rival`.

### 5. Pricing out smaller creators (instrumented)

1. Launch SEO Workspace with fake RevenueCat customer info containing no active `premium` entitlement.
2. Enter a target keyword, title, description, and five tags.
3. Tap Recalculate Score.
4. Tap AI Coach.
5. Navigate to Settings.

**Expected:** The deterministic SEO score appears before payment, AI Coach shows a paywall instead of making a network call, and Settings displays `$4.99/month`.

### 6. baseline parity (manual)

1. Install a release build on an Android 14 physical device.
2. Open the app and tap Connect YouTube.
3. Complete Google OAuth for a YouTube channel that has at least one uploaded video.
4. Return to the app and tap Sync Now on Dashboard.
5. Tap the first video row and enter a target keyword in SEO Workspace.
6. Tap Recalculate Score.

**Expected:** The app shows the connected channel, at least one synced video, opens the selected video in SEO Workspace, and displays a 0-100 SEO score with a visible point breakdown.

### 7. competitor-tracking tab that freezes (manual)

1. Install a release build and connect a YouTube channel.
2. Use the app for five minutes by switching repeatedly between Dashboard, SEO Workspace, and Settings.
3. Confirm there is no competitor-tracking tab to open.
4. Tap Sync Now twice during the session.

**Expected:** The app remains responsive, sync shows one visible progress state at a time, and there is no competitor-tracking tab or rival feed.

## Build instructions

```sh
keytool -genkeypair -v -keystore upload-keystore.jks -storepass changeit-change-before-release -keypass changeit-change-before-release -alias upload -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=Channel Clarity Coach,O=Solo Builder,C=US"
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
./gradlew :app:bundleRelease -Pandroid.injected.signing.store.file=$PWD/upload-keystore.jks -Pandroid.injected.signing.store.password=changeit-change-before-release -Pandroid.injected.signing.key.alias=upload -Pandroid.injected.signing.key.password=changeit-change-before-release
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
