# ClearWave Podcasts — build spec

A podcast player for Castbox switchers that promises no fake-close full-screen ads, no ad-driven playback interruption, and one-step OPML/RSS import.

## 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:** Podcast Player - Castbox
- **Package id:** `fm.castbox.audiobook.radio.podcast`
- **Google Play:** https://play.google.com/store/apps/details?id=fm.castbox.audiobook.radio.podcast
- **appy.fyi report:** https://appy.fyi/report/fm.castbox.audiobook.radio.podcast
- **Category:** Music & Audio

## Overview

- **Working name:** ClearWave Podcasts (trademark cleared: no)
- **Package id:** `fyi.appy.clearwavepodcasts`
- **Min / target SDK:** 26 / 35
- **Backend:** none
- **Estimated build time:** 8 weeks
- **Pricing:** subscription, $2.99 via `revenuecat`
- **Runtime AI:** none
- **Permissions:** `INTERNET`, `POST_NOTIFICATIONS`

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

- No full-screen interstitial ads, fake close buttons, lock-screen takeovers, or third-party ad SDKs in v1.
- No automatic podcast subscriptions or automatic episode downloads in v1; every subscription and download must follow an explicit user action.
- No user accounts, cloud sync, comments, social feeds, creator monetization, or cross-device web app in v1.
- No AI episode summaries or generated chapter markers in v1 because the report describes AI as modest and not central to the market gap.
- No advanced podcast editing, recording, hosting, or publishing tools.
- No Chromecast, Wear OS, CarPlay, or desktop support in v1 beyond Android Auto media browsing/playback.

## Tech stack

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

| Purpose | Gradle coordinate |
| --- | --- |
| Main Compose activity integration | `androidx.activity:activity-compose:1.9.3` |
| Compose navigation graph | `androidx.navigation:navigation-compose:2.8.4` |
| Lifecycle-aware Compose state collection | `androidx.lifecycle:lifecycle-runtime-compose:2.8.7` |
| Local database for podcasts, episodes, subscriptions, downloads, playlists, and playback progress | `androidx.room:room-runtime:2.6.1` |
| Room Kotlin coroutine extensions | `androidx.room:room-ktx:2.6.1` |
| Room annotation processor for generated DAO implementations | `androidx.room:room-compiler:2.6.1` |
| Reliable podcast streaming, media sessions, background playback, and Android Auto integration | `androidx.media3:media3-exoplayer:1.4.1` |
| Media session and MediaLibraryService support for lock screen, background controls, and Android Auto | `androidx.media3:media3-session:1.4.1` |
| Media3 Compose/player UI support where needed for player controls | `androidx.media3:media3-ui:1.4.1` |
| Media3 download database used by DownloadManager | `androidx.media3:media3-database:1.4.1` |
| OkHttp-backed Media3 data source for streaming and downloads | `androidx.media3:media3-datasource-okhttp:1.4.1` |
| HTTP client for RSS feeds, OPML feed URL resolution, and iTunes podcast search requests | `com.squareup.okhttp3:okhttp:4.12.0` |
| Podcast artwork loading and caching in Compose lists and detail screens | `io.coil-kt:coil-compose:2.7.0` |
| JSON decoding for Apple iTunes Search API podcast results | `org.jetbrains.kotlinx:kotlinx-serialization-json:1.7.3` |
| Subscription purchase and entitlement handling for the $2.99/month ad-free tier | `com.revenuecat.purchases:purchases:8.10.6` |

## Design system

- **Primary color:** `#176B87`
- **Background color:** `#F7FAFC`
- **Error color:** `#B3261E`
- **Typography:** Material 3 default type scale, no custom font
- **Launcher icon glyph:** Phosphor `headphones` (regular weight)
- **Theme notes:** Use Material 3 dynamic color only when Android 12+ system dynamic color is available; otherwise use #176B87 as primary in light mode and #7DD3FC as primary in dark mode. Light background is #F7FAFC; dark background is #0B1220. Cards use rounded 16dp corners, no gradients, and no ad-like full-screen surfaces.

## Screens

### OnboardingImport
- **Route:** `onboarding/import`
- **Purpose:** First-run entry for importing existing subscriptions by OPML file or adding a single RSS feed URL.
- **Reached via:** app launch when no subscriptions exist; tap Import OPML from Settings
- **Key UI elements:** App promise panel stating: no fake full-screen ads and no ad that pauses playback; Import OPML button using Android document picker; Add RSS URL text field; Continue to Library button; Import progress row with parsed feed count and failed feed count; Error banner with retry and skip actions
- **States:** empty, loading, error, populated

### Library
- **Route:** `library`
- **Purpose:** Home screen showing subscribed podcasts, recent episodes, and a non-interruptive upgrade card for non-subscribers.
- **Reached via:** app launch after onboarding; bottom navigation Library item; system back from PodcastDetail, SearchDiscovery, Downloads, Playlists, or SettingsSubscription
- **Key UI elements:** Top app bar with Search, Downloads, Playlists, and Settings icons; Subscribed podcasts horizontal row; Recent episodes vertical list; Mini-player anchored at bottom when playback session exists; Inline upgrade card for non-subscribers only; never full-screen; Empty library call-to-action to import OPML or search
- **States:** loading, empty, error, populated

### PodcastDetail
- **Route:** `podcast/{podcastId}`
- **Purpose:** Shows podcast artwork, description, subscribe state, and episode list for one podcast.
- **Reached via:** tap podcast tile on Library; tap podcast search result on SearchDiscovery; tap podcast title from Player
- **Key UI elements:** Podcast artwork; Podcast title and publisher; Subscribe or Unsubscribe button; Refresh feed button; Episode list with play, add to playlist, and download buttons; No automatic download toggle
- **States:** loading, empty, error, populated

### Player
- **Route:** `player/{episodeId}`
- **Purpose:** Full-screen now-playing experience with reliable playback controls and no advertising surface that can pause or cover playback.
- **Reached via:** tap episode play button from Library; tap episode play button from PodcastDetail; tap mini-player; tap episode from Downloads; tap episode from PlaylistDetail; Android Auto media item selection
- **Key UI elements:** Episode artwork; Podcast and episode title; Play/pause button; Skip back 15 seconds button; Skip forward 30 seconds button; Seek bar with elapsed and remaining time; Playback speed selector from 0.75x to 2.0x; Download button; Add to playlist button; Retry banner for recoverable stream errors
- **States:** loading, error, populated

### Downloads
- **Route:** `downloads`
- **Purpose:** Lists episodes explicitly downloaded by the user and active download progress.
- **Reached via:** tap Downloads icon on Library; bottom navigation Downloads item
- **Key UI elements:** Downloaded episodes list; Active downloads section with progress percentage; Pause download button; Cancel download button; Delete downloaded file button; Storage used summary
- **States:** loading, empty, error, populated

### SearchDiscovery
- **Route:** `search`
- **Purpose:** Searches public podcasts and lets switchers add shows without manual feed hunting.
- **Reached via:** tap Search icon on Library; tap Find Podcasts from empty Library
- **Key UI elements:** Search text field; Search results list from Apple iTunes Search API; Podcast result artwork, title, author, and subscribe button; Manual RSS URL entry action; Recent searches list
- **States:** empty, loading, error, populated

### Playlists
- **Route:** `playlists`
- **Purpose:** Shows user-created episode playlists.
- **Reached via:** tap Playlists icon on Library; bottom navigation Playlists item
- **Key UI elements:** Create playlist floating action button; Playlist list with name and episode count; Empty state explaining playlists; Rename playlist action; Delete playlist action
- **States:** loading, empty, error, populated

### PlaylistDetail
- **Route:** `playlist/{playlistId}`
- **Purpose:** Shows and plays episodes saved to one playlist.
- **Reached via:** tap playlist row on Playlists; after creating a playlist from Playlists
- **Key UI elements:** Playlist title; Episode list in saved order; Drag handle for manual ordering; Remove episode from playlist action; Play all button
- **States:** loading, empty, error, populated

### SettingsSubscription
- **Route:** `settings`
- **Purpose:** Contains import tools, subscription status, privacy promise, and app settings.
- **Reached via:** tap Settings icon on Library; tap upgrade card on Library
- **Key UI elements:** $2.99/month ad-free subscription card; Restore purchases button; Current entitlement status; Import OPML button; Add RSS feed button; Privacy summary stating no third-party ad SDK; Notification permission explainer for playback/download notifications
- **States:** loading, error, populated

## Data model

### Podcast (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; stable SHA-256 hash of normalized feedUrl |
| feedUrl | `String` | unique, normalized absolute URL |
| title | `String` |  |
| author | `String` | empty string when RSS author is missing |
| description | `String` | plain text with HTML tags stripped |
| artworkUrl | `String?` | nullable |
| websiteUrl | `String?` | nullable |
| lastRefreshAt | `Instant?` | nullable; Room type converter stores epoch milliseconds |

### Subscription (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| podcastId | `String` | primary key; foreign key to Podcast.id |
| subscribedAt | `Instant` | Room type converter stores epoch milliseconds |
| source | `String` | one of OPML_IMPORT, RSS_URL, SEARCH_RESULT; never SYSTEM_AUTO |

### Episode (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `String` | primary key; SHA-256 of podcastId plus RSS guid or enclosureUrl |
| podcastId | `String` | foreign key to Podcast.id |
| guid | `String` | RSS guid when present, otherwise enclosureUrl |
| title | `String` |  |
| description | `String` | plain text with HTML tags stripped |
| audioUrl | `String` | enclosure URL; required to play |
| mimeType | `String?` | nullable RSS enclosure type |
| durationMs | `Long?` | nullable |
| publishedAt | `Instant?` | nullable; Room type converter stores epoch milliseconds |
| artworkUrl | `String?` | nullable; episode image or podcast artwork fallback |

### PlaybackProgress (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| episodeId | `String` | primary key; foreign key to Episode.id |
| positionMs | `Long` | last persisted playback position |
| durationMs | `Long?` | nullable player duration |
| completed | `Boolean` | true when position is within final 30 seconds of known duration |
| updatedAt | `Instant` | Room type converter stores epoch milliseconds |

### EpisodeDownload (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| episodeId | `String` | primary key; foreign key to Episode.id |
| status | `String` | QUEUED, DOWNLOADING, COMPLETED, FAILED, PAUSED, CANCELED |
| progressPercent | `Int` | 0 through 100 |
| localUri | `String?` | nullable until completed; app-private file URI |
| bytesDownloaded | `Long` |  |
| totalBytes | `Long?` | nullable when server does not provide content length |
| requestedAt | `Instant` | set only after explicit user tap |
| lastError | `String?` | nullable |

### Playlist (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| id | `Long` | primary key, autogenerate |
| name | `String` |  |
| createdAt | `Instant` | Room type converter stores epoch milliseconds |
| updatedAt | `Instant` | Room type converter stores epoch milliseconds |

### PlaylistEpisode (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| playlistId | `Long` | composite primary key part 1; foreign key to Playlist.id |
| episodeId | `String` | composite primary key part 2; foreign key to Episode.id |
| sortOrder | `Int` | 0-based manual order within playlist |
| addedAt | `Instant` | Room type converter stores epoch milliseconds |

### SearchHistory (`room_local`)

| Field | Type | Notes |
| --- | --- | --- |
| query | `String` | primary key; trimmed search text |
| searchedAt | `Instant` | Room type converter stores epoch milliseconds |

## Features

### OPML and RSS import

Imports podcast subscriptions from an OPML file or a manually entered RSS URL so switchers can bring their existing library over in one step.

- **Answers complaint:** Ship simple OPML/RSS import so a switcher can bring years of subscriptions over in one step, no manual re-adding.

- **Screens:** OnboardingImport, Library, SettingsSubscription

- **Estimated hours:** 52

**Implementation notes:** Use ACTION_OPEN_DOCUMENT with MIME types text/xml, application/xml, and */* for OPML selection. Parse OPML using org.xmlpull.v1.XmlPullParser from Android framework, reading every <outline> element with xmlUrl; ignore outlines without xmlUrl. Normalize each feed URL by trimming, requiring http or https, removing URL fragments, and preserving query parameters. For each URL, fetch with OkHttp GET using 15 second connect/read timeouts, parse RSS with XmlPullParser, and create Podcast plus Episode rows in one Room transaction. RSS parsing must read channel title, description, link, itunes:image href, image/url, item guid, item title, item description, item pubDate, item enclosure url/type/length, and itunes:duration. Atom feeds may be rejected with a visible per-feed error in v1 rather than crashing. The import completion state shows imported count and failed URL count; failed URLs are not retried silently.

**Acceptance criteria:**
- Given an OPML file containing 3 valid xmlUrl attributes, import creates exactly 3 Podcast rows and 3 Subscription rows.
- Given an OPML file containing one invalid URL and one valid RSS URL, the valid feed imports and the UI displays failed count 1.
- Given the same OPML file imported twice, Podcast and Subscription counts do not duplicate because Podcast.id is based on normalized feedUrl.
- Given a manual RSS URL that returns a podcast feed with episodes, tapping Add creates one Podcast row and at least one Episode row.

### Explicit-only subscriptions and downloads

Prevents the app from subscribing to shows or downloading episodes unless the user explicitly taps the relevant button.

- **Answers complaint:** Unwanted subscriptions/downloads

- **Screens:** OnboardingImport, PodcastDetail, SearchDiscovery, Downloads

- **Estimated hours:** 32

**Implementation notes:** Create a single SubscriptionRepository.subscribe(podcastId, source) method and require source to be OPML_IMPORT, RSS_URL, or SEARCH_RESULT; do not expose any background recommendation or auto-subscribe API. Create a single DownloadRepository.requestDownload(episodeId, requestedByUser: Boolean) method and throw IllegalArgumentException if requestedByUser is false. Feed refresh may insert Episode rows but must never insert Subscription or EpisodeDownload rows. Search result rows must stay transient until the user taps Subscribe. OPML import counts as explicit only after the user selects a file and taps Confirm Import on the parsed preview.

**Acceptance criteria:**
- Refreshing all subscribed feeds inserts new Episode rows but creates zero new Subscription rows.
- Opening SearchDiscovery and viewing results creates zero Subscription rows until a Subscribe button is tapped.
- Calling DownloadRepository.requestDownload with requestedByUser=false fails and creates no EpisodeDownload row.
- After tapping Download on an episode, exactly one EpisodeDownload row is created for that episode.

### Reliable background playback

Streams podcast episodes with Media3 background playback, persisted progress, retry handling, and controls that are never interrupted by ads.

- **Answers complaint:** Playback reliability

- **Screens:** Library, PodcastDetail, Player, Downloads, PlaylistDetail

- **Estimated hours:** 64

**Implementation notes:** Implement a singleton PlaybackService extending MediaSessionService and backed by ExoPlayer from AndroidX Media3. Build MediaItems with mediaId=Episode.id, uri=Episode.audioUrl or completed download localUri, and MediaMetadata title/artist/artworkUri. Persist PlaybackProgress every 5 seconds while STATE_READY and whenever playback pauses, seeks, or the service is destroyed. On player error caused by HttpDataSourceException or SocketTimeoutException, show a Retry banner and retry the same MediaItem with exponential delays of 1s, 2s, and 4s, stopping after 3 retries. The only code paths allowed to call player.pause() are user play/pause, audio focus loss, headset unplug becoming noisy, and service shutdown; subscription upgrade cards must not access Player or MediaController. Use skip back 15 seconds and skip forward 30 seconds buttons with bounds clamped to 0 and duration.

**Acceptance criteria:**
- Starting an episode continues playback when the app is backgrounded and shows Android media notification controls.
- Pausing at 12 minutes 34 seconds, killing the app process, and reopening the episode resumes within 5 seconds of 12:34.
- A transient stream timeout triggers visible Retry state and performs no more than 3 automatic retries.
- Displaying or tapping the upgrade card does not call player.pause() and does not change playback state.

### Offline downloads

Downloads user-selected episodes to app-private storage for offline playback with visible progress and cancel/delete controls.

- **Answers complaint:** baseline parity

- **Screens:** PodcastDetail, Player, Downloads

- **Estimated hours:** 42

**Implementation notes:** Use Media3 DownloadManager with StandaloneDatabaseProvider from media3-database and OkHttpDataSource.Factory from media3-datasource-okhttp. Create one DownloadRequest per Episode.id with uri=Episode.audioUrl and customCacheKey=Episode.id. Run downloads through a DownloadService so they can continue while the app is backgrounded. Store files in app-private cache managed by Media3 DownloadManager, not shared external storage, so READ_MEDIA_* and MANAGE_EXTERNAL_STORAGE are not requested. Mirror DownloadManager state into EpisodeDownload rows: QUEUED, DOWNLOADING with percent, COMPLETED, FAILED, PAUSED, or CANCELED. PlaybackRepository must prefer a COMPLETED local download over the remote audioUrl.

**Acceptance criteria:**
- Tapping Download on an episode creates a DownloadRequest with id equal to Episode.id.
- During download, Downloads screen shows progress from 0 to 100 or indeterminate when total size is unknown.
- Canceling an active download removes the DownloadRequest and changes EpisodeDownload.status to CANCELED.
- After a completed download, enabling airplane mode still allows that episode to play from its local URI.

### Discovery search

Lets users search for podcasts and subscribe from results without needing to know the RSS URL.

- **Answers complaint:** baseline parity

- **Screens:** SearchDiscovery, PodcastDetail, Library

- **Estimated hours:** 34

**Implementation notes:** Use Apple iTunes Search API with OkHttp GET https://itunes.apple.com/search?media=podcast&entity=podcast&limit=25&term={urlEncodedQuery}. Decode JSON using kotlinx.serialization with ignoreUnknownKeys=true. For each result, display collectionName, artistName, artworkUrl100, and feedUrl when feedUrl is present; hide subscribe action for results without feedUrl. On Subscribe, fetch the feedUrl with the same RSS parser used by import, upsert Podcast and Episode rows, then insert Subscription(source=SEARCH_RESULT). Store non-empty search queries in SearchHistory after successful response.

**Acceptance criteria:**
- Searching for a non-empty term sends exactly one GET request with media=podcast, entity=podcast, limit=25, and the URL-encoded term.
- Results without feedUrl are displayed as unavailable and cannot create a subscription.
- Tapping Subscribe on a result with feedUrl creates one Subscription row with source SEARCH_RESULT.
- If the network request fails, SearchDiscovery shows an error state with Retry and does not clear prior successful results.

### Episode playlists

Allows users to create playlists, add episodes, reorder them, and play the list in order.

- **Answers complaint:** baseline parity

- **Screens:** Playlists, PlaylistDetail, PodcastDetail, Player

- **Estimated hours:** 32

**Implementation notes:** Store Playlist and PlaylistEpisode in Room. Creating a playlist inserts Playlist(name, createdAt, updatedAt). Adding an episode inserts PlaylistEpisode with sortOrder equal to current max sortOrder + 1 for that playlist. Reordering updates all affected sortOrder values in one transaction. Play All builds a Media3 playlist by querying PlaylistEpisode ordered by sortOrder and calling player.setMediaItems(items, startIndex=0, startPositionMs=stored progress for first episode). Deleting a playlist removes PlaylistEpisode rows by foreign-key cascade but does not delete Podcast, Episode, or EpisodeDownload rows.

**Acceptance criteria:**
- Creating a playlist with name Road Trip adds one Playlist row visible on Playlists.
- Adding three episodes to a playlist stores sortOrder values 0, 1, and 2.
- Dragging the third episode to the first position updates playback order to third, first, second.
- Deleting a playlist removes its PlaylistEpisode rows but leaves the underlying Episode rows intact.

### Honest subscription monetization

Offers a $2.99/month ad-free subscription using only inline, user-dismissable upgrade surfaces and no deceptive ad patterns.

- **Answers complaint:** Unskippable, deceptive full-screen ads

- **Screens:** Library, SettingsSubscription

- **Estimated hours:** 34

**Implementation notes:** Integrate RevenueCat Purchases and configure one monthly product at $2.99/month in Play Console and RevenueCat. The app must not include a third-party display ad SDK. For non-subscribers, show only inline upgrade cards in Library and SettingsSubscription; cards must be normal Compose surfaces, never Dialog, Popup, full-screen route, WebView, or system overlay. Dismissed upgrade cards stay hidden for 7 days using local SharedPreferences or DataStore. Subscribers with active entitlement hide all upgrade cards. Restore Purchases calls Purchases.sharedInstance.restorePurchases and refreshes entitlement status. If RevenueCat is unavailable, the app continues normal podcast playback and shows subscription status as temporarily unavailable.

**Acceptance criteria:**
- No dependency with group com.google.android.gms and artifact play-services-ads exists in the Gradle dependency graph.
- Non-subscriber upgrade messaging appears inline in Library or SettingsSubscription and never covers the full screen.
- Tapping play, pause, seek, subscribe, import, search, or navigate never displays a full-screen ad-like surface.
- When RevenueCat entitlement is active, all upgrade cards are hidden.
- If RevenueCat customer info fetch fails, playback and library navigation still work.

### Ad-safe playback controls

Ensures monetization never pauses, covers, or overrides active audio playback.

- **Answers complaint:** Ads that override playback

- **Screens:** Library, Player, SettingsSubscription

- **Estimated hours:** 14

**Implementation notes:** Keep monetization UI state in SubscriptionViewModel only and keep MediaController access in PlaybackViewModel only. Do not inject PlaybackViewModel into upgrade card composables. Add a PlaybackGuard test helper around MediaController in debug builds that records pause callers; assert upgrade card click handlers do not call pause, stop, clearMediaItems, or seekTo. Upgrade card CTA opens SettingsSubscription and does not modify PlaybackService state. If playback is active when SettingsSubscription opens, mini-player remains visible and MediaSession state remains STATE_READY with playWhenReady=true.

**Acceptance criteria:**
- With an episode playing, tapping the Library upgrade card CTA leaves player.isPlaying true.
- With an episode playing, opening SettingsSubscription leaves player.isPlaying true.
- Upgrade card composables compile without a MediaController, Player, or PlaybackViewModel parameter.
- Debug PlaybackGuard records zero pause, stop, or clearMediaItems calls from subscription UI actions.

### Android Auto media library

Exposes subscribed podcasts, recent episodes, downloads, and playlists through Android Auto using Media3 MediaLibraryService.

- **Answers complaint:** Playback reliability

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

- **Estimated hours:** 16

**Implementation notes:** Implement MediaLibraryService with a browse tree containing root children: Subscriptions, Recent Episodes, Downloads, and Playlists. For Subscriptions, child items are Podcast rows; selecting a podcast shows its Episode rows. For Downloads, include only EpisodeDownload.status=COMPLETED episodes. For Playlists, child items are Playlist rows and their ordered PlaylistEpisode children. Add manifest service with android.media.browse.MediaBrowserService action, exported=true, and automotive_app_desc.xml declaring media template support. Media item IDs must be stable: podcast:{id}, episode:{id}, playlist:{id}. onGetItem resolves those IDs from Room and returns browsable or playable MediaItem metadata. Playback uses the same PlaybackService/ExoPlayer instance as the phone UI.

**Acceptance criteria:**
- A MediaBrowser client can connect and retrieve root children named Subscriptions, Recent Episodes, Downloads, and Playlists.
- Downloaded episodes shown through Android Auto are limited to EpisodeDownload.status COMPLETED.
- Selecting an episode media item starts playback through the same MediaSession used by the app UI.
- Android Auto browse tree does not include upgrade cards, ads, or subscription prompts.

## Store listing

- **Title:** ClearWave Podcasts
- **Short description:** Podcast playback with no fake full-screen ads.
- **Category:** Music & Audio
- **Keywords:** podcast player, podcasts, RSS, OPML import, offline podcasts, Android Auto podcasts, ad-free podcasts, playlist
- **Icon prompt:** A clean Android app icon for a podcast player called ClearWave Podcasts: rounded square background in deep teal #176B87, centered simple white over-ear headphones glyph with two subtle audio wave arcs, flat vector style, high contrast, no text, no microphone, no gradients, no brand logos.

**Long description:**

ClearWave Podcasts is built for listeners who want their podcast app to just play podcasts.

Import your library with OPML or add RSS feeds directly, search for shows, download episodes for offline listening, build playlists, and keep listening in the background or in Android Auto.

What ClearWave will not do: no fake X buttons, no full-screen deceptive ads, no ad that pauses your podcast, and no surprise subscriptions or downloads. The optional $2.99/month subscription removes upgrade messages and supports continued development.

## Legal

- **Regulated category:** none
- **Privacy policy URL:** https://clearwave-podcasts.example.com/privacy (privacy claims verified: no)
- **Data collected:** Purchase and subscription status processed through RevenueCat; Podcast search query sent to Apple iTunes Search API only when the user performs a search

## Test plan

### 1. Ship simple OPML/RSS import so a switcher can bring years of subscriptions over in one step, no manual re-adding. (unit)

1. Create an in-memory Room database.
2. Load a test OPML string with three outline elements containing xmlUrl values https://example.com/a.xml, https://example.com/b.xml, and https://example.com/c.xml.
3. Stub OkHttp responses for all three URLs with valid RSS XML containing channel title and one item enclosure each.
4. Run OpmlImportUseCase.import(opmlInputStream).
5. Query PodcastDao.count(), SubscriptionDao.count(), and EpisodeDao.count().

**Expected:** Podcast count is 3, Subscription count is 3, Episode count is 3, and every Subscription.source is OPML_IMPORT.

### 2. Unwanted subscriptions/downloads (unit)

1. Create an in-memory Room database with one existing subscribed podcast.
2. Stub a feed refresh response containing two new episode items.
3. Run FeedRefreshUseCase.refreshSubscribedFeeds().
4. Query SubscriptionDao.count() before and after refresh.
5. Query EpisodeDownloadDao.count() after refresh.

**Expected:** Subscription count is unchanged and EpisodeDownload count remains 0 after feed refresh.

### 3. Unwanted subscriptions/downloads (unit)

1. Create an Episode row in an in-memory Room database.
2. Call DownloadRepository.requestDownload(episodeId, requestedByUser = false).
3. Catch the thrown exception.
4. Query EpisodeDownloadDao.findByEpisodeId(episodeId).

**Expected:** The call throws IllegalArgumentException and no EpisodeDownload row exists for that episode.

### 4. Unskippable, deceptive full-screen ads (instrumented)

1. Launch the app with a fake non-subscriber entitlement.
2. Seed the database with one subscribed podcast and one episode.
3. Navigate Library -> PodcastDetail -> Player -> Library -> SearchDiscovery -> SettingsSubscription.
4. On each screen, inspect the Compose tree for nodes tagged UpgradeCard, FullScreenAd, DialogAd, and WebViewAd.
5. Press back from SettingsSubscription to Library.

**Expected:** UpgradeCard nodes appear only as inline cards on Library or SettingsSubscription; no FullScreenAd, DialogAd, or WebViewAd node exists at any point, and back navigation is never blocked by monetization UI.

### 5. Ads that override playback (instrumented)

1. Seed the database with one podcast and one playable episode using a local test audio URL.
2. Launch the app and start playback from Library.
3. Wait until player state is READY and isPlaying is true.
4. Tap the inline upgrade card CTA on Library.
5. Wait 2 seconds.
6. Read PlaybackGuard recorded calls and MediaController.isPlaying.

**Expected:** MediaController.isPlaying remains true and PlaybackGuard records zero pause, stop, or clearMediaItems calls from the upgrade flow.

### 6. Playback reliability (instrumented)

1. Seed one episode with a playable test audio URL.
2. Start playback and seek to 754000 milliseconds.
3. Press the home button to background the app.
4. Wait 7 seconds.
5. Force-stop and relaunch the app from the test runner.
6. Open the same episode in Player.
7. Read displayed seek position and PlaybackProgressDao row.

**Expected:** PlaybackProgress.positionMs and the Player seek bar are both between 749000 and 759000 milliseconds.

### 7. Playback reliability (instrumented)

1. Configure the test HTTP server to time out the first three audio segment requests and then succeed.
2. Start playback of the test episode.
3. Observe Player error/retry UI state.
4. Advance test dispatcher time through 1 second, 2 seconds, and 4 seconds retry delays.
5. Read retry attempt counter from the fake data source.

**Expected:** The Player shows a retry banner after the first timeout, performs exactly 3 automatic retries, and then reaches READY when the server succeeds.

### 8. baseline parity (instrumented)

1. Seed one episode with a downloadable test audio URL.
2. Open PodcastDetail and tap Download on the episode.
3. Wait until Downloads screen shows the episode as COMPLETED.
4. Disable network access for the test process.
5. Tap the downloaded episode in Downloads.
6. Read the MediaItem local configuration URI used by PlaybackService.

**Expected:** Playback starts successfully while network is disabled and the MediaItem URI is an app-private local/cache URI rather than the original remote audio URL.

### 9. Playback reliability (manual)

1. Install the app on an Android phone paired with an Android Auto test head unit or Android Auto Desktop Head Unit.
2. Create at least one subscription, one completed download, and one playlist.
3. Start Android Auto and open the app from the media launcher.
4. Browse root categories.
5. Open Downloads and select a downloaded episode.
6. Use Android Auto pause and play controls.

**Expected:** Android Auto shows Subscriptions, Recent Episodes, Downloads, and Playlists; selecting a downloaded episode starts playback; pause and play controls update the phone app mini-player state.

### 10. Unskippable, deceptive full-screen ads (manual)

1. Install a fresh build and do not purchase the subscription.
2. Use the app for 10 minutes: import OPML, search for a show, subscribe, start playback, pause, resume, download, create playlist, and open Settings.
3. Record whether any monetization surface covers the whole screen, uses a fake close button, opens an install page, or appears on every navigation action.

**Expected:** No full-screen monetization appears, no fake close button appears, no install page opens, and playback/pause controls are always immediately usable.

## Build instructions

```sh
set -e
./gradlew clean
./gradlew testDebugUnitTest
./gradlew connectedDebugAndroidTest
rm -f clearwave-release.jks
keytool -genkeypair -v -keystore clearwave-release.jks -storepass changeit123 -alias clearwave -keypass changeit123 -keyalg RSA -keysize 2048 -validity 10000 -dname "CN=ClearWave Podcasts,O=SoloBuilder,C=US"
./gradlew bundleRelease -Pandroid.injected.signing.store.file=$PWD/clearwave-release.jks -Pandroid.injected.signing.store.password=changeit123 -Pandroid.injected.signing.key.alias=clearwave -Pandroid.injected.signing.key.password=changeit123
```

## Human gates still required

- `trademark_and_privacy_review`
- `closed_testing_recruitment`
