From 0b6b2a21f146fd7138c5644b038a6eb3ed051972 Mon Sep 17 00:00:00 2001 From: Aswin C Date: Sun, 8 Nov 2026 22:46:56 +0530 Subject: [PATCH] docs: remove server and anti-abuse details from public docs Public READMEs and docs no longer describe request-signing formats, session backoff and verification behaviour, server hosts and paths, internal routes, database rules, remote switch names or rollback steps. They keep the contracts contributors need. Two corrupted README sentences are rewritten from the code they describe. --- .github/PULL_REQUEST_TEMPLATE.md | 2 -- CONTRIBUTING.md | 4 +-- app/README.md | 22 +++++++------- core/analytics/README.md | 3 +- core/catalog/README.md | 18 +++++------ .../src/test/resources/fixtures/v2/README.md | 4 +-- core/model/README.md | 2 +- core/network/README.md | 26 +++++----------- core/playback/README.md | 3 +- core/support/README.md | 16 +++++----- docs/ANNOUNCEMENT_COMPOSER.md | 2 +- docs/TESTING.md | 2 +- docs/recommendation-system.md | 4 +-- feature/briefing/README.md | 2 +- feature/explore/README.md | 2 +- feature/home/README.md | 2 +- feature/info/README.md | 8 ++--- feature/onboarding/README.md | 6 ++-- feature/settings/README.md | 2 +- scripts/README.md | 30 +++++++++---------- 20 files changed, 73 insertions(+), 87 deletions(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index a1f7648ec..15e9c3e5a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,5 +1,3 @@ - - ## Title (required) Use Conventional Commits. Examples from this repo: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a1604f11..df09fdc39 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -77,7 +77,7 @@ BOXLORE_PUBLIC_KEY=demo-placeholder-key Rebuild after changing these values. The placeholder has a valid URL format but supplies no backend: search, recommendations, and briefing cannot retrieve live results. -Against a real backend, the app signs requests with an API session from `POST /auth/session`, not with `BOXLORE_PUBLIC_KEY` (it is still a build field but is never sent). Debug builds attest with the Firebase App Check debug provider. Without a registered debug token they get a limited session, which works except for AI onboarding. To give every debug install the same registered token, add an optional `BOXLORE_APPCHECK_DEBUG_SECRET=` to `local.properties` and register that UUID as an App Check debug token in the Firebase console. Keep it out of Git like the rest of the file. Use local library, downloads, and RSS/OPML flows for checks that do not need the API. Working backend and Firebase services require separately provisioned configuration. +Against a real backend, the app signs requests with an API session from `POST /auth/session`, not with `BOXLORE_PUBLIC_KEY` (it is still a build field but is never sent). Debug builds use the Firebase App Check debug provider. Use local library, downloads, and RSS/OPML flows for checks that do not need the API. Working backend and Firebase services require separately provisioned configuration. Connect a device or start an emulator, then install and launch: @@ -87,4 +87,4 @@ adb devices adb shell am start -n cx.aswin.boxlore/.MainActivity ``` -Use the [test selection guide](docs/TESTING.md#choose-checks-for-a-change) for validation commands and [architecture guide](ARCHITECTURE.md) for module ownership. Local maintainer environments also use root `AGENTS.internal.md` for private workflow and operational instructions. +Use the [test selection guide](docs/TESTING.md#choose-checks-for-a-change) for validation commands and [architecture guide](ARCHITECTURE.md) for module ownership. diff --git a/app/README.md b/app/README.md index 83012ea43..a869d12d6 100644 --- a/app/README.md +++ b/app/README.md @@ -23,9 +23,9 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - The app-scoped updater checks on foreground at most every six hours (failed requests back off for 15 minutes). Settings → Check for updates bypasses the interval. The Home/Library utility capsule gets an icon-only action only for a confirmed newer compatible version. Background checks never open a prompt or start a download. Direct installs read a bounded versioned `update.json` from GitHub Latest, cache the last valid offer and distinguish connection errors from an up-to-date result. Same-version artifact refreshes stay quiet. - Direct APK updates use an app-private, checksum-keyed resumable transfer. Download, verification and installation are separate explicit actions; the Android installer owns confirmation. Before each handoff, the APK's checksum, package, exact version, Android minimum and signing identity are checked again. Interrupted transfers resume; failed integrity checks discard corrupt bytes. Returning from install permission settings or cancelling Android installation retains the Install action. A changed offer discards stale prepared state. Restoration separates session eligibility from verified-file recovery and retains cancellation and checksum guards. - `assembleRelease` builds the direct APK. `bundlePlayRelease` builds the Google Play AAB with release optimization/signing, the same application identity and normal notification receiver, but no APK downloader/installer implementation or `REQUEST_INSTALL_PACKAGES` permission. Play availability uses Play Core and caches the confirmed version across process recreation; the explicit action obtains fresh eligibility before starting a flow or finishing a downloaded flexible update. In-progress immediate Play updates resume on foreground. When a Play build's check fails (Play won't answer for a Play build installed outside the Play Store, or Play is unavailable), Settings → Check for updates says "Couldn’t check Google Play for updates" and leads with **Open Google Play** (`openPlayListing`: the Play Store app's page for boxlore, or the web page without the Play Store), keeping **Try again** under it (30 Oct 2026; `AppUpdatesPanelTest`, `PlayStoreListingTest`). Direct builds keep their own message. Release artifact validation checks both variants separately, including installer exclusion in Play. -- Announcements use native Material 3 Compose surfaces, typography and buttons. Compact alerts and full-screen messages have a fixed category/close header, scrollable Markdown and optional image, and fixed actions. Controlled accent roles, banner/uncropped cover, headings, lists, callouts, links and emphasis are supported; payload HTML remains text. Release alerts have Download update (native metadata check and transfer), View on GitHub (keeps the alert on return), and Dismiss. Normal alerts retain their optional single action. The browser admin preview follows the same visual specification without executing in the app. +- Announcements use native Material 3 Compose surfaces, typography and buttons. Compact alerts and full-screen messages have a fixed category/close header, scrollable Markdown and optional image, and fixed actions. Controlled accent roles, banner/uncropped cover, headings, lists, callouts, links and emphasis are supported; payload HTML remains text. Release alerts have Download update (native metadata check and transfer), View on GitHub (keeps the alert on return), and Dismiss. Normal alerts retain their optional single action. - Built-in feature announcements, such as the Android Auto overlay, spell the product name boxlore in lowercase; `:core:testing`'s `BrandNameGuardTest` enforces this for app copy. -- Announcement payload additions are optional and persist with legacy defaults. Only release alerts are install-channel/version filtered; ordinary and new-episode notifications remain available to both channels. New Play/direct builds retain ordinary broadcast topics and register their install channel. Release sends use direct_users while excluding Play/test topics, with an explicit include-Play override via All installs. Older clients cannot be classified through their shared prod_users topic, so they do not receive these new strict direct-only release sends until updated; the legacy payload/window remains readable for normal sends. +- Announcement payload additions are optional and persist with legacy defaults. Only release alerts are install-channel/version filtered; ordinary and new-episode notifications remain available to both channels. New Play/direct builds retain ordinary broadcast topics and register their install channel. Older clients cannot be classified through their shared prod_users topic, so they do not receive these new strict direct-only release sends until updated; the legacy payload/window remains readable for normal sends. - The updater persists its visible/prepared session through permission settings, Activity recreation and process restart. Opening it refreshes metadata automatically, retaining cached notes while checking and without starting a download. An announcement Download action performs one refresh before transfer. Available-update screens have no redundant check button; failed/current checks retain recovery controls. Its unboxed release-note paragraphs scroll between a fixed header and full-width download/install controls, keeping progress and actions accessible for long notes. Restoration verifies an existing completed file without starting a new transfer or installation. Missing files return to Download; an explicit Install tap rechecks the package/signature and retries Android's permission or installation flow. Completing an update clears the obsolete session on the next app start. Update offers/session preferences are excluded from cloud/device backup. - `assembleUpdateTest` is signed and optimized like release, uses a loopback-only test manifest and test-only metadata, and subscribes only to test_users for announcements. Its ADB-only AnnouncementTestReceiver previews an exported message locally without FCM; this receiver and test marker are rejected by shipping artifact checks. Test update/session preferences are separate. Its mapping upload and Crashlytics collection are disabled, and analytics mark it internal. Test builds must never be published; they intentionally keep the package/signature so installation can preserve listening data. @@ -45,8 +45,8 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - On startup, `BoxLoreApplication` hydrates missing DataStore appearance keys from theme fast-cache on its IO scope, keeping restoration off the first-paint path (Google Backup can restore SharedPreferences without DataStore), and after UI readiness asks `SmartDownloadManager.reconcileScheduleWithPreferences` to keep the Smart Downloads toggle and periodic WorkManager job in sync. It also configures `LearningEventLog` via `BoxcastPrefs.resolveLearnerLogEnabled`: on by default in debug when unset; **always off in release** unless the user has explicitly persisted an opt-in from the debug screen. Once startup work is allowed and before cloud sync starts, it also repairs player-only artwork links that older builds saved into history and the queue (`repairPlaybackArtworkLinks`), so Jump back in shows real covers, history rows that found a real cover upload, and a changed queue is pushed again; with nothing left to repair it changes nothing. - `AppContainer` constructs the shared graph: database, network, auth (`authRepository`), RSS, ranking, catalog, folders (`FolderRepository`), playback, queue, downloads, prefs, and analytics dependencies. It provides the narrow `DeviceIdentityPort` (delegating to `BoxcastPrefs.getOrCreateSyncDeviceId()`) to stamp local mutations for cross-device sync. It constructs `UserSyncCoordinator` with real DAOs, conflict resolvers, and token providers, and wires `CloudSyncTriggerCoordinator` to observe process lifecycle, playback milestones, database mutations, and connectivity state. It builds `LocalEpisodeCatalogRepository` with the main Room database and the downloads-owned cache relinker, allowing a repaired full feed to transactionally re-key safely matched legacy listener ids (phone-minted IDs with no alias or an unresolved one) without creating a reverse module dependency. `RssPodcastRepository` gets no relinker: switching a Podcast Index show to its RSS feed links the feed's episodes by alias instead of moving downloads. It gives `AdaptiveCandidateScorer` the database's `EpisodeListenerStore.groupTokens`, so ranking counts an episode stored under several IDs once, gives `HistorySyncResolver` `EpisodeListenerStore.memberIds`, so a pull never overwrites the playing episode's progress under its owner ID, gives `UserSyncCoordinator` the `EpisodeIdentityDao`, so an account switch clears the shared like and completion settings with the history, and gives `UserSyncCoordinator` and `QueueSyncResolver` one `SyncEpisodeIdentity`, so synced history and queue rows carry their episode's recipe ID and catalog key, pulled rows are recorded as aliases before they are merged, and the queue merges per episode. The catalog instance is shared by `PodcastRepository`, `SubscriptionRepository`, `SubscriptionForegroundSync`, and FCM hydration. The same application scope owns `LegacyRssRepair`. - Cloud Synchronization (`sync/`): `CloudSyncWorker` runs guaranteed background synchronization via WorkManager (`schedulePeriodicSync` every 6 hours with network constraint, `enqueueOneShotSync` on app backgrounding or network recovery), mapped in `LegacyWorkerFactory`. `CloudSyncTriggerCoordinator` binds to `ProcessLifecycleOwner`, auth state flows, network connectivity changes, and player milestones (PAUSE, COMPLETED, track changes) to trigger non-polling, battery-friendly delta pushes and pulls, and clears the active playback session on sign-out. It exposes `syncStatusFlow` (`Idle`, `Syncing`, `Success`, `Error`) and `triggerManualSync()` consumed by the Account Settings screen. -- **API sessions (`session/`, 2026-10-17).** `BoxLoreApplication.onCreate` calls `ApiSessionSetup.install` before anything else, so the shared `/auth/session` exchange (`:core:network` `ApiSessionManager`) starts behind the splash and no request goes out before the manager exists. This build sends only `X-Boxlore-Session`, never the bundled app key. The setup sets `X-App-Version` and `X-App-Channel` (`InstallChannel`: `play` for the Play build or a Play install, else `github`; the same rule picks the update flow in `createAppUpdates`), installs App Check (debug provider in debug, Play Integrity in release) with background auto-refresh off, and supplies App Check only to the exchange (`FirebaseAppCheckTokenSource`, failure categories from `AppCheckFailureReason`). The old blocking App Check provider, which made every request wait up to 5 s, is gone. `observe` passes process foreground/background (`ProcessLifecycleOwner`) and connectivity to the manager, which retries App Check for limited sessions only in the foreground with a network. `ApiSessionAnalyticsListener` forwards session events to `api_session_status` and each App Check attempt to `app_check_status` with its reason. -- **Turnstile step-up.** When a limited session reaches an AI onboarding route, the manager asks `TurnstileStepUpController` for a token. `TurnstileStepUpHost`, composed once in `BoxLoreAppRoot` inside the window-layout provider, loads `/auth/turnstile` (a static page on the API host) in a locked-down WebView (`TurnstileWebViewPage`, following Cloudflare's mobile guide: JavaScript, DOM storage, cookies including Turnstile's third-party frame, and the default user agent; but no file or content access, no new windows, top-level navigation limited to the API host, `challenges.cloudflare.com` and `about:blank` / `about:srcdoc`, and an `androidx.webkit` message listener that accepts the `BoxloreTurnstile` bridge only from the API origin). The page runs invisibly inside a zero-size parent (laid out but never drawn, touched or announced) while onboarding keeps its normal thinking state. Only if Turnstile asks for interaction does the same page move into the shared `BoxloreModalBottomSheet` (a phone sheet; a centered dialog on expanded and folded windows) with Echo on its stage beside its line (“I’m the only robot allowed in this chat.”, Echo's curious asking pose, its eyes glancing down at the check), a plain line saying what to tap and why, the check inside a `surfaceContainerHigh` card, and **Skip**. The phone sheet opens fully, Echo and the gaps shrink on windows under 480dp tall, and the content scrolls if it still doesn't fit, so the check and Skip stay reachable. A run with no answer within 20 s (or 2 minutes once interactive), a page error, Skip, Back, a swipe or an outside tap fails it, and onboarding carries on with its non-AI path. The step-up runs only while the PostHog flag `api-turnstile-step-up` is on (on for everyone since 2026-10-31); with the flag off or missing, limited sessions go straight to the non-AI path. Copy lives in `res/values/session_check.xml`. +- **API sessions (`session/`).** `BoxLoreApplication.onCreate` calls `ApiSessionSetup.install` before anything else, so the API session (`:core:network` `ApiSessionManager`) starts behind the splash and no request goes out before the manager exists. The bundled app key is never sent. The setup sets `X-App-Version` and `X-App-Channel` (`InstallChannel`: `play` for the Play build or a Play install, else `github`; the same rule picks the update flow in `createAppUpdates`) and supplies Firebase App Check to the session exchange only (`FirebaseAppCheckTokenSource`). `observe` passes process foreground/background (`ProcessLifecycleOwner`) and connectivity to the manager. `ApiSessionAnalyticsListener` forwards session events to `api_session_status` and App Check attempts to `app_check_status`. +- **AI verification (`turnstile/`).** When the API asks for extra verification on an AI onboarding route, `TurnstileStepUpController` gets a token from a locked-down WebView page (`TurnstileStepUpHost`, composed once in `BoxLoreAppRoot`; `TurnstileWebViewPage`, whose bridge accepts messages only from the API origin). The page runs invisibly; only if the check needs interaction does it move into the shared `BoxloreModalBottomSheet` (a sheet on phones, a centered dialog on expanded and folded windows) with Echo and **Skip**. If it fails, times out or is skipped, onboarding carries on with its non-AI path. A remote switch decides whether it runs. Copy lives in `res/values/session_check.xml`. - Authentication & Deep Links: `MainActivity` handles incoming App Link intents for `https://boxlore.in/auth` (and `https://aswin.cx/boxlore/auth` from links sent by older builds) to complete passwordless email link sign-in via `authRepository.signInWithEmailLink`. It prevents one-time link replay across activity recreation by nullifying intent data and tracking consumed links, and prompts for the user's email via `CrossDeviceEmailDialog` if a magic link is opened cross-device without local pending auth state. Sign-in goes through `AppContainer.emailLinkSignIn` instead of toasts: each new link raises an Account page request, and `BoxLoreAppRoot` opens Settings on Account & sync (`ACCOUNT_PAGE_ROUTE`, unless `isAccountPage` says it is already showing; before onboarding finishes Settings shows the onboarding version), where the page shows Signing you in, then the first sync, or why the link failed. `accountPageRequestAction` decides each request (ignore one already handled, including across recreation, since the handled value is saved with `rememberSaveable`; only mark it handled when Account & sync is already showing; otherwise open it), and `AccountPageRouteTest` covers it and the route check. `BoxLoreApplication` configures `NetworkModule.authenticator` with `FirebaseAuthAuthenticator` at startup to automatically refresh STS tokens on HTTP 401. - Links: `MainActivity` provides `SafeUriHandler` (`:core:designsystem`) as `LocalUriHandler`, so every `openUri` in the app (privacy, account deletion, feedback, briefing sources, credits) shows a note instead of crashing when no app can open the link. The updates panel's notes link, the Play listing and the import card's notification settings start through `startFirstAvailable` / `openLink`, fallbacks included. - Install attribution: `AppContainer` wires `InstallReferrerManager.onInstallReferrerResolved` → analytics person properties (`install_channel`). Catalog stays free of `:core:analytics`. @@ -71,8 +71,8 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - `DownloadServiceLauncherHolder` is installed with `MediaDownloadService::class.java` so `:core:downloads` can launch the foreground download service without depending on `:core:playback`. - `LegacyWorkerFactory` maps legacy worker class names to current worker implementations for WorkManager continuity. - `MainActivity` hosts theme, edge-to-edge setup, app update hooks, library import state (full-screen `OpmlImportDialog` overlay so onboarding welcome never shows through), the selected floating or classic navigation presentation, and the player overlay. Its start/stop lifecycle informs the application-scoped playback repository whether UI position polling is needed; background audio, persistence, notifications, Cast, and MediaSession behavior remain service-owned. -- **Feed fix off switches** (`sync/FeedFixSwitches`): PostHog kill switches for the feed and episode fixes, passed into core modules as lambdas. A fix runs unless its flag is on; no answer (PostHog not ready, flag missing or inactive, an error) keeps it on. `feed-fix-match-baseline-off` sends the feed-only extras' comparison list back from the 1,000-item `episodes/match` list to the 100-item page (`PodcastRepository.useEpisodeMatchBaseline`). `feed-fix-address-recheck-off`, `feed-fix-sync-fallback-off` and `feed-fix-moved-feed-off` turn off the subscription sync's dead-address re-check, its `/sync` fallback for failed feeds, and following moved feeds (`FeedFixToggles`). `FeedFixSwitchesTest` covers the default. The same fail-open switches pause the v2 build's jobs: `upgrade-repair-off` (the one-time repair of saved feed addresses after the update), `address-check-off` (the daily address check of followed shows), both without marking the work done, and `v2-discovery-off` (discovery back on the v1 routes). These three are read through `sync/SavedKillSwitch`, because the repair can start on a headless launch before PostHog is set up: once PostHog is ready its answer decides and is saved; while it is still starting, the last saved answer is used, and the job runs only when none was saved. -- **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` PostHog flag picks where show pages read episodes (on: the show's own feed; off: the server's list). Unlike the off switches it fails closed: with no boolean answer from PostHog it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs. `$feature_flag_called` is recorded once per process, only when the value came from a live PostHog answer, and the process uses the value that exposure read returned. `sync/RolloutFlagMemory` saves `feed_first` and the three switches above for the next process whenever PostHog reports loaded flags (`PostHogAndroidConfig.onFeatureFlags`: the startup preload, `identify` and `reset`; explicit `reloadFeatureFlags` calls don't reach it, so a mid-session change is saved at the next of those loads). To roll back, keep `feed_first` active with a 0% release condition so phones get and save `false`; never disable or delete it before the flag's removal, because a disabled flag is missing from PostHog's answer and a phone that saved `true` would stay on. `AppContainer.rolloutSwitches` hands `feed_first` and the three switches above to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`. Discovery reads `v2Discovery` (`PodcastRepository` and `DiscoveryRouting`, below); the daily address check and broken-feed reports read `addressCheck` (`address-check-off`, through `SubscriptionForegroundSync`, below); show pages read `feed_first` (`PodcastRepository.isFeedFirstShow`, below); the feed address repair reads `upgradeRepair`. +- **Feed fix off switches** (`sync/FeedFixSwitches`): remote switches that turn off individual feed and episode fixes, passed into core modules as lambdas (`FeedFixToggles`, `PodcastRepository.useEpisodeMatchBaseline`). They fail open: a fix runs unless its switch is on, and no answer keeps it on. `FeedFixSwitchesTest` covers the default. Switches of the same kind pause the v2 build's jobs (the one-time feed address repair and the daily address check, both without marking the work done, and v2 discovery). Those are read through `sync/SavedKillSwitch`, because the repair can start on a headless launch before remote config is ready: the live answer decides and is saved; until then the last saved answer is used, and the job runs only when none was saved. +- **Feed-first rollout** (`sync/FeedFirstSwitch`): the `feed_first` flag picks where show pages read episodes (on: the show's own feed; off: the server's list). It fails closed: with no live answer it uses the last saved answer, else off. The first read fixes the value for the process, so a page's source never switches while the app runs, and the exposure is recorded once per process, from a live answer only. `sync/RolloutFlagMemory` saves `feed_first` and the switches above whenever flags load. `AppContainer.rolloutSwitches` hands them to `:core:catalog` as `RolloutSwitches` on `PodcastRepository`: discovery reads `v2Discovery`, the daily address check and broken-feed reports read `addressCheck`, show pages read `feed_first` (`PodcastRepository.isFeedFirstShow`) and the feed address repair reads `upgradeRepair`. - **Analytics switch and tags** (`BoxLoreApplication`, `analytics/PostHogWiring`): - `AnalyticsHelper.initPolicy` runs before PostHog setup with the app version, so a switch saved by another version is ignored. - `PostHogWiring.onFlagsLoaded` gives PostHog one flags-loaded callback that runs the rollout memory, then `AnalyticsHelper.refreshAnalyticsPolicy` (the `analytics-policy-v1` event-level switch). A step that throws doesn't stop the next one; the first failure is rethrown after both ran. @@ -82,12 +82,12 @@ The application module owns the Android app shell: `BoxLoreApplication`, `MainAc - After setup, `ListenInterruptions.reportLeftover` (`:core:playback`) sends `playback_interrupted` for a listen the last run left unfinished because Android ended the app. - App-owned surfaces send page views through `TrackScreenView`: `connection_check`, `cross_device_email`, `in_app_announcement` (trail only), `lore_queue_conflict`, `tablet_alpha_notice`, `app_updates`, and the import steps `library_import_pick`/`_progress`/`_ask`/`_failed`. - See `:core:analytics`. -- **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same `v2-discovery-off` switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)). +- **v2 discovery wiring**: `AppContainer.discoveryEpisodeIds` (`RoomDiscoveryEpisodeIds` over the main database, opened on first use) goes to `PodcastRepository` with `rolloutSwitches`, and the container's `init` installs the same v2 discovery switch and translator in `DiscoveryRouting`, so the briefing link builders in features and playback and `ChapterRepository` follow the repository: `/v2` routes with episode IDs shown under this phone's own IDs, or the v1 routes while the switch is set ([`core/catalog/README.md`](../core/catalog/README.md)). - **Feed reporting** (`sync/AnalyticsFeedTelemetry`): `AppContainer.feedTelemetry` is the `FeedTelemetryPort` handed to catalog code, so feed reads and address swaps reach `feed_fetch_result` and `feed_url_corrected` without catalog depending on analytics. A reporting failure never reaches the caller (`AnalyticsFeedTelemetryTest`). `SubscriptionRepository` gets it through `FeedAddressWiring`, so every saved-address correction of a followed show sends one `feed_url_corrected`. - **Recipe IDs on analytics (P4 PR 18).** `BoxLoreApplication` installs `AppContainer.episodeRecipeIdLookup` (`AnalyticsHelper.installEpisodeRecipeIdLookup`), which reads `EpisodeIdentityDao.knownRecipeIds` on the sync scope; `AnalyticsFeedTelemetry` forwards `own_ids_upgrade_completed`, and `AppContainer.upgradeAddressRepair` keeps its tally in `boxlore_upgrade_repair_report`. -- **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, `feedTelemetry`, and pending registrations in SharedPreferences `boxlore_notification_reregistrations`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind `feed-fix-moved-feed-off`); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind `address-check-off`. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs. It records each outcome (`settle`): a show it gives up on stays pending and the next foreground pass queues it again; `rss:` shows keep their own journal worker. +- **Saved feed addresses** (P4 PR 14): `AppContainer` wires the one address writer. `subscriptionRepository` gets `FeedAddressWiring` (re-registration through `fcm/PodcastNotificationRegistrationWorker`, `feedTelemetry`, and pending registrations in SharedPreferences `boxlore_notification_reregistrations`); `subscribedEpisodeCatalog` follows moved feeds on every refresh path (`FeedMoveFollower`, behind its off switch); `autoDownloadCoordinator` saves recovered addresses through `ensureHttpsFeedUrl`; cloud pulls save addresses through `subscriptionRepository.feedAddresses`; `subscriptionForegroundSync` gets `FeedAddressUpkeep` for the daily address check and broken-feed reports (report times in SharedPreferences `boxlore_feed_reports`, never sent while `connectivityStatus` says offline), behind the address-check switch. `PodcastNotificationRegistrationWorker` registers a Podcast Index show's notifications again after its address changed and waits for Firebase to acknowledge the node and topic: unique work `podcast-notification-registration-`, REPLACE (it reads the row when it runs), network-gated, exponential backoff from 30 s, at most 8 runs. It records each outcome (`settle`): a show it gives up on stays pending and the next foreground pass queues it again; `rss:` shows keep their own journal worker. - **Feed-first show pages** (P4 PR 16): `AppContainer.podcastRepository` gets `FeedFirstWiring`: the stored copies (`localEpisodeCatalogRepository.showPages`), `connectivityStatus` for the offline rung, one broken-feed report limiter (`feedReportLimiter` over the `boxlore_feed_reports` stamps, the same instance `FeedAddressUpkeep.reportLimiter` gives the daily address check, so both claim inside one daily limit), `feedTelemetry` for `feed_fetch_result`, and `isFollowed` (`podcasts.isSubscribed`), so the show page leaves followed shows' reports to their own refresh. The page reads the feed only while `feed_first` is on (`RolloutSwitches.feedFirst`, fixed per process). -- **Feed address repair after the update** (P4 PR 15, `sync/UpgradeRepairWorker`): a thin WorkManager shell around `:core:catalog` `UpgradeAddressRepair` (`AppContainer.upgradeAddressRepair`, reached through `SharedAppDependenciesHolder`). Every launch, headless ones included, `BoxLoreApplication` calls `UpgradeRepairWorker.scheduleIfNeeded`, which enqueues unique work `feed-address-upgrade-repair` (KEEP, network-gated, exponential backoff from 30 s) only while the stored state says work is left: followed shows with repair steps left, or step 4 not yet recorded in `noBackupFilesDir/upgrade_repair_notifications`. A fresh install, or one with only `rss:` follows, settles without network and enqueues nothing. A restore brings Room but not WorkManager's queue or the step 4 record, so the next launch enqueues the repair again and every followed show's notifications are registered again. A run maps `DONE` and `PAUSED` (`upgrade-repair-off`) to success and `RETRY` to a retry; a launch's chain stops after 10 runs and the next launch enqueues it again. `AppContainer` wires `/v2/shows/feeds` (`AddressCheckAnswer.fetchWith`), the one address writer, the catalog re-read, `PodcastNotificationRegistrationWorker.request` (returns once WorkManager has saved the request, so step 4 never records one lost to process death), `userSyncCoordinator.requestIdentityCatchUp` (once every followed show was read again, the next sync runs the identity catch-up again), `connectivityStatus` and `feedTelemetry`. +- **Feed address repair after the update** (P4 PR 15, `sync/UpgradeRepairWorker`): a thin WorkManager shell around `:core:catalog` `UpgradeAddressRepair` (`AppContainer.upgradeAddressRepair`, reached through `SharedAppDependenciesHolder`). Every launch, headless ones included, `BoxLoreApplication` calls `UpgradeRepairWorker.scheduleIfNeeded`, which enqueues unique work `feed-address-upgrade-repair` (KEEP, network-gated, exponential backoff from 30 s) only while the stored state says work is left: followed shows with repair steps left, or step 4 not yet recorded in `noBackupFilesDir/upgrade_repair_notifications`. A fresh install, or one with only `rss:` follows, settles without network and enqueues nothing. A restore brings Room but not WorkManager's queue or the step 4 record, so the next launch enqueues the repair again and every followed show's notifications are registered again. A run maps `DONE` and `PAUSED` (its off switch) to success and `RETRY` to a retry; a launch's chain stops after 10 runs and the next launch enqueues it again. `AppContainer` wires `/v2/shows/feeds` (`AddressCheckAnswer.fetchWith`), the one address writer, the catalog re-read, `PodcastNotificationRegistrationWorker.request` (returns once WorkManager has saved the request, so step 4 never records one lost to process death), `userSyncCoordinator.requestIdentityCatchUp` (once every followed show was read again, the next sync runs the identity catch-up again), `connectivityStatus` and `feedTelemetry`. - The NPS survey is Home's own sheet (`feature/home`), reporting to a PostHog `api` survey; PostHog draws no surveys (`surveys = false`), so the SDK can update freely. PostHog push is off too (`capturePushNotificationSubscriptions`, `capturePushNotificationOpened`, on by default in SDK 3.7x), so the FCM token and notification opens never reach PostHog. `BoxLoreApplication` makes `EngagementPromptCoordinator` with the install-only `NpsSurveyStore` (`datastore/boxlore_nps_survey.preferences_pb`, left out of legacy backup, cloud backup and device transfer: `BackgroundCheckBackupRulesTest`) and counts each start with `onAppLaunched`. It sets `PlaybackFinishes.onRecorded` to `EngagementPromptCoordinator.recordFinish`, so each finish is also kept on disk and the survey's moment outlives a restart. - JVM `ArtworkPredictiveBackBackgroundTest` checks actual native rendering of the Back wrapper with changing artwork backgrounds beneath the saved app theme. - JVM `ArtworkNavigationHostThemeTest` uses a real NavHostController and the production slide transitions to verify source capture before first loader composition, stable saved-theme fallback, Back loader inheritance and separation of Home/regular-page colors from transient loader accents. @@ -237,14 +237,14 @@ Episode push artwork keeps its six-second total deadline and uses 700ms per rout - Full-player style persistence and presentation regressions live in `:core:prefs`, `:feature:settings` and `:feature:player`; app assembly validates the shared wiring. - Native announcements have no WebView, JavaScript bridge, render handshake or alternate fallback layout. Closing, scrolling and actions work immediately; image failure removes only the image. Compact surfaces use surfaceContainer and a subtle backdrop. Announcements and the updater set system-bar icon contrast on their own Dialog window without changing the underlying Activity; `NativeDialogSystemBarsTest` covers light/dark and dimmed surfaces. -- `AnnouncementPolicyTest` and `AnnouncementPreferencesTest` cover optional fields, legacy category fallback, channel/version filtering, isolated audiences and persisted styles/actions. `UpdateSessionTest` and `AppUpdatesPanelTest` cover saved files, process recreation and the explicit permission-return retry. Sender tests compare browser/workflow approval digests and reject changed drafts before delivery. `AnnouncementRenderTest` covers release/custom actions, scoped accent roles and Markdown compatibility. Shipping artifact checks still reject isolated receivers/markers. +- `AnnouncementPolicyTest` and `AnnouncementPreferencesTest` cover optional fields, legacy category fallback, channel/version filtering, isolated audiences and persisted styles/actions. `UpdateSessionTest` and `AppUpdatesPanelTest` cover saved files, process recreation and the explicit permission-return retry. `AnnouncementRenderTest` covers release/custom actions, scoped accent roles and Markdown compatibility. Shipping artifact checks still reject isolated receivers/markers. - Push actions share cold/warm launch routing: registered share/episode/show links use Navigation, unmatched web links open externally, and update targets open the native updater. Known utility app-scheme links resolve to allowlisted routes, including Feedback and Lore. Legacy release APK actions map to the updater; general APK links remain ordinary links. Empty or non-allowlisted targets do not render action buttons. Unmatched verified App Links exclude boxlore from the external chooser to avoid a launch loop. `navigation/WebAppLinks` is the one list of verified hosts and paths: `boxlore.in` `/share`, `/auth` and `/__/auth/links` (emailed sign-in links), plus the `aswin.cx` `/boxlore/share`, `/boxcast/share` and `/boxlore/auth` paths that older builds shared. It also builds the podcast and episode share deep links; `WebAppLinksTest` opens links from every domain in a JVM NavHost, and `WebAppLinksManifestTest` keeps the manifest's `autoVerify` filters (one host per filter) in step with it. `PushActionRouterTest` exercises a real JVM NavHost, and `PushAnnouncementActionTest` checks the notification's actual PendingIntent destination without sending FCM. - `updates/` JVM regressions cover newer/current/incompatible manifests, six-hour checks, retry backoff, manual override, coalesced requests, offline cached offers, version refreshes, malformed/untrusted/oversized metadata, checksum failure, exact range resume and servers ignoring Range. Fake HTTP responses keep these checks away from production. `UpdateEntryPointsTest` covers the action appearing/disappearing while Settings/Feedback remain usable. `AppUpdatesTest` covers quiet checks, explicit transfer, dismissal, retries, changing offers and Play not using the APK installer. `ReleaseNotesMarkdownTest` verifies adjacent emphasis and delimiters inside links or code without swallowing later formatting. Additional fixtures cover cancelling a blocked socket, package/version/signing identity and the update screen's notes, error and installation states. - Unit tests live under `app/src/test`, including app container smoke coverage, sync coordinator lifecycle stability (`AppContainerSmokeTest`), `CloudSyncWorker` WorkManager execution, resolution, and periodic scheduling (`CloudSyncWorkerTest`), `CloudSyncTriggerCoordinator` auth transition handling, process start/stop debouncing, playback milestone detection, and database mutation coalescing (`CloudSyncTriggerCoordinatorTest`), worker factory mapping, FCM payload parsing (type + snake/camel ids, feedUrl/guid/enclosure), new-episode route/id, bounded slot bounds, negative-modulo non-negative floor, normalized intent `filterEquals` equivalence, and auto-download WorkManager enqueueing helpers (`NewEpisodeFcmLogicTest`), opted-in feed hydration before notify, library backup analytics error codes, import-dialog system-bar edge-to-edge (`ImportDialogSystemBarsTest`), push-target route allowlisting, bottom navigation visibility, onboarding origin derivation, and settings back navigation handling (`BottomNavPresentationTest`), cold-start destination precedence (`StartDestinationResolverTest`), launch-landing Back decisions (`LaunchSubscriptionsBackDecisionTest`), and episode navigation route building and argument decoding (`EpisodeNavRouteRegressionTest`). - Home Spotlight card/arrow navigation opens the podcast page with the Spotlight entry point, even when the show has a latest episode. Resume and Fresh Drop still open their specific episodes. `HomeHeroNavigationTest` verifies these destinations with a real JVM NavHost, including RSS/encoded show IDs, attribution, missing episodes and Back to Home. - Navigation and feature UI behavior are covered mainly in feature module tests and Maestro smoke flows. -- `session/ApiSessionAppTest` covers the install channel rule, App Check failure categories and the `api_session_status` / `app_check_status` payloads. `TurnstileStepUpControllerTest` (virtual time) covers an invisible pass, the 20 s invisible timeout (which also covers a backgrounded app with no host on screen), interaction extending the wait to 2 minutes, every failure reason, and stale pages being ignored. `TurnstileStepUpHostAdaptiveTest` (Robolectric) keeps one interactive check while the window moves phone → 599/600dp → 839/840dp → expanded → 479/480dp tall → portrait tablet → half-opened fold → flat → phone: the same page view moves between the sheet and the centered dialog, stays attached, window changes never count as a skip, and the page is destroyed once. In every presentation Echo stands left of its line, level with it, and the check sits inside its card. On a real 740×360dp landscape window the check card and Skip sit fully on screen and Skip still ends the run. It also checks the invisible page is hidden from accessibility and never shows the sheet, that Skip ends the run once on a phone, and that on an expanded dialog a tap inside does nothing while a tap outside skips once. These are JVM checks. The real Turnstile page has been passed once in a device WebView (debug build on a phone, 2026-10-31), followed by Echo's AI routes succeeding. +- `session/ApiSessionAppTest` covers the install channel rule, App Check failure categories and the `api_session_status` / `app_check_status` payloads. `TurnstileStepUpControllerTest` (virtual time) covers passes, timeouts, every failure reason and stale pages. `TurnstileStepUpHostAdaptiveTest` (Robolectric) keeps one interactive check across phone, tablet, fold and short-window changes: the page moves between the sheet and the dialog, stays attached, window changes never count as a skip, and Skip stays reachable. - `ImportFlowModelTest` covers the failed-backup regression (-1 shows), which failures promise an unchanged library, the screens and Echo poses (each shipped in the Echo manifest), progress counts and clamping, the checklist stages and Echo's size. `ImportDialogAdaptiveTest` keeps one live import while the window moves phone → 599/600dp → 839/840dp → split → 479/480dp tall → portrait tablet → half-opened fold → flat → phone: the Start fresh selection and list position, the running progress and a failure with its way back survive, Back dismisses only when allowed, each button calls its callback once, the options stay reachable at 2x text in RTL on a 320x568dp phone and in landscape, and the folded import stays on one side of the hinge. `ImportAccountHandoffTest` covers the fade to Account. - `AdaptivePlayerChromeControllerTest` additionally covers Lore-only forcing, prior-state restoration, history exclusion, episode/availability resets and player-interaction priority. - `AdaptivePlayerScrollLogicTest` covers travel thresholds, reversal, source/axis filtering, gesture boundaries, player-interaction suppression, and reset behavior for adaptive chrome. diff --git a/core/analytics/README.md b/core/analytics/README.md index 35c1d9ca2..090c98c49 100644 --- a/core/analytics/README.md +++ b/core/analytics/README.md @@ -18,7 +18,7 @@ Owns analytics event capture and non-fatal error reporting for Boxlore. The modu - `trackCustomThemeEditorOpened` emits once per editor visit. `trackCustomThemeSaved` is called after custom theme preference persistence succeeds. Both use glossary-backed events with only entry point or background style; no color values are captured. Facade and inventory tests cover both emissions. -- `trackApiSessionStatus(ApiSessionStatus)` emits `api_session_status` for API sessions (`ApiSessionAnalyticsTracks`); null properties are left out. One event per `/auth/session` exchange (`kind=exchange`: `trigger`, `success`, `duration_ms`, `attestation`, `limited`, `app_check_attempted`, `failure_reason`, `server_reason`), per first renewal of a token or renewal problem (`kind=renew`, `renew_outcome`) and per Turnstile step-up (`kind=step_up`, `step_up_outcome`). Properties are categories only, never tokens, ids or keys. `trackAppCheckStatus` takes an optional `reason` (a short failure category such as `play_integrity_-9`); App Check now runs only inside a session exchange, so `app_check_status` arrives once per attempt rather than once per launch. +- `trackApiSessionStatus(ApiSessionStatus)` emits `api_session_status` for API sessions (`ApiSessionAnalyticsTracks`); null properties are left out. One event per `/auth/session` exchange (`kind=exchange`: `trigger`, `success`, `duration_ms`, `attestation`, `limited`, `app_check_attempted`, `failure_reason`, `server_reason`), per first renewal of a token or renewal problem (`kind=renew`, `renew_outcome`) and per Turnstile step-up (`kind=step_up`, `step_up_outcome`). Properties are categories only, never tokens, ids or keys. `trackAppCheckStatus` takes an optional `reason` (a short failure category); App Check now runs only inside a session exchange, so `app_check_status` arrives once per attempt rather than once per launch. - `trackFeedFetchResult(FeedFetchReport)` emits `feed_fetch_result`: once per show-page read (`context=show_page`, `show_id`, `source`, `result`, `completeness` on successful reads, `http_status` only on `http_error` and `gone`) and once per background pass and source as a summary (`context=background_pass`, `result` `ok`/`mixed`/`failed`, `shows_count`, `ok_count`, `not_modified_count`, `failed_count`, and `namespace_mismatch_count` only when the server reported another episode namespace for shows read in the pass; a pass that read nothing sends nothing). `trackFeedUrlCorrected(FeedUrlCorrection)` emits `feed_url_corrected` once per saved-address swap (`show_id`, `reason`, `step`). Neither carries an address: a show ID that looks like one is left out, and `feed_url_corrected` is then dropped (`FeedAnalyticsTracks`, `FeedAnalyticsTracksTest`). Catalog code reaches them through `:core:domain`'s `FeedTelemetryPort`, which `:app` wires. - `recipe_id` beside `episode_id` (P4 PR 18, D11): `AnalyticsEmit` fills `recipe_id` on every event in `AnalyticsGlossary.EPISODE_ID_EVENTS` and `recipe_ids` on episode surfaces of `home_surface_impression` (`EpisodeRecipeIds`). The value is the episode ID itself when it is in the recipe range, else one already known (`rememberEpisodeRecipeIds`, or found earlier); unknown IDs are looked up in the background through the lookup `:app` installs (`installEpisodeRecipeIdLookup`, trusted keyed aliases only), so an event is never held back and the next event of that episode carries it. A miss isn't asked again for 10 minutes; caches are bounded. `trackOwnIdsUpgradeCompleted(UpgradeRepairReport)` emits `own_ids_upgrade_completed` once per upgraded install: `outcome` (`done` / `gave_up`), `shows_count`, `shows_gave_up_count`, `addresses_swapped_count`, `notifications_reregistered_count`, `runs_count`, `elapsed_hours` (tenths); counts only, no show IDs or addresses. Tests: `EpisodeRecipeIdsTest`, `GlossaryAllEventsEmissionTest`. @@ -96,7 +96,6 @@ src/main/java/cx/aswin/boxlore/core/analytics/ - Playback events take only `EntryPointContext.ALLOWED_KEYS` from a play's context, and none replaces a field the event set; media item extras (artwork links, Android Auto keys, the stream address) never reach analytics. - Echo's chat sends what the listener typed capped at 500 characters, and never the reply text. - Deep links: custom `deep_link_opened` (`captureDeepLinks = false`). -- Weekly Pulse KPIs stay on **Application Opened** / **Application Installed**. - Coverage inventory: [`docs/analytics/glossary_emission_coverage.csv`](../../docs/analytics/glossary_emission_coverage.csv) (`emission` | `sdk_backed` | `person_props_only`). - Events whose callers are gone leave the glossary with their façades: `library_history_tracking_notice` (the History notice was removed) and `auto_chapters_lifecycle` / `auto_transcript_lifecycle` (the on-device chapters and transcript calls were removed). The unused launch enrichment façade (`subscription_count_bucket`) went with them. `RecordingAnalyticsTest` checks the retired names stay out of the allowlist. diff --git a/core/catalog/README.md b/core/catalog/README.md index cbf849421..498b6ffd0 100644 --- a/core/catalog/README.md +++ b/core/catalog/README.md @@ -10,35 +10,35 @@ Owns catalog orchestration: Podcast Index access through `PodcastRepository`, su Failed consent writes keep activation off, and local notification flags prevent a failed revoke from implicitly re-enabling after restart. RSS sync isolates each feed and runs alongside PI sync; the source adapter explicitly implements every catalog operation. -- `SubscribedEpisodeCatalog` routes the shared local catalog contract by podcast ID to the PI or true RSS adapter. Shared negative IDs from a source switch resolve to the active subscription rather than a retired PI compatibility row. Foreground refresh, Home tips, Smart Downloads freshness, auto-download discovery and push hydration support RSS-only shows without PI lookups or required promotion. With a `FeedMoveFollower` (the app passes one behind `feed-fix-moved-feed-off`), every refresh of a Podcast Index show through it (the show page, pushes, auto-download, restores, the ingest after a follow) checks for a feed that moved for good (permanent redirects or `itunes:new-feed-url`, same title; the stored title when the caller sent none) and saves the move through the address writer below. Requests that ask for move detection themselves (the background pass) are left to their caller. +- `SubscribedEpisodeCatalog` routes the shared local catalog contract by podcast ID to the PI or true RSS adapter. Shared negative IDs from a source switch resolve to the active subscription rather than a retired PI compatibility row. Foreground refresh, Home tips, Smart Downloads freshness, auto-download discovery and push hydration support RSS-only shows without PI lookups or required promotion. With a `FeedMoveFollower` (the app passes one behind a remote switch), every refresh of a Podcast Index show through it (the show page, pushes, auto-download, restores, the ingest after a follow) checks for a feed that moved for good (permanent redirects or `itunes:new-feed-url`, same title; the stored title when the caller sent none) and saves the move through the address writer below. Requests that ask for move detection themselves (the background pass) are left to their caller. - **Saved feed addresses (one writer).** `FeedAddressUpdater` (`SubscriptionRepository.feedAddresses`, `updateFeedAddress(FeedAddressChange)`, `ensureHttpsFeedUrl`, `adoptMovedFeed`) is the only writer of a show's saved address. In one Room transaction (`PodcastDao.replaceFeedAddress`) it writes the HTTPS publisher address and the transport address, keeps the old address in `previousFeedUrl` for undo, records where the address came from (`feedUrlSource`: a `FeedUrlSources` value or the registry's `feedSource`) and when the registry confirmed it (`feedUrlCheckedAt`), clears every stored ETag and Last-Modified of the show (the row, its catalog feed and its feed-only extras), and marks a followed show for the next sync push (not for an address that came from the cloud, and never an unfollowed row, whose push could end the follow on another device). A write the registry didn't confirm keeps the stored daily-check stamp. It is a compare-and-set on the address it read, and `FeedAddressChange.onlyReplacing` narrows it to the address a caller read: a move is adopted only from the saved address that was read (a push or a restore may read another one, `FeedMoveFollower`), and a recovered address replaces only the address that failed. It records the show as pending (`PendingNotificationRegistrations`, the app's `SharedPreferencesPendingNotificationRegistrations`) before the write, and writes nothing (`NOT_SAVED`) when that record couldn't be saved, which no caller treats as done: a cloud pull fails before its pull time is saved (`FeedAddressNotSavedException`), the daily check leaves the show due for the next pass, and a restore reports the show, asks the app to register the show's notifications again when they are on (`FeedAddressWiring.requestNotificationRegistration`; any failure of that request is caught), and clears the mark only once Firebase acknowledged (`registrationSettled`), and only the request the job read before registering (`pendingMark`; each request gets a new mark), so an address change made while a job ran stays pending for the job that replaced it; each foreground pass asks again for every pending show (`resumePendingRegistrations`), so one lost with the process or given up on by the background job is never dropped. It reports a correction of a followed show's address as `feed_url_corrected` (`FeedAddressWiring.telemetry`). `rss:` shows and addresses that aren't HTTPS are refused. A cloud pull never puts back the address a confirmed correction (`FeedUrlSources.isConfirmedCorrection`: the registry, a feed move, a broken-feed report or the `/podcast` re-check) replaced: it returns `KEPT_CORRECTION` and marks the follow for a push, so the account gets the corrected address back. Merges of pulled rows write through `PodcastDao.upsertKeepingFeedAddress`, so an older copy never writes its address over one saved meanwhile. A follow keeps a saved HTTPS address (`FeedUrlSources.followedFeedUrl`), and a missing address from a show's details is filled through the writer. Every writer goes through it: missing-address recovery, the dead-address re-check, moved feeds, broken-feed reports, the daily check, auto-download, backup restores and cloud pulls (`SubscriptionSyncResolver(feedAddresses = …)` saves a pulled address for a Podcast Index show this way and leaves the row clean; without it, as in older tests, the row is written as before). - **Renumbered shows (one follow).** `RenumberedShowFollows` (`SubscriptionRepository.renumberedFollows`: `adoptAnswer`, `adoptKnown`, `currentId`, `endedFormerFollows`, `collapseFollowsQuietly`) keeps one follow when Podcast Index renumbers a show ([docs/EPISODE_IDS.md](../../docs/EPISODE_IDS.md#renumbered-shows)). `PodcastRepository(onShowRenumbered = …)` reports every details answer for a canonical numeric show ID that names another one, before the answer is returned, so every existing details call detects it (the show page, a cloud pull's enrichment, address recovery). The answer is recorded (`RenumberingWiring.shows`, `boxlore_renumbered_shows` in the app) and a follow of the old show moves to the new one in one transaction (`PodcastDao.moveRenumberedFollow`, rules in `RenumberedFollowMerge`): the new show keeps its own row and namespace, or is created without one, with notifications and auto-download on when either was, and its own genre, icon, sort and trims, else the old show's; it keeps its own follow time or takes the time of the move, never the old show's earlier one, which any later unfollow of the new show on another device would beat in sync. Both rows are read, merged and written inside the DAO's transaction, so a pull or toggle never lands between the read and the write. The old show is unfollowed like any unfollow. Both rows are dirty, so the next push carries the follow and the unfollow, and a pull of the old show's older follow loses to the newer unfollow. Notifications for the new show are recorded pending (`FeedAddressUpdater.markRegistrationPending`) before the row turns them on and then registered through the acknowledged job; the old show's are turned off. Folder memberships, Home pins and the manual order move; the old show's catalog starts its unfollowed countdown; the old show's listener rows are linked to the new show's episodes (`RenumberingWiring.linkEpisodes`, `:core:rss`). A new show followed through its own RSS feed keeps that follow and only the old one ends, its folder, pin and order places moving to the RSS show and only the old show's catalog countdown starting; when the old follow has notifications and the RSS show's doesn't (turning them on needs the listener's consent), both follows stay. `rss:` shows are never touched. Each foreground pass first moves follows of shows recorded as renumbered, then asks details only of followed shows that share a feed address or podcast GUID with another follow, at most six per pass (`RenumberedShowFollows.collapseFollows`, run quietly by the pass, order from `RenumberedFollowMerge.lookupOrder`): a show is asked once per process after an answer, and a failed lookup is asked again on a later pass after the shows not yet asked; a show is never matched by title. `toggleSubscription(podcast, shownFollowed)` decides the direction from what the caller showed (the show page passes its own state, which counts a follow under the old number; other callers show the show's own row): asked to follow, a follow under the old number moves here with its settings; asked to unfollow, every follow of the show ends (`endedFormerFollows`), and unfollowing a followed `rss:` feed also ends a follow kept under a number its show replaced (`endFormerFollowsOf`). A follow moves only once the renumbering is saved; when saving fails it stays under the old number. A toggle never adds a second follow and a Subscribe tap never unfollows. `isFollowedUnderFormerId` tells the show page about a follow still under the old number. The daily address check also passes on merges the server confirmed with Podcast Index (`V2ShowAddress.mergedInto`): `FeedAddressDailyCheck` hands each to `adoptKnown(oldId, confirmedNewId)`, which records the renumbering and moves the follow without a details lookup, and the merged show's own address is never swapped. - `PodcastNotificationRegistrar` (`SubscriptionRepository.registerPodcastNotifications`) registers a Podcast Index show's notifications again and waits for Firebase to acknowledge both the `tracked_podcasts/` node and the `new_ep_` topic, the same node and topic as turning notifications on. The node is one per show for every follower and its write replaces it whole, so it is written only with an address (once the show has a latest episode); otherwise only the topic is subscribed. `NotificationRegistrationOutcome.RETRY` is retried by the app's capped background job; rules that refuse the write give `DROPPED`. - **Daily address check.** `FeedAddressDailyCheck` runs at the start of each foreground pass (behind `RolloutSwitches.addressCheck`). It asks `POST /v2/shows/feeds` for every followed Podcast Index show with a saved address not checked in the last day, in chunks of at most 500, and swaps an address only for a confirmed move: the registry flags the show for address repair, the address is the registry's own (`registry:` source), HTTPS (an http-only address is never adopted, since background reads need HTTPS), different from the saved one, and not the address the saved one replaced (whatever replaced it: a feed move, dead-address recovery or a cloud pull). `fetchUrl: null` keeps the saved address. The new address is read first (`DirectFeedRecovery.addressRead`, a catalog refresh from it) and saved only when that read works, so an address the phone can't read never replaces one it can. Each show the registry answered for is stamped (`podcasts.feedUrlCheckedAt`): a swap writes the stamp with the address, and any other show is stamped after its swap had its chance. A failed call or `registryAvailable: false` stamps and swaps nothing. Follows with a change made on this phone that the account hasn't received yet (dirty and synced before) wait for the push; follows never synced to an account are checked. A different `epNs` is only logged (D12). -- **Broken-feed reports.** `FeedReporter` sends `POST /v2/feed-report` (`{id, status}`, never an address) for every failed background read of a Podcast Index show (`RefreshOutcome.Failure.reportStatus`), behind the same switch, never while offline, at most once per show a day and 20 a day in all, the API's own limit (`FeedReportLimiter`, one per app, shared with the show page, which stops 5 short so followed shows always have room; times kept in `FeedReportStamps`, the app's `SharedPreferencesFeedReportStamps`). Only a report the API answered counts: a failed send or a 429 uses up nothing. Its answer is an address to try only when it differs, is HTTPS and the saved address looks dead. +- **Broken-feed reports.** `FeedReporter` sends `POST /v2/feed-report` (`{id, status}`, never an address) for every failed background read of a Podcast Index show (`RefreshOutcome.Failure.reportStatus`), behind the same switch, never while offline, at most once per show a day and 20 a day in all (`FeedReportLimiter`, one per app, shared with the show page, which stops 5 short so followed shows always have room; times kept in `FeedReportStamps`, the app's `SharedPreferencesFeedReportStamps`). Only a report the API answered counts: a failed send or a 429 uses up nothing. Its answer is an address to try only when it differs, is HTTPS and the saved address looks dead. - **Feed address repair after the update.** `UpgradeAddressRepair` (`SharedAppDependencies.upgradeAddressRepair`, run by the app's `UpgradeRepairWorker`) repairs the saved address of every Podcast Index show followed when the phone upgraded; the v39 upgrade seeds one pending `upgrade_repair_shows` row each, a fresh install none. Per show: (1) `POST /v2/shows/feeds` in chunks of at most 500 (`UpgradeAddressCheckStep`, production request `AddressCheckAnswer.fetchWith`); the answer stamps the daily check and records `checkedFetchUrl` only for a confirmed move (`FeedAddressDailyCheck.confirmedMove`), plus the transport address, source and the server's `epNs`. A chunk the server couldn't answer (503, 429, another 5xx, no connection, or `registryAvailable: false`) stays at step 0 without counting a try while the other chunks go on; one it refused (another 4xx) or whose answer couldn't be read counts a try for each show. A show refused 5 times, or unanswered for 7 days since its first failed check (`MAX_UNANSWERED_MS`), goes on without an answer: no swap, and the daily check looks after its address. (2) The swap through the one address writer (`feed_url_corrected`, step `upgrade_repair`; skipped for follows changed on the phone and not pushed yet, which the daily check handles once pushed). An answer more than a day old is asked again at the start of a run (a repair paused or stopped for a while never applies an old answer), and one the daily check has since replaced with a newer answer is not applied. (3) A catalog re-read of the saved address (`rereadWith`; the server's namespace is passed only for comparison, so issued IDs never change, and moves found are followed by the catalog). It never starts the foreground auto-download pass (`runPostPersistCallback = false`): the regular background pass applies the listener's background download rules. (4) Notification re-registration for every followed show with notifications on whose own steps are over, recorded in `UpgradeNotificationMarker` (`FileUpgradeNotificationMarker`, `noBackupFilesDir`) only once the app reports the request saved, so a restored phone registers again even when Room says steps 1–3 are done. Once no show has steps 1–3 left, `UpgradeRepairPorts.onAddressStepsFinished` runs once per install (recorded in the same file; never on a fresh install, while shows wait, or while paused), which the app wires to `UserSyncCoordinator.requestIdentityCatchUp`, so history and queue rows whose episodes the reads settled reach the cloud with their identity. Each step is written as it finishes, so process death resumes at the next one; the row is read again before each step, and a show no longer followed ends `done`. A failed re-read while online counts against a cap of 5 tries (`gave_up`); failures while offline never count and stop the run. Shows followed after the upgrade get no row. `needsWork()` settles a fresh install, or one with only `rss:` follows, without network. Behind `RolloutSwitches.upgradeRepair`: off returns `PAUSED` without marking anything done. One `feed_fetch_result` background summary (`source=feed`) per run that read feeds, with `namespaceMismatchCount` for reads of shows whose server namespace differs from the phone's (decision D12). - **Upgrade report (P4 PR 18).** `UpgradeAddressRepair` counts each run of an upgraded install in an `UpgradeRepairTally` (`SharedPreferencesUpgradeRepairTally`, file `boxlore_upgrade_repair_report`, backed up with Room): runs, first and last run times, addresses swapped, notifications registered again. The run that finishes the repair records "sent" first (`commit()`), then reports `own_ids_upgrade_completed` once through `FeedTelemetryPort.upgradeRepairCompleted` with `UpgradeRepairDao.showCounts`. A fresh install, a repair finished before this build, or a failed "sent" write reports nothing (`UpgradeRepairReportTest`). - RSS notification activation requires explicit public-feed disclosure acceptance through `setNotificationsEnabled(..., acceptRssDisclosure = true, disclosedFeedUrl = savedUrl)`. `DeviceRssNotificationConsent` stores only URL fingerprints in `noBackupFilesDir`; changed URLs, restore and remote cloud settings cannot supply acceptance. Canonical `rss:` IDs require disclosure even with stale source metadata. Refused or revoked activation also removes this device's old registration; a library cleared during acceptance cannot publish a deleted show. Notification settings remain independent of auto-download. The shared checker receives only explicitly accepted public feeds. `TrackedPodcastRtdbLogic.topic` maps `rss:` to FCM-safe `rss_` while retaining canonical IDs in payloads. JSON/cloud RSS restore retains the original saved show ID across redirects. -- RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Live RTDB rules refuse whole-node deletes, so cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. +- RSS topics and per-device registration keys include a SHA-256 hash of the exact trimmed accepted URL. `DeviceRssNotificationRegistrations` keeps an atomic publication/cleanup journal in `noBackupFilesDir`, containing fingerprints and keys rather than URLs. Activation fails closed if the journal cannot be saved. `RssNotificationReconciler` awaits both RTDB and FCM acknowledgement, retains active entries and failed cleanup obligations, and retires old URL scopes or deleted/disabled shows. Cleanup keeps the node and removes only its `feedUrl` child, which the checker needs to poll an RSS scope. The rules refuse that delete only when no node exists for the key (for example a legacy key this device never published), so a refusal still unsubscribes the FCM topic and clears the journal entry; any other failure keeps both. Migration scans disabled and unsubscribed canonical RSS rows too, removes legacy device keys/topics, and rejects restored notification flags without local consent. `canPresentEpisodeNotification` verifies the current saved URL, subscription, enabled flag and device-local acceptance. -- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to proxy), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. Followed shows' catalog refreshes never read a Podcast Index list: new episodes take recipe IDs in `:core:rss`. `loadPiEpisodesForSupplement` is the Podcast Index list feed-only extras (`EpisodeSupplementPort`) are compared with: up to 1,000 newest episodes from `episodes/match` (match fields only), falling back to the oldest page (at most 100) when that route fails, returns nothing, or its `useEpisodeMatchBaseline` off switch is set. It is strict, so a failure of both stops the extras refresh instead of comparing the feed with an empty list. Its callers are the show page's "Missing episodes?" refresh, the legacy subscription sync and push paths without a local catalog, and the restore of extras opt-ins; they stay until show pages read feeds for every listener. Full pages keep the 100 cap. `getEpisode` resolves an ID in `EpisodeLinkResolver`'s order without reading a feed: a stored row (local catalog, then the feed-only rows for a negative ID), then the phone's row of the episode the ID is a trusted alias of, then the network. It sends a revcipe-ry canonical positivge ID (`[2^52, 2^53 − 1]`) that no local row or alias holds to `/v2/episode` (with the show when a caller passes `getEpisode(episodeId, showId)` and the show is below 2^52): a recipe-range ID (`[2^52, 2^53 − 1]`)d never reachesto the v1 Podcast Index lookup, and an old Podcast Index ID falls back; to v1 `/episode` only when the v2 route can't answer; other legacy IDs keep v1. The episode keeps the ID asked for and carries the recipe ID and catalog key. A `needsFeed` answer, a 400/404/410 or an error is not found. Podcast Index IDs keep v1 `/episode`. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). `PodcastRepository` also owns the `/v2` client (`apiV2`, a `BoxLoreApiV2` built on first use): same base URL and the same cached OkHttp client as `api`, so the process keeps one HTTP cache. Tests can pass `boxLoreApiV2` the same way they pass `boxLoreApi`. `getEpisode` and the discovery routes below use its only caller so far. In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). +- `PodcastRepository` coordinates Podcast Index calls, recommendation endpoints, curated vibes, home bootstrap, similar episodes (with content languages), content catalog/v3, user feedback submission (`submitFeedback` transmitting categorized feedback, repro steps, optional email, and diagnostics to the API), and RSS delegation. Canonical `PodcastEntity.toPodcast()` mapper standardizes entity conversion across data and feature modules, with module-boundary isolation between `:core:catalog` and `:core:rss`. Subscribed PI shows whose local catalog is **ready** (`LocalEpisodeCatalogPort.isReady`) serve list/search/`getEpisode` from Room only. Unsubscribe stamps a 14-day TTL; that catalog stays on disk but is **not** ready, so unsubscribed reads keep today’s PI pages plus cached extras merge. True `rss:` rows are unchanged. `getEpisodes` uses a bounded window (200), not `getAllNewest`. Followed shows' catalog refreshes never read a Podcast Index list: new episodes take recipe IDs in `:core:rss`. `loadPiEpisodesForSupplement` is the Podcast Index list feed-only extras (`EpisodeSupplementPort`) are compared with: up to 1,000 newest episodes from `episodes/match` (match fields only), falling back to the oldest page (at most 100) when that route fails, returns nothing, or its `useEpisodeMatchBaseline` off switch is set. It is strict, so a failure of both stops the extras refresh instead of comparing the feed with an empty list. Its callers are the show page's "Missing episodes?" refresh, the legacy subscription sync and push paths without a local catalog, and the restore of extras opt-ins; they stay until show pages read feeds for every listener. Full pages keep the 100 cap. `getEpisode` resolves an ID in `EpisodeLinkResolver`'s order without reading a feed: a stored row (local catalog, then the feed-only rows for a negative ID), then the phone's row of the episode the ID is a trusted alias of, then the network. It sends a canonical positive ID that no local row or alias holds to `/v2/episode` (with the show when a caller passes `getEpisode(episodeId, showId)` and the show is below 2^52): a recipe-range ID (`[2^52, 2^53 − 1]`) never reaches the v1 Podcast Index lookup, and an old Podcast Index ID falls back to v1 `/episode` only when the v2 route can't answer; other legacy IDs keep v1. The episode keeps the ID asked for and carries the recipe ID and catalog key. A `needsFeed` answer, a 400/404/410 or an error is not found. Podcast Index IDs keep v1 `/episode`. `invalidateEpisodesCache` drops the 5-minute PI page cache for a feed (Podcast Info pull-to-refresh). `PodcastRepository` also owns the `/v2` client (`apiV2`, a `BoxLoreApiV2` built on first use): same base URL and the same cached OkHttp client as `api`, so the process keeps one HTTP cache. Tests can pass `boxLoreApiV2` the same way they pass `boxLoreApi`. `getEpisode` and the discovery routes below use its only caller so far. In-memory caching and pagination safety are managed by `PodcastRepositoryMemoryPolicy` (`clampPageLimit` caps requests to 100, and `TimedLruCache` bounds cache entries with LRU and TTL eviction to prevent OutOfMemoryError). - **Show page episode source (P4 PR 16).** `PodcastRepository.showPageSource(feedId)` is `RSS` (`rss:` shows), `FOLLOWED_CATALOG` (a ready followed catalog), `FEED` (`RolloutSwitches.feedFirst` on, a Podcast Index show ID, and its feed not found unreadable on this page) or `SERVER_LIST`. Only the show page reads the stored copy: `getShowPageEpisodes` pages a `FEED` show from it (`FeedFirstWiring.copies`, a `ShowPageFeedPort`), more pages following while the copy has rows or isn't the whole archive, and an oldest-first page of a partial copy comes from the server's list. `getEpisodesPaginated` (Smart Queue, Android Auto, Home, downloads, the episode page) never reads the copy, which only the show page reads again. For a show whose catalog isn't ready it reads `/v2/episodes`, its recipe IDs mapped to the phone's owners (`DiscoveryEpisodeIds.toLocalOwners`, so a rollback of the flag shows the same IDs), and v1 `/episodes` only when `/v2/episodes` fails (decision D1). `getEpisodeWindow` and `getEpisodes` use the same IDs: around an episode the feed-first copy holds, the copy's window; else the newest 200 of `/v2/episodes` under local owners; v1 only when that fails (`LEGACY(own-ids)`). `FeedFirstShowPages` (`openFeedShowPage`, `readShowPageFeed`) reads the feed into the copy, `NEWEST` or `ARCHIVE` deep, down the failure ladder: offline shows the copy with no read and no report (`OFFLINE`); the address is the registry's (`/v2/podcast`), else the saved one, and an http-only feed goes through `/v2/feed/` (`v2Transport`: the copy's ETag as `If-None-Match`, the body capped like a direct read with `RssFeedClient.readFeedBody`, a `404 not_proxied` with an HTTPS `fetchUrl` read directly, under the download gate and per-show lock); a failed read is tried once more (not after a 404 or 410), then reported through `FeedReporter.reportTarget` (the app's one `FeedReportLimiter`, leaving `FOLLOWED_SHOWS_RESERVE` of the daily cap to followed shows; never for a followed show, whose own refresh reports and saves addresses), and a different address in the answer is read, through the transport when it is http-only; then the page uses the server's list with a notice (`FALLBACK`) until it is opened again. One `feed_fetch_result` per read (source `feed` or `transport`, `partial` or `complete`). Once a day per process, in the background so a page's first paint never waits, it removes unfollowed copies not opened for 30 days, under a size cap (decision D10). In-show search always runs on the phone: the stored copy (`LIMIT 200` in the query), else the newest 1,000 of `/v2/episodes` matched by title or description; v1 `episodes/search` is never called (decision D16). `invalidateEpisodesCache` also drops pages cached under the Podcast Index ID a `url:`, `guid:` or `itunes:` ID resolved to. - A stored episode read by ID (`getLocalCatalogEpisode`) takes its show's name, cover, genre and artist from the saved show (`localCatalogMeta`, `Episode.withShowMeta`), as the stored page reads do; before, it came back without them and the episode page's "More from" row showed the placeholder instead of the show's cover. `PodcastRepositoryShowPagesTest` covers it. - Episode links, pushes and Android Auto items that name an episode by ID and maybe its show resolve through `resolveEpisodeLink(episodeId, showId, onReadingFeed)` (`EpisodeLinkResolver`, `PodcastRepositoryEpisodeLinks.kt`). Order: a stored row; a trusted alias of the ID, or for a recipe ID with its show the one episode of that show whose trusted aliases carry it, answered with the phone's own row and ID; the API (an answer for an episode stored under another trusted ID or the same key is translated to the phone's row; a v1 answer, which has neither, by its enclosure address); and, for a `needsFeed` answer, one read of the show's feed through the existing reader (`RssEpisodeFeedReader`: the publisher's HTTPS address with `RssFeedClient`, an http-only address only through `/v2/feed/`), matched by recipe ID in the show's stored namespace. Only items whose key follows the contract match; a feed listing the same item twice gives its first copy in document order, and a recipe ID two different keys share matches nothing. Results are `EpisodeLinkResult.Found`, `NotFound` (nothing lists it: 400, 404 or 410) or `Unavailable` (a source couldn't be reached, including 401, 403, 408 and 429). `storedEpisodeForKey(show, key)` finds a stored row by catalog key (the local catalog by GUID or enclosure, then the show's feed-only rows by enclosure); alias, push and link lookups use it. Ambiguous and unresolved aliases never resolve. `EpisodeAliasReads` / `RoomEpisodeAliasReads` are the alias reads (`trustedKeyOf`, `trustedKeyForRecipe`, `namespaceOf`); `PodcastRepository(episodeAliases = …)` takes them (none by default, as in older tests) and `feedReader` is the test seam for the feed read. `localEpisodeForRecipe(show, recipeId)` is the push match by (show, recipe ID). - Episode share links: `EpisodeSharePreparer` (an `EpisodeShareTargetSource`) names the episode by its trusted alias's recipe ID and Podcast Index show (also for an RSS show linked to that show), else by `EpisodeShareIds.choose` on the episode's fields; phone-only negative or reserved IDs are never shared: an episode held only under one is shared by the recipe ID of its catalog key in the show's namespace, else as the show's link, and `rss:` shows keep today's link. With a known catalog key it registers the episode first through `EpisodeShareRegistrar.registerOnce` (`POST /v2/episodes/register`, `{show, guid, enclosureUrl}` with the audio address so the API can match a GUID that differs from Podcast Index's, or `{show, enclosureUrl}` when the key is the audio address), waiting at most `DEFAULT_TIMEOUT_MS` (2 s); the link goes out whatever the answer, and a timeout, offline, 401, 429 or server error hands the request to `scheduleRetry` (the app's background worker). 400/404 are dropped. - `syncSubscriptions` (`POST /sync`) is PI-only. `SubscriptionForegroundSync` sends it the shows that are **not** catalog-ready and have no saved HTTPS feed address, plus the shows whose own feed failed this pass. The PI ids go out in chunks of 20 (`PI_SYNC_CHUNK_SIZE`), at most two calls at a time (`PI_SYNC_PARALLELISM`), because the API serves only the first 40 ids of a call: whole-library callers (Smart Downloads, backup restore) used to lose every show past the 40th. A failed chunk drops only its own shows' tips. Ready ids contribute the Room newest tip; opted-in not-ready ids contribute the cached extras tip. Live feed freshen stays on `SubscriptionForegroundSync`. `getEpisodeWindow` is the bounded Smart Queue / Auto entry (never `getAllNewest`). -- Show search: legacy `searchPodcasts` / `searchPodcastsWithCorrection` keep calling `GET /search` (hybrid). New clients use `searchPodcastsTypeahead` (`GET /search/typeahead` / Meili) and `searchPodcastsGrouped` (parallel typeahead + hybrid → Matches / Also found). Concept search uses `searchSemanticGrouped` / `searchEpisodesSemantic` (`GET /search/semantic`: one CF embed → Qdrant podcasts + episodes; additive `feeds` + legacy `items`). -- `logic.mergeShowSearchResults` dedupes Meili vs hybrid by id / itunes / feed URL. Missing/generic typeahead genres are filled from matching hybrid results when their known genre agrees; title-only matches and conflicting genres are not enrichment evidence. Existing catalog identity, other metadata, order and grouping stay unchanged. +- Show search: legacy `searchPodcasts` / `searchPodcastsWithCorrection` keep calling `GET /search` (hybrid). New clients use `searchPodcastsTypeahead` (`GET /search/typeahead`) and `searchPodcastsGrouped` (parallel typeahead + hybrid → Matches / Also found). Concept search uses `searchSemanticGrouped` / `searchEpisodesSemantic` (`GET /search/semantic`: podcasts and episodes in one answer; additive `feeds` + legacy `items`). +- `logic.mergeShowSearchResults` dedupes typeahead vs hybrid by id / itunes / feed URL. Missing/generic typeahead genres are filled from matching hybrid results when their known genre agrees; title-only matches and conflicting genres are not enrichment evidence. Existing catalog identity, other metadata, order and grouping stay unchanged. - `mapRegionForBriefing` maps content regions to briefing markets (`us` / `in` / `gb` / `global`; legacy `uk` → `gb`) via `ContentRegions.briefingMarket`. -- `SubscriptionRepository`, `ChapterRepository`, and `TranscriptRepository` expose catalog-adjacent data operations. `ChapterRepository` supports remote JSON, local file paths, and show note description parsing (`hasChaptersInDescription`, `parseChaptersFromDescription`). `TranscriptRepository` supports remote SRT/VTT and local file paths. Offline persistence and file retrieval for downloaded episodes are modularized in `ChapterOfflineStorage` (`saveOfflineChapters`, `getOfflineChapters`, `deleteOfflineChapters`, `hasOfflineChapters`) and `TranscriptOfflineStorage` (`saveOfflineTranscript`, `getOfflineTranscript`, `deleteOfflineTranscript`, `hasOfflineTranscript`). Normal `updateLatestEpisode` calls never replace a newer Room tip with an older item (`LatestEpisodeTipLogic`); same published date does **not** replace or badge. A successful full publisher-feed ingest passes `publisherFeedAuthoritative`, allowing the real feed tip to replace a newer PI cross-promo or same-date different identity. NEW is still set only when `publishedDate` is newer. Subscribe recovers a missing HTTPS `feedUrl` via `GET /podcast` (daily retry also runs in launch sync). For catalogue notification subscriptions, `SubscriptionRepository` writes RTDB `tracked_podcasts/{podcastIndexId}` with `title`, `imageUrl`, and an HTTPS `feedUrl` when a Room tip exists. Live RTDB rules allow those three keys only. The checker selects RSS whenever that URL is present, independently of Missing episodes?; it seeds `lastRssKey` quietly unless the newest RSS item matches a PI episode different from the saved `lastEpisodeId`. Turning catalogue notifications **off** removes the `feedUrl` child. Pure RSS uses the journaled per-device URL scopes described above. Unsubscribe sets a 14-day TTL on the local catalog (no CASCADE wipe), records an `unsubscribedAt` tombstone timestamp, and marks `isDirty = 1` for delta sync; resubscribing resets `unsubscribedAt = 0` and sets `isDirty = 1`. Show settings updates (notifications, custom genre/icon, auto-download, skip intro/outro, playback speed) mark the show `isDirty = 1` for cloud sync. `updateCustomGenre` applies user-defined genre overrides and custom icon keys for subscribed shows; unsubscription clears any custom tag/icon overrides and resets to original catalog genre. +- `SubscriptionRepository`, `ChapterRepository`, and `TranscriptRepository` expose catalog-adjacent data operations. `ChapterRepository` supports remote JSON, local file paths, and show note description parsing (`hasChaptersInDescription`, `parseChaptersFromDescription`). `TranscriptRepository` supports remote SRT/VTT and local file paths. Offline persistence and file retrieval for downloaded episodes are modularized in `ChapterOfflineStorage` (`saveOfflineChapters`, `getOfflineChapters`, `deleteOfflineChapters`, `hasOfflineChapters`) and `TranscriptOfflineStorage` (`saveOfflineTranscript`, `getOfflineTranscript`, `deleteOfflineTranscript`, `hasOfflineTranscript`). Normal `updateLatestEpisode` calls never replace a newer Room tip with an older item (`LatestEpisodeTipLogic`); same published date does **not** replace or badge. A successful full publisher-feed ingest passes `publisherFeedAuthoritative`, allowing the real feed tip to replace a newer PI cross-promo or same-date different identity. NEW is still set only when `publishedDate` is newer. Subscribe recovers a missing HTTPS `feedUrl` via `GET /podcast` (daily retry also runs in launch sync). For catalogue notification subscriptions, `SubscriptionRepository` writes RTDB `tracked_podcasts/{podcastIndexId}` with `title`, `imageUrl`, and an HTTPS `feedUrl` when a Room tip exists, and nothing else. The checker selects RSS whenever that URL is present, independently of Missing episodes?; it seeds `lastRssKey` quietly unless the newest RSS item matches a PI episode different from the saved `lastEpisodeId`. Turning catalogue notifications **off** removes the `feedUrl` child. Pure RSS uses the journaled per-device URL scopes described above. Unsubscribe sets a 14-day TTL on the local catalog (no CASCADE wipe), records an `unsubscribedAt` tombstone timestamp, and marks `isDirty = 1` for delta sync; resubscribing resets `unsubscribedAt = 0` and sets `isDirty = 1`. Show settings updates (notifications, custom genre/icon, auto-download, skip intro/outro, playback speed) mark the show `isDirty = 1` for cloud sync. `updateCustomGenre` applies user-defined genre overrides and custom icon keys for subscribed shows; unsubscription clears any custom tag/icon overrides and resets to original catalog genre. - **Discovery on `/v2`.** While `RolloutSwitches.v2Discovery` is on (the default; read per request), `PodcastRepository` sends recommendations (`getPersonalizedRecommendations`), Home and Explore picks (`getHomeRecommendations`, `POST /v2/home/bootstrap` with a `V2BootstrapRequest`), the launch bootstrap (`getHomeBootstrapDataFast`), trending and its stream, semantic search, because-you-like, similar episodes, curated vibes, the curiosity deck and briefing metadata to the `/v2` routes; off sends each back to its v1 route. Show search (`searchPodcastsWithCorrection`, also the hybrid half of `searchPodcastsGrouped` and onboarding search) stays on v1 `/search` either way: shows carry no episode IDs, and `/v2/search` has no Apple or Podcast Index results, so shows outside the catalog would disappear. Typeahead, onboarding, content catalog v3, feedback, `/sync` and the OPML import lookup stay on v1 too. Answers have the v1 shapes: - **Episode IDs in.** Recipe IDs in answers are replaced by the stored ID that owns the episode on this phone (`DiscoveryEpisodeIds.localOwners`, before callers rank, dedupe or show them): recommendations, Home picks, semantic and similar episodes, because-you-like episodes, trending and vibe latest episodes, curiosity cards and briefing related episodes (bootstrap chapters and `ChapterRepository`, including a downloaded briefing's saved chapters file). The owner comes with the show its listener state is stored under (`DiscoveryEpisodeOwner`; the show's own feed for a show the listener switched to it), and the episode moves to that show; a curiosity card, which names its show by number, is then left as it is. Every translated or untranslated answer keeps the recipe ID it named in `Episode.recipeId`. With no trusted alias (most history older builds played from discovery, still unresolved), a unique listening-history or queue row of the same show with the answer's audio address is the owner, unless a trusted alias places that row elsewhere. An episode the phone can't place keeps its recipe ID, which resolves through `/v2/episode`. Show IDs never change. - - **Episode IDs out.** Recommendation seeds and exclusions, and the similar-episodes seed, go as recipe IDs (`DiscoveryEpisodeIds.recipeIds`): a Podcast Index ID from an older build becomes its episode's recipe ID when a trusted alias has one, and phone-minted IDs with one (feed-only episodes, RSS episodes linked to a Podcast Index show) are no longer left out. A Podcast Index ID the phone can't place still seeds through its text fallback and is still excluded as itself (it can't clash with a recipe ID). `/v2` excludes by recipe ID only, so recommendations and Home picks also drop episodes the request's history holds after translation (`withoutHistory`), and similar episodes drop their own seed (`withoutSeed`, by stored ID or by show and title); the similar-episodes seed is `"0"` when the episode can't be placed. Recommendation bodies carry the normalised content languages (at most four; the API refuses more and expands `id` to `in` itself). Without a seed or an interest, personal recommendations make no request (as on v1), while Home and Explore picks ask for the country's default interests (`countryDefaultRecommendationRequest`, mirroring the Worker's `defaultInterests`), so a new listener gets popular picks as on v1. Home picks are marked fallback only for that request, as on v1 (interest-only picks are personal). + - **Episode IDs out.** Recommendation seeds and exclusions, and the similar-episodes seed, go as recipe IDs (`DiscoveryEpisodeIds.recipeIds`): a Podcast Index ID from an older build becomes its episode's recipe ID when a trusted alias has one, and phone-minted IDs with one (feed-only episodes, RSS episodes linked to a Podcast Index show) are no longer left out. A Podcast Index ID the phone can't place still seeds through its text fallback and is still excluded as itself (it can't clash with a recipe ID). `/v2` excludes by recipe ID only, so recommendations and Home picks also drop episodes the request's history holds after translation (`withoutHistory`), and similar episodes drop their own seed (`withoutSeed`, by stored ID or by show and title); the similar-episodes seed is `"0"` when the episode can't be placed. Recommendation bodies carry the normalised content languages (at most four; the API refuses more and expands `id` to `in` itself). Without a seed or an interest, personal recommendations make no request (as on v1), while Home and Explore picks ask for the country's default interests (`countryDefaultRecommendationRequest`, matching the API's defaults), so a new listener gets popular picks as on v1. Home picks are marked fallback only for that request, as on v1 (interest-only picks are personal). - **Failures.** A failed `/v2` request never falls back to v1. It keeps the last good answer of the same request in this process (`V2DiscoveryLastGood`); recommendations and Home picks keep their last good picks per country across restarts (`RecommendationsLastGood`, translated again when read), including when an answer has no picks. With neither, the result is empty or null, and Home's bootstrap and picks are marked `failed` (`HomeBootstrapData.failed`, `HomeRecommendations.failed`) so Home and Explore keep the trending and picks they show (restored from their caches) instead of replacing them with an empty list. An empty success (a trending page past the end) stays empty. - **Caches.** `/v2` recommendation and because-you-like answers are cached under `v2|` keys, so a v1 answer never serves a v2 request or the reverse. - `RoomDiscoveryEpisodeIds` is the alias-table implementation (`episode_aliases`, trusted aliases only; a recipe ID two keys of one show share stays as it is; the audio-address match above); the app passes it as `discoveryEpisodeIds`. `DiscoveryRouting.install` gives the same switch and translator to code outside the repository: `BriefingLinks` builds the briefing chapters link on `/v2/briefings/chapters/` while v2 discovery is on (the transcript link keeps `/briefings/transcript/`, as the `/v2` briefing answer links it), and `ChapterRepository` translates related episodes of the chapters it fetches. - `RecommendationsV2Request` always sends `contractVersion: 2`, which the `/v2` recommendation bodies need. With the switch off, personal picks go straight to contract-1 `/recommendations` (`v1PersonalizedRecommendations`), exactly what shipped builds received: their v1 `recommendations/v2` request was always refused, so the switch restores the shipped behaviour. -- `SubscriptionForegroundSync` refreshes subscribed latest episodes (`syncSubscriptions` in chunks of 10). Cold start from Home still waits 2s for first paint; **Subscriptions appear** call `requestRefresh()`. Each pass starts with `sweepExpired` so unsubscribed catalogs past the 14-day TTL are deleted. PI `/sync` runs for shows that are not catalog-ready. Every subscribed PI show with an HTTPS `feedUrl` refreshes the local catalog (HEAD/quiet skip for ready rows; not-ready rows force the repair GET; one-time PI rematch only while `needsFullBackfill`). Missing HTTPS URLs are recovered via `GET /podcast` at most once per day, with bounded concurrency; one failed recovery does not abort the rest of the pass. A show whose own feed failed this pass is handled after the feed reads (`DirectFeedRecovery.recover`): the failure is reported (above), and when it says the saved address looks dead (`RefreshOutcome.Failure.addressGone`: 401/403/404/410/451, no such host, a failing certificate, or not a feed), the report's address is tried, else `DirectFeedRecovery.recheckGoneFeedAddress` asks `GET /podcast` for the server's confirmed address. That re-check is the daily check's fallback: it shares its per-show stamp (`feedUrlCheckedAt`), so it runs only for shows the daily check couldn't reach and never twice a day (the lookup counts only once the server answers); a different HTTPS address is read at once and saved only when that read works; shows still failing take their latest episode from `/sync` through the ordinary save, which never replaces a newer tip. A feed that moved for good (`RefreshOutcome.Success.movedTo`) has its new address saved. Never for `rss:` shows. `FeedFixToggles` (`addressRecheck`, `syncFallback`, `movedFeed`) are the app's PostHog kill switches; each stays on without an answer. `PodcastRepository.rolloutSwitches` (`RolloutSwitches`) carries the v2 build's switches from the app: `feedFirst` (show pages read the show's own feed instead of the server's list; off by default, and the app fixes it per process), and `upgradeRepair`, `addressCheck` and `v2Discovery` (on by default, and the app uses the last saved answer while PostHog is starting; off pauses the upgrade repair or the daily address check without marking them done, or sends discovery back to the v1 routes). `v2Discovery` is read by the discovery calls above and `addressCheck` by the daily check and the broken-feed reports; `upgradeRepair` by the upgrade repair (`UpgradeAddressRepair`). Automatic foreground feed work selects at most 10 due shows per pass, rotates past failures to prevent starvation, prioritizes the visible show, and caps concurrency at 2. Ready feeds share the RSS layer's persisted six-hour cooldown. The 15-minute timer pauses outside the foreground and leaving foreground cancels its in-flight automatic pass; the five-minute pass cooldown still coalesces repeated screen requests. New Episodes pull to refresh calls `refreshNow()`: it skips that cooldown and runs a `ManualRefreshPass`, whose feed selection (`isFeedDueThisPass`) also reads feeds inside the six-hour wait (still at most 10 a pass, rotating). It lets an automatic pass already running finish first, joins another pull's pass instead of starting a second, runs on the sync scope so leaving the screen does not cancel it, and does nothing in the background. A successful persist emits `directFeedRefreshed`. Subscribe calls `requestCatalogIngest` on the same application scope so the first publisher-feed persist is not cancelled when Podcast Info is popped; it ignores `requestRefresh` cooldown / in-flight gates. `catalogIngestFinished` emits when that one-shot ends (success, skip, or failure). The same pass patches RTDB `feedUrl` for notifying HTTPS shows. `:app` starts it after onboarding; Library `onScreenResume` calls `requestRefresh`. +- `SubscriptionForegroundSync` refreshes subscribed latest episodes (`syncSubscriptions` in chunks of 10). Cold start from Home still waits 2s for first paint; **Subscriptions appear** call `requestRefresh()`. Each pass starts with `sweepExpired` so unsubscribed catalogs past the 14-day TTL are deleted. PI `/sync` runs for shows that are not catalog-ready. Every subscribed PI show with an HTTPS `feedUrl` refreshes the local catalog (HEAD/quiet skip for ready rows; not-ready rows force the repair GET; one-time PI rematch only while `needsFullBackfill`). Missing HTTPS URLs are recovered via `GET /podcast` at most once per day, with bounded concurrency; one failed recovery does not abort the rest of the pass. A show whose own feed failed this pass is handled after the feed reads (`DirectFeedRecovery.recover`): the failure is reported (above), and when it says the saved address looks dead (`RefreshOutcome.Failure.addressGone`: 401/403/404/410/451, no such host, a failing certificate, or not a feed), the report's address is tried, else `DirectFeedRecovery.recheckGoneFeedAddress` asks `GET /podcast` for the server's confirmed address. That re-check is the daily check's fallback: it shares its per-show stamp (`feedUrlCheckedAt`), so it runs only for shows the daily check couldn't reach and never twice a day (the lookup counts only once the server answers); a different HTTPS address is read at once and saved only when that read works; shows still failing take their latest episode from `/sync` through the ordinary save, which never replaces a newer tip. A feed that moved for good (`RefreshOutcome.Success.movedTo`) has its new address saved. Never for `rss:` shows. `FeedFixToggles` (`addressRecheck`, `syncFallback`, `movedFeed`) are the app's remote switches; each stays on without an answer. `PodcastRepository.rolloutSwitches` (`RolloutSwitches`) carries the v2 build's switches from the app: `feedFirst` (show pages read the show's own feed instead of the server's list; off by default, and the app fixes it per process), and `upgradeRepair`, `addressCheck` and `v2Discovery` (on by default, and the app uses the last saved answer while remote switches load; off pauses the upgrade repair or the daily address check without marking them done, or sends discovery back to the v1 routes). `v2Discovery` is read by the discovery calls above and `addressCheck` by the daily check and the broken-feed reports; `upgradeRepair` by the upgrade repair (`UpgradeAddressRepair`). Automatic foreground feed work selects at most 10 due shows per pass, rotates past failures to prevent starvation, prioritizes the visible show, and caps concurrency at 2. Ready feeds share the RSS layer's persisted six-hour cooldown. The 15-minute timer pauses outside the foreground and leaving foreground cancels its in-flight automatic pass; the five-minute pass cooldown still coalesces repeated screen requests. New Episodes pull to refresh calls `refreshNow()`: it skips that cooldown and runs a `ManualRefreshPass`, whose feed selection (`isFeedDueThisPass`) also reads feeds inside the six-hour wait (still at most 10 a pass, rotating). It lets an automatic pass already running finish first, joins another pull's pass instead of starting a second, runs on the sync scope so leaving the screen does not cancel it, and does nothing in the background. A successful persist emits `directFeedRefreshed`. Subscribe calls `requestCatalogIngest` on the same application scope so the first publisher-feed persist is not cancelled when Podcast Info is popped; it ignores `requestRefresh` cooldown / in-flight gates. `catalogIngestFinished` emits when that one-shot ends (success, skip, or failure). The same pass patches RTDB `feedUrl` for notifying HTTPS shows. `:app` starts it after onboarding; Library `onScreenResume` calls `requestRefresh`. - `LegacyRssRepair` runs once after onboarding for pre-fix OPML rows when enabled by the app-shell build/install gate. It fetches each subscribed `sourceType=rss` feed and accepts only an exact Podcast Index feed-URL variant or podcast GUID; title/author/artwork are never match inputs. Rows with `linkedPodcastIndexId` are skipped because they represent an intentional RSS-source choice. URL/GUID conflicts, lookup failures, active target subscriptions, or incomplete local catalogs leave the RSS row untouched. Successful rows adopt the PI id while retaining RSS episode ids, journaling DataStore id references for crash-safe completion, restoring notification routing for shows that had alerts enabled, and clearing any recommendation cache seeded by the old id. A pass starts only when an eligible row and connectivity are present, publishes `inProgress` for persistent app-shell status UI, and is admitted by a process-local one-shot gate. Three consecutive transient lookup/inspect failures stop the remaining sequential work. Fresh installs can persist the completed marker without starting a pass or showing progress. Once a pass settles without a pending ID transaction, its version marker is written even if individual feeds had transient failures, preventing foreground/banner loops; only a process killed mid-transaction can resume from the pending journal on a later launch. - `content.ContentOrchestrator`, `ServerGroupedSectionProvider`, `ContentContextEngine`, and related content contracts assemble discovery slates from catalog/v3 (no live `content/sections/v1` client). - `content.CuratedMoods` is the shared catalog of curated-mood IDs/titles used by Home daypart rails and Explore For You chips (`getCuratedVibe`). diff --git a/core/catalog/src/test/resources/fixtures/v2/README.md b/core/catalog/src/test/resources/fixtures/v2/README.md index e2338b77b..e92aace64 100644 --- a/core/catalog/src/test/resources/fixtures/v2/README.md +++ b/core/catalog/src/test/resources/fixtures/v2/README.md @@ -1,6 +1,6 @@ # Recorded `/v2` discovery answers -Answers recorded from the live Worker routes on 2026-10-25 with a synthetic device ID and no +Answers recorded from the live API routes on 2026-10-25 with a synthetic device ID and no listener data, then trimmed for size: lists cut to their first items, descriptions shortened, and briefing link signatures replaced with `sig=redacted`. Field names, types and nesting are as recorded. @@ -18,7 +18,7 @@ recorded. | `shows-feeds.json` | `POST /v2/shows/feeds` with `{"ids":[3391039,920666,9999999999,7]}`, recorded 2026-10-27: a registry move flagged for repair, a dump address, a show the API doesn't know (`fetchUrl: null`) and an http-only show read through the API (`fetchVia: proxy`) | Two answers can't be recorded on demand, so they are written field for field in the shape the -Worker answers with (the same fields, types and nesting as `shows-feeds.json`): +API answers with (the same fields, types and nesting as `shows-feeds.json`): | File | Answer | |---|---| diff --git a/core/model/README.md b/core/model/README.md index 176d29b0b..899c9abbd 100644 --- a/core/model/README.md +++ b/core/model/README.md @@ -16,7 +16,7 @@ Owns shared domain models, enums, and pure value helpers used across network, da - `EpisodeMediaCacheKey`: Media3 `customCacheKey` helper — briefing keys append audio URL `v=` so same-day regenerations bust the local audio cache. - `ContentRegion` / `ContentRegions`: 11 chart storefronts, language allowlist/normalize/expand (`id`→`id,in`), off-market soft-warn helpers, and briefing market mapping that preserves the explicit `global` briefing tab. - `ContentLanguageSelection`: pure language-chip toggle rules (English has its own switch, max languages with English's slot free when it is off, BCP47 normalization) and `englishSwitch` (the "Include English" state: on, allowed, and the picks that keep it on). -- English off (D9, 29 Oct): `ContentRegions.ENGLISH_OFF_LANGUAGES` (fr, de, es, pt, ru, nl; chosen from catalog pool sizes, kept aligned with the proxy), `canSwitchEnglishOff`, and `normalizeLanguages(…, englishOff)`, which leaves English out only when allowed. +- English off (D9, 29 Oct): `ContentRegions.ENGLISH_OFF_LANGUAGES` (fr, de, es, pt, ru, nl; chosen from catalog pool sizes, kept aligned with the API), `canSwitchEnglishOff`, and `normalizeLanguages(…, englishOff)`, which leaves English out only when allowed. - `ContentLanguageSelection.normalizeKeepingEnglishChoice` (30 Oct) re-normalizes a list that is already the listener's choice without adding English back when it was left out; request builders use it so English off reaches the server. - `PlaybackEntryPoint`, `ShareTarget`, and `ShareLinkBuilder`. Episode links can name their show (`episode(…, showId)` adds `&show=` before `t` or the clip range); `forTarget` builds the link for an `EpisodeShareTarget` (`Episode(id, showId)`, or `Show(showId)`, which is the show's link). - `EpisodeShareIds.choose(localId, podcastId, recipeId)` picks the ID a share link carries: for a Podcast Index show its recipe ID (or its own recipe-range or Podcast Index ID) with the show; never a phone-only negative or reserved ID (the show's link instead); a show added by its feed address (`rss:`) keeps the episode's own ID with no show. `EpisodeShareTargetSource` is the seam the app fills with alias reads and registration (`FROM_EPISODE` uses the episode's fields only). diff --git a/core/network/README.md b/core/network/README.md index dc7369083..63796d61d 100644 --- a/core/network/README.md +++ b/core/network/README.md @@ -6,7 +6,7 @@ Owns the Retrofit API boundary, OkHttp/Retrofit construction, request and respon ## Public API -- `BoxLoreApi` defines the Retrofit service surface, including additive `GET search/typeahead` (Meili show typeahead), cloud sync endpoints (`POST user/sync/push`, `POST user/sync/pull`, `DELETE user/sync/account`), and legacy routes. Legacy `GET search` and `POST sync` are unchanged. `GET episodes/match` (up to 1,000 newest episodes, match fields only) serves only the comparison list of a Podcast Index show's feed-only extras; followed shows' catalogs never call it, because new feed items take recipe IDs instead of being matched against a Podcast Index list. +- `BoxLoreApi` defines the Retrofit service surface, including additive `GET search/typeahead` (show typeahead), cloud sync endpoints (`POST user/sync/push`, `POST user/sync/pull`, `DELETE user/sync/account`), and legacy routes. Legacy `GET search` and `POST sync` are unchanged. `GET episodes/match` (up to 1,000 newest episodes, match fields only) serves only the comparison list of a Podcast Index show's feed-only extras; followed shows' catalogs never call it, because new feed items take recipe IDs instead of being matched against a Podcast Index list. - `BoxLoreApiV2` is the `/v2` surface for builds that use boxlore's own episode IDs. Paths are relative (`v2/...`) and there is no app-key parameter. It covers show details and addresses (`getPodcast`, `getShowFeeds` for the daily check of up to 500 shows, `getFeed` for an http-only show's feed document, `reportFeed`), episodes (`getEpisode`, `getEpisodes` as the last-resort list, `registerEpisode` before sharing, `getSimilarEpisodes`), search (`search`, `searchSemantic`) and discovery (trending, home bootstrap POST and GET, recommendations, because-you-like, curated vibe and curiosity, briefing metadata and `getBriefingResource`). Discovery routes answer in the v1 response shapes. - On `/v2`, an episode's `id` is its recipe ID. `EpisodeItem` carries the optional `servedId` (the ID older builds were given) and `catalogKey`, both absent on v1. `EpisodeItem.v2Identity()` returns recipe ID, show ID, catalog key and served ID (as a decimal string), or null when `id` is outside the recipe range or the show is missing. Use it only on `/v2` answers. - `V2EpisodeIds` holds the range checks: recipe IDs in `[2^52, 2^53 − 1]`, show IDs and namespaces in `1..2^53 − 1`, `isEpisodeShowId` for the episode routes (`/v2/episode`, `/v2/episodes` and registration take only shows below 2^52), a strict `parseShowId` for stored show IDs (canonical digits only; `rss:` shows return null) and `isLookupEpisodeId` (never a negative local ID). Legacy episode IDs stay strings. `V2EpisodesPaging.isValid` mirrors the `/v2/episodes` bounds (show below 2^52, `limit` 1–1000, `offset` 0–10000). The routes answer 400 outside these rules, which is permanent, so check before calling and never retry it. @@ -15,25 +15,17 @@ Owns the Retrofit API boundary, OkHttp/Retrofit construction, request and respon - `getFeed` streams the document. It sends `Cache-Control: no-store`, so the shared HTTP cache never stores feeds, and takes an optional `If-None-Match`. A 304 is not a successful Retrofit response, so callers check `code()`. - Request models validate on construction: `V2ShowsFeedsRequest` holds 1–500 show IDs, `V2FeedReportRequest` takes a `V2FeedReportStatus` value (`http_NNN`, `timeout`, `dns`, `tls`, `parse`, `network`, `other`), and `V2EpisodeRegisterRequest` needs a show below 2^52 and a catalog key (the trimmed GUID, else the trimmed enclosure URL) of at most 2,048 characters, as the route does. A null field is left out of the JSON. - `V2BriefingRoute.url(baseUrl, region, date, version)` builds `/v2/briefings//` links with `d` and `v`. - - `getHomeBootstrap` takes `V2BootstrapRequest`: like `BootstrapRequest`, but its `recommendationsRequest` is the current recommendation body (`RecommendationsV2Request`), because on `/v2` the Worker forwards it to the current algorithm, which reads seeds and exclusions as recipe IDs and refuses the older `history` body. `country` and `vibeIds` are always sent. + - `getHomeBootstrap` takes `V2BootstrapRequest`: like `BootstrapRequest`, but its `recommendationsRequest` is the current recommendation body (`RecommendationsV2Request`), because `/v2` reads seeds and exclusions as recipe IDs and refuses the older `history` body. `country` and `vibeIds` are always sent. - `RecommendationsV2Request.contractVersion` has no default: the client never encodes defaults and the API answers 400 without the field, so callers pass `RecommendationsV2Request.CONTRACT_VERSION` (2). This applies to v1 `recommendations/v2`, `/v2/recommendations` and the `/v2` bootstrap's recommendations part. - `FirebaseAuthAuthenticator` provides an OkHttp `Authenticator` handling HTTP 401 Unauthorized responses with Kotlin `Mutex` refresh serialization to prevent token refresh stampedes (consuming `AuthRepository` from `:core:auth`). - `NetworkModule` creates OkHttp, Retrofit, and related network clients. `createCachedClient(context)` builds the shared client with the 50 MiB HTTP cache; build `BoxLoreApi` and `BoxLoreApiV2` on that one client (`createBoxLoreApi(baseUrl, client)`, `createBoxLoreApiV2(baseUrl, client)`), because two caches must never share a directory. There is no context-based `createBoxLoreApi` shortcut, so nothing can open a second cache on the same directory; tests pass their own client. The clients come with automatic header redaction for `Authorization`, `X-App-Key`, `X-Firebase-AppCheck`, `X-Boxlore-Session`, `X-Session-Renew` and `X-Session-Proof`. Every request carries `X-App-Version` and `X-App-Channel` (`play` or `github`) when the app sets `appVersion` / `appChannel`. `createSessionExchangeClient()` is the plain client for `POST /auth/session` (no session interceptor, no Firebase authenticator, no cache). -- **English off (D9, 29 Oct).** `EnglishOffInterceptor` (first on the API client) adds `englishOff` to any request whose languages leave English out, which happens only when the listener switched it off: `englishOff=1` on a `languages` query, `"englishOff": true` in a JSON body with a `languages` array. Requests with English, without languages or that already say pass through byte for byte (`EnglishOffInterceptorTest`). -- **API sessions (`session/`, 2026-10-17).** This build authenticates with API session tickets only and **never sends the bundled app key**: `ApiSessionInterceptor` strips `X-App-Key` from every request. `NetworkModule.sessionManager` is set by the app at startup. -- **Request proofs (`session/RequestProofInterceptor`, 2026-11-02).** A session can be asked for with any install id, so routes holding an install's private data need proof the request comes from the phone that registered the session's device key. `RequestProofSigner` (`ApiSessionManager.requestProofs`) signs `bls-request....` (API clock) with the device key, and `RequestProofInterceptor`, a network interceptor that `NetworkModule.createSupportApi` adds to the support client only, sends it as `X-Request-Proof` (redacted in logs). No device key or no session means no header, and the API answers 401. The support client also carries the signed-in account's Firebase ID token from the first request (`AccountTokenInterceptor`, fed by `NetworkModule.accountToken`, which the app sets to the cached token with a 3-second wait; nothing is added when signed out or when a request already has `Authorization`), so the API links conversations to the account. `device_key_required` and `proof_required` count as shield codes, so `FirebaseAuthAuthenticator` doesn't retry them with a token. - - `ApiSessionManager` owns the install's session. One shared `POST /auth/session` exchange runs at a time; `start()` begins it at launch when no usable session is stored, and requests wait for it (at most 15 s) only then: a fresh install, or after the 30-day attestation window. With a stored session, requests attach it without waiting. Failed exchanges back off (2 s, 5 s, 15 s, 30 s, 1 min, then every 5 min, or the server's `Retry-After`); inside a backoff, requests go out without a session at once. There is no app-key fallback. - - Exchanges send `{installId, platform: "android", channel, devicePublicKey}` (plus `turnstileToken` for a step-up) with `X-Firebase-AppCheck`. App Check is fetched only here, bounded to 6 s; a phone that has failed App Check gets its next session without asking App Check on the critical path. Background upgrades and refreshes give it 30 s instead (`backgroundAppCheckTimeoutMs`): they run while a usable session is held, so no request waits, and slow Play Integrity has time to finish. - - Renewal: once a token is past `exp` (30 s early, for requests in flight), requests add `X-Session-Proof: .`, a SHA256withECDSA signature of `bls-renew..` by the device key (`AndroidKeystoreDeviceKey`: non-exportable EC P-256 in StrongBox when available, else the TEE). One proof is reused for up to 2 minutes. `X-Session-Renew` from a network response (never a cached one) replaces the token when it extends the same session. The API clock is learned from each token's `iat`, so a wrong phone clock does not break renewals. - - `session_expired` / `session_invalid` / `session_revoked`: the interceptor gets one new session and retries once. A session refused within a minute of being issued is not replaced again (the request returns its 401 and the exchange backs off), so a misconfigured API can't cause an exchange storm. - - Limited sessions (`attestation: none`): `ApiSessionScheduler` retries App Check in the background only while the app is in the foreground with a network, about a minute after the first failure, then hourly (three times), then daily, and swaps in the full session silently. Weaker sessions are also refreshed (without App Check) an hour before they run out, and App Check sessions re-attest a day before their window ends. Background exchanges are at least a minute apart. - - `verification_required` on an AI route: `ApiSessionStepUp` asks the app's `TurnstileTokenSource` for a token, exchanges it for a `challenge` session and retries once. It runs only when `stepUpEnabled` returns true (the app keeps it off until the API can verify Turnstile). Concurrent AI requests share one step-up; a failure starts a 30-minute cooldown in which AI requests return their 401 at once, so onboarding uses its non-AI path. `daily_ai_limit` (429) is passed straight to the caller. - - `ApiErrorCodes` reads the shield's `{"error": ""}` bodies (`of(okhttp3.Response)` peeks without consuming; `of(retrofit2.Response)` reads the error body). `AI_UNAVAILABLE` lists the codes after which AI routes won't work for this session. - - `ApiSessionListener` reports each exchange (trigger, success, duration, attestation, limited, App Check attempt and failure category, server reason), the first renewal of each token or a renewal problem, and step-up outcomes; the app forwards them to `:core:analytics`. +- **English off.** `EnglishOffInterceptor` (first on the API client) adds `englishOff` to any request whose languages leave English out, which happens only when the listener switched it off: `englishOff=1` on a `languages` query, `"englishOff": true` in a JSON body with a `languages` array. Requests with English, without languages or that already say pass through byte for byte (`EnglishOffInterceptorTest`). +- **API sessions (`session/`).** Requests are authenticated with API session tickets; the bundled app key is never sent (`ApiSessionInterceptor` strips `X-App-Key`). `NetworkModule.sessionManager` is set by the app at startup. `ApiSessionManager` owns the install's session: it gets one at launch, attaches it to requests, renews it with the install's device key (`AndroidKeystoreDeviceKey`, non-exportable) and backs off after failures. `ApiSessionScheduler` keeps the session fresh in the background, `ApiSessionStepUp` handles the API's extra verification for AI routes, `ApiErrorCodes` reads the API's error codes, and `ApiSessionListener` reports session outcomes, which the app forwards to `:core:analytics`. Attestation comes in through ports (`ApiSessionPorts.kt`). +- **Support requests.** The support client (`NetworkModule.createSupportApi`) proves requests for an install's private data come from that install (`RequestProofSigner`, `RequestProofInterceptor`) and carries the signed-in account's Firebase ID token (`AccountTokenInterceptor`, fed by `NetworkModule.accountToken`). - `FirebaseAuthAuthenticator` ignores 401s carrying shield codes (`ApiErrorCodes.SHIELD_CODES`): those belong to the session interceptor, and a Firebase token can't fix them. - `StreamingJsonConverterFactory` streams response deserialization directly from OkHttp's `ResponseBody.byteStream()` via `Json.decodeFromStream` without monolithic string buffering. - DTOs under `cx.aswin.boxlore.core.network.model`, including recommendation, bootstrap, content catalog/v3, history, sync, user cloud sync (`UserSyncModels.kt` with `feedUrl` support on `UserSubscriptionSyncDto`), and request payload models. -- Cloud sync episode identity (`UserSyncModels.kt`): `ListeningHistorySyncDto` and `QueueItemSyncDto` carry optional `recipeId` (a JSON number) and `catalogKey`; `QueueSyncDto.recentRemovedEpisodes` is the rich removal list (`QueueRemovalSyncDto`: `id`, `recipeId`, `show`, `catalogKey`) beside the plain `recentRemovedEpisodeIds`; `SyncPullRequest.richRemovals` asks the server for the rich list. With `encodeDefaults` off, unknown identity fields are not sent (the server keeps what it stored, as for older builds), a rich list is sent whenever it is set (an empty list too, because the server replaces its list with the one sent), and `richRemovals` must be set to true explicitly. A pull answer without the rich list decodes it as null. `SyncEpisodeIdentityFields` reads the fields from the wire: a recipe ID outside `[2^52, 2^53 − 1]` or a blank key (or one over 2,048 characters) counts as unknown. Legacy episode IDs stay opaque strings. Listening (Room v40): `ListeningHistorySyncDto.firstPlayedAt` and `finishedAt` (null unknown, 0 before recording, else epoch ms); `SyncPushRequest.listeningDays` (`ListeningDaySyncDto`, one installation's listening of one episode on one local day, under its listening device ID; all zero when that installation no longer holds the day) and `playedShows` (`PlayedShowSyncDto`), left out when empty; `SyncPushResponse.listeningAccepted` (only a server that stores listening sets it); each `ListeningDaySyncDto` also carries the summary's richer totals (Room v41: `longestSessionMs`, `speakerMs`, `headphonesMs`, `bluetoothMs`, `carMs`, `castMs`, `loreMs`, `smartQueueMs`, `newReleaseMs`; 0 not known) and, since Room v42, `releaseKnownMs` (the time of episodes with a known release date; 0 not known), `SyncPushRequest.listeningTotals` says the push's days carry them (0 included) and `SyncPushRequest.listeningReleaseKnown` that they carry `releaseKnownMs`, and `SyncPushResponse.listeningTotals` / `SyncPullResponse.listeningTotals` say the server stores them (false from an older server), and `listeningReleaseKnown` on both that it stores `releaseKnownMs`; `SyncPullRequest.listening` and `listeningSince` (nullable, so 0 is sent; the server falls back to `since`); `SyncPullResponse.listeningDays` and `playedShows`, null from an older server. Onboarding curriculum / genre-synth / similar-shows requests accept optional `languages` (chip codes; proxy expands and defaults from country). `BecauseYouLikeRequest` also carries optional `languages` (omitted when null), so English off reaches the because-you-like route. +- Cloud sync episode identity (`UserSyncModels.kt`): `ListeningHistorySyncDto` and `QueueItemSyncDto` carry optional `recipeId` (a JSON number) and `catalogKey`; `QueueSyncDto.recentRemovedEpisodes` is the rich removal list (`QueueRemovalSyncDto`: `id`, `recipeId`, `show`, `catalogKey`) beside the plain `recentRemovedEpisodeIds`; `SyncPullRequest.richRemovals` asks the server for the rich list. With `encodeDefaults` off, unknown identity fields are not sent (the server keeps what it stored, as for older builds), a rich list is sent whenever it is set (an empty list too, because the server replaces its list with the one sent), and `richRemovals` must be set to true explicitly. A pull answer without the rich list decodes it as null. `SyncEpisodeIdentityFields` reads the fields from the wire: a recipe ID outside `[2^52, 2^53 − 1]` or a blank key (or one over 2,048 characters) counts as unknown. Legacy episode IDs stay opaque strings. Listening (Room v40): `ListeningHistorySyncDto.firstPlayedAt` and `finishedAt` (null unknown, 0 before recording, else epoch ms); `SyncPushRequest.listeningDays` (`ListeningDaySyncDto`, one installation's listening of one episode on one local day, under its listening device ID; all zero when that installation no longer holds the day) and `playedShows` (`PlayedShowSyncDto`), left out when empty; `SyncPushResponse.listeningAccepted` (only a server that stores listening sets it); each `ListeningDaySyncDto` also carries the summary's richer totals (Room v41: `longestSessionMs`, `speakerMs`, `headphonesMs`, `bluetoothMs`, `carMs`, `castMs`, `loreMs`, `smartQueueMs`, `newReleaseMs`; 0 not known) and, since Room v42, `releaseKnownMs` (the time of episodes with a known release date; 0 not known), `SyncPushRequest.listeningTotals` says the push's days carry them (0 included) and `SyncPushRequest.listeningReleaseKnown` that they carry `releaseKnownMs`, and `SyncPushResponse.listeningTotals` / `SyncPullResponse.listeningTotals` say the server stores them (false from an older server), and `listeningReleaseKnown` on both that it stores `releaseKnownMs`; `SyncPullRequest.listening` and `listeningSince` (nullable, so 0 is sent; the server falls back to `since`); `SyncPullResponse.listeningDays` and `playedShows`, null from an older server. Onboarding curriculum / genre-synth / similar-shows requests accept optional `languages` (chip codes; the API defaults them from the country). `BecauseYouLikeRequest` also carries optional `languages` (omitted when null), so English off reaches the because-you-like route. - Next-turn onboarding requests may opt into `guided_v1`. `GuidedOnboardingTurn` adds separate response/question text, choices, action IDs and an explicit kind/intent alongside the legacy message/options/search fields. `OnboardingNextTurnRequest` also carries optional `features` (the app sends `finish_hint`), `country` and `languages`, all omitted when null; `GuidedOnboardingTurn.finish` (`hidden` / `available` / `prominent`) is decoded when the API sends it. Its absence remains compatible with older responses; synthesis requests do not implicitly opt in. Response interpretation belongs to onboarding rather than the HTTP layer. - `getOnboardingProbeDeck` (`POST onboarding/probe-deck`) returns `OnboardingProbeDeckResponse` (2-4 `OnboardingProbeCard`s or none; preview fields optional). `OnboardingNextTurnRequest.probeReactions` (`OnboardingProbeReaction`: id, title, `listen` / `like` / `dislike` / `unknown`) is omitted when null and is sent with next-turn, synthesize and probe-deck requests. `OnboardingProbeCard.toPodcast()` maps a card to the app's podcast model. - Onboarding synthesis, curriculum, genre and similar-show DTOs accept optional `recommendationFormat`. The updated flow explicitly sends `discovery_v2` to permit up to six recommendation rows; defaults omit the field, preserving older request behavior. Curriculum decoding preserves the API's row and show order and forwards opaque search queries unchanged. No response/storage migration is required. @@ -81,7 +73,7 @@ src/main/java/cx/aswin/boxlore/core/network/ ## Persistence & identity -- `BootstrapRequest.include` lists the reply parts the app uses (`INCLUDE_BRIEFING`, `INCLUDE_TRENDING`, `INCLUDE_CURATED_VIBES`, `INCLUDE_RECOMMENDATIONS`). It is omitted when null, which older Workers and app versions treat as every part. +- `BootstrapRequest.include` lists the reply parts the app uses (`INCLUDE_BRIEFING`, `INCLUDE_TRENDING`, `INCLUDE_CURATED_VIBES`, `INCLUDE_RECOMMENDATIONS`). It is omitted when null, which the API treats as every part. - No user data is persisted by this module. - API session state lives in the DataStore file `boxlore_api_session` (keys `session_token`, `install_id`, `clock_offset_seconds`, `app_check_failures`, `next_app_check_at_ms`). The install id is random (16 bytes, base64url) and separate from the analytics and sync device ids. The device key is the Keystore alias `boxlore_api_session_device_key`. Keep both names stable: renaming signs every install out of its session. The app's backup rules exclude the file, because a restored token could never renew without this phone's Keystore key. - Base URL, public key, app version, App Check tokens, and device identifiers are supplied at runtime by app or repository wiring. @@ -93,9 +85,7 @@ src/main/java/cx/aswin/boxlore/core/network/ - Unit tests live under `core/network/src/test`. - `BoxLoreApiContractTest` uses MockWebServer fixtures for endpoint contracts. - Prefer MockWebServer over live backend calls. -- `session/ApiSessionInterceptorTest` (MockWebServer): a stored session goes out at once while an exchange hangs and no request carries `X-App-Key`; one exchange serves concurrent first requests; `X-Session-Renew` is stored and foreign or older renewals are ignored; the proof appears only from 30 s before `exp`; a missing device key sends no proof and reports it once; a refused session is replaced once; a brand-new session refused again backs off; failed exchanges back off without an app-key fallback; App Check failure gives a limited session and the next launch skips App Check; the step-up retries with a `challenge` session, a failed step-up returns the 401 and cools down, and it stays off until enabled; `daily_ai_limit` passes through; other hosts get no session. -- `session/ApiSessionSchedulerTest` (virtual time): limited → full upgrade on the 1 min / hourly / daily schedule, a slow App Check that times out at launch but passes on the background upgrade, nothing in the background or offline, an overdue retry when the network returns, and the pure next-step plan. `ApiSessionPolicyTest` covers token parsing, proof timing, usability, replacement and backoffs. `OkHttpSessionExchangerTest` covers the `/auth/session` body, headers and failure reasons. `FirebaseAuthAuthenticatorTest` checks shield 401s are left alone. -- `session/RequestProofTest`: the signed message and its clock, no proof without a device key or a readable session, the interceptor signing only requests that carry a session, and the account token added when signed in (kept when a request has its own, none when signed out). +- `session/` tests (`ApiSessionInterceptorTest`, `ApiSessionSchedulerTest`, `ApiSessionPolicyTest`, `RequestProofTest`, `OkHttpSessionExchangerTest`) cover the session lifecycle with MockWebServer and virtual time; `FirebaseAuthAuthenticatorTest` checks the authenticator leaves the API's own 401s alone. - `BoxLoreApiV2ContractTest` (MockWebServer) checks every `/v2` path (also under a base URL with a path prefix) and that no request carries `X-App-Key`; the exact request JSON of shows/feeds, feed reports, registration, recommendations (with `contractVersion: 2`), bootstrap (with and without its current-algorithm recommendations part), similar episodes (a recipe ID `id`, `limit` left at its default is not sent) and because-you-like (with and without `excludePodcastId` and `country`) (defaults are not encoded, so only explicitly set fields go out); decoding of show addresses (including `fetchUrl: null` and proxied shows), that a proxied show is read with `getFeed` on the base URL and never at the server's `transportUrl`, `needsFeed` with and without an address, a known episode with its identity, the fallback list, a 404 and a 304; that feed documents never enter the HTTP cache; and briefing URLs. `model/V2ModelsTest` covers the range checks (including the episode-route show limit and `/v2/episodes` paging bounds), strict show ID parsing, request validation (including the 2,048-character catalog key) and read targets. - `BoxLoreApiSyncContractTest` (MockWebServer) checks the push and pull JSON: each row's `recipeId` and `catalogKey` are sent only when set, the rich removals are sent (an empty list too) and left out only when unset, a pull sends `richRemovals: true` only when asked and sends nothing for the defaults, and a pull answer recorded in the sync API's shape decodes (recipe fields as numbers or null, the rich removals present or absent), plus the range checks of `SyncEpisodeIdentityFields`. - `OnboardingGuidedContractTest` checks legacy decoding, request-specific opt-in and structured recovery action decoding, including unknown additive fields, and the finish-hint opt-in (features, country, languages omitted by default; `finish` decoded), plus show deck cards with and without a preview and opt-in reactions. diff --git a/core/playback/README.md b/core/playback/README.md index db09d3ef8..f38c6130b 100644 --- a/core/playback/README.md +++ b/core/playback/README.md @@ -180,7 +180,8 @@ Media-session bitmap loading classifies transient transport failures separately - `ListeningSessionDetailsTest` covers the play source, release dates, listening time at the playback speed, the saved session's details, the output route order and the progress save's dates. - `HistoryBeyondScreenLimitTest` covers the library export and the insight counts reaching history rows older than the latest 300 (regression). - `SwitchedShowPlaybackWritesTest` covers a show switched to its feed: a seek and a like on the feed ID keep the owner's row under the Podcast Index show with its own metadata, a missing owner row is created under the owner's show, and listening insights count the old show's listening for the feed show. -- `AutoEpisoIdleAliasRSesolutsionHandoffTest` covers Athe idle cloud handoff decision `PlaybackRepository.updateIdlePlaybackSession` takes through `IdleSessionHand Aoff.shouto items sldHandOvedr`: ua ndewer asession old ID:f the saved browsme epitem plays thode owunder's history with its own mediar ID (or andy cof its IDs) leache key, a rvesumption target lines up with the quidleued coplay under as it is, another IDepisode takes it ornly kwheepsn its IDsession is newier than the player's episode's hiprojected lastor play, and an empty plambiguouyer takes ait. `EpisodeAliasQueueTest` also covers that a removal, a clear and a replace record every ID of an episode that leaves nothinge queue. +- `IdleSessionHandoffTest` covers the idle cloud handoff decision `PlaybackRepository.updateIdlePlaybackSession` takes through `IdleSessionHandoff.shouldHandOver`: a newer session of the same episode under its owner ID (or any of its IDs) leaves the idle player as it is, another episode takes it only when its session is newer than the player's episode's projected last play, and an empty player takes it. `EpisodeAliasQueueTest` also covers that a removal, a clear and a replace record every ID of an episode that leaves the queue. +- `service/auto/AutoEpisodeAliasResolutionTest` covers Android Auto items under an old ID: a saved browse item plays the episode's history under its owner, a resumption target lines up with the queued copy of its episode and keeps its own ID with the episode's history, and an ID with no alias resolves only its own rows. - `EpisodeAliasPlaybackTest` covers an episode under two IDs: offline playback when the owner and the download holder differ (both IDs play the download's key, progress and completion land on the owner), the like button and mark played reaching both IDs, lists counting it once, and queue duplicates and removals across IDs with rich removals; plus an episode with one ID keeping its own key, row and writes. `EpisodeAliasReadsTest` covers an item starting under a non-owner ID resuming from the owner's position and the next progress tick keeping it, restores that keep a completion made under another ID and never add a row beside it, a seek from another ID's page keeping the owner's like, and lookups by any ID. `EpisodeAliasHistoryEditsTest` covers remove and Undo across two IDs restoring every row, session, daily total and the group settings unchanged, clearing history clearing the settings, bulk mark played and unplayed (one ID as before, two IDs reaching both) and the Smart Queue sources. `EpisodeAliasQueueTest` covers removals keeping the playing ID, tombstones only for queued IDs, Undo clearing every ID's removals, a pulled queue state pruning rich removals, and the download holder's cache key through `playQueue`, `addToQueue`, `addToQueueNext`, `addEpisodesAfterCurrent`, `PlaybackTransportHelper.resume` and `AutoMediaResolver`. `AutoEpisodeAliasActionsTest` covers Android Auto's like and mark played buttons for one and two IDs. `SmartQueueRefillCoordinatorTest` covers refill never queueing the same episode twice and refill cache keys equal to `EpisodeMediaCacheKey.of`, including a pick whose download another ID holds. `QueueRepositoryTest` and `QueueAliasPolicyTest` cover queue rows and player indices across IDs. - `QueueEntryShowTest` covers an episode from another show added with Play next or Add to queue, a show-less list from one show's page with such an episode in between (also after a restart and a reload from the queue), an episode with no show at all from a page that couldn't place it, a mixed Downloads list started under its placeholder, a Smart Queue pick rebuilt from its media item or reaching the player with no entry at all, an Android Auto item rebuilt from the player, a transition the player moved past during its lookups, and the session restored after a restart, with and without a history row: each keeps its own show once the queue advances, instead of the previous episode's. `QueueShowPolicyTest` covers another show's ID, the blank ID Room saves for a missing show, the "unknown" placeholder, "Unknown Podcast" for an entry with no show, the start show, and show fields never taken from another show. `PlaybackQueueCoordinatorTest` covers a new queue tagging show-less episodes and a reload relabelling nothing; `CastPlaybackPolicyTest` covers `CastQueueSnapshotPolicy` resets and candidate changes. - Service-level tests must install shared dependency holders before exercising service code. diff --git a/core/support/README.md b/core/support/README.md index 6912bad54..415ddf308 100644 --- a/core/support/README.md +++ b/core/support/README.md @@ -6,7 +6,7 @@ Listener feedback as support conversations with the boxlore API (`/support/...`) server allows (switches, limits, the push topics), sending a new message, and uploading photos. Wording, statuses and limits come from the server, so they change without an app update. -It does not own any UI (the screens live in `:feature:settings`) and does not talk to Telegram or +It does not own any UI (the screens live in `:feature:settings`) and does not answer messages or send pushes; that is the API's job. ## Public API @@ -54,16 +54,14 @@ Features use it; it never depends on a feature, the design system or the catalog ## Persistence & identity -- Nothing stored on the phone. The API knows the install from the API session the shared client - attaches (`X-Boxlore-Session`) together with the device key that session registered: every - support request carries `X-Request-Proof`, the device key's signature over the session, method - and path (`RequestProofInterceptor` in `:core:network`, added by `NetworkModule.createSupportApi`). - The app never sends an id of its own. Without a device key or proof the API answers 401 and the - message goes through the old form. +- Messages live on the server; the phone keeps only what `SupportLocalStore` remembers between + launches. The API knows the install from its API session and the support client's request proof + (see `:core:network`); the app never sends an id of its own. When the API refuses a request for + a missing proof, the message goes through the old form. - `OutgoingFeedback.requestId` is the retry key: the screen sends the same key when the same message is sent again, so a send whose answer was lost isn't saved twice. -- Fallback: when support isn't there but the old `POST /feedback` form can still deliver (404, 503 - `support_unavailable`, or 401 `device_key_required` / `proof_required`), the message goes through it +- Fallback: when support isn't there but the old `POST /feedback` form can still deliver (not found, + support unavailable, or a refused proof), the message goes through it (`legacySubmit`). The old form needs the API session too, so a 401 for a missing or refused session is a failure: the screen keeps the draft to send later. The old form knows only feature, bug and other (audio goes as bug, anything newer as other) and has no photos. A refresh that finds support diff --git a/docs/ANNOUNCEMENT_COMPOSER.md b/docs/ANNOUNCEMENT_COMPOSER.md index 6bbc844cc..e2dc7942d 100644 --- a/docs/ANNOUNCEMENT_COMPOSER.md +++ b/docs/ANNOUNCEMENT_COMPOSER.md @@ -2,7 +2,7 @@ ## Purpose -The static composer creates legacy-compatible FCM announcement drafts, previews the native app's visual design, and requires explicit review before a workflow request. The existing Google/password/TOTP gateway and its encrypted configuration remain local and unchanged. The composer never stores or reads sender credentials beyond the gateway's existing in-memory session. +The static composer creates legacy-compatible FCM announcement drafts, previews the native app's visual design, and requires explicit review before a workflow request. The admin gateway and its configuration stay local and unchanged. The composer never stores or reads sender credentials beyond the gateway's existing in-memory session. ## Behavior diff --git a/docs/TESTING.md b/docs/TESTING.md index 9eb75ea71..7e74f489a 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -246,7 +246,7 @@ See [`docs/screenshots/README.md`](screenshots/README.md). **Merge gate:** master uses a branch ruleset with no merge queue. The required check is **`testDebugUnitTest`**, and the ruleset requires every review conversation to be resolved before merging (GitHub's conversation resolution, which covers CodeRabbit's threads, outdated ones included). SonarCloud, CodeRabbit, and Gitleaks also run on PRs. Fix all Sonar new-code issues and address every CodeRabbit finding, marking every review thread Resolved; the bare CodeRabbit status only confirms that the review completed. -The unit suite cancels prior in-progress runs on each PR push. `[skip unit]` in the PR title no-ops the suite only for docs/chore changes with no logic risk, and a PR that changes only documentation (Markdown outside source sets, `docs/`, `screenshots/`, the license) skips it on its own; both still report green. **Release copy** applies the impact labels the PR body's checkboxes pick, then checks the developer copy and the listener notes against the labels as they are now, in one job. Actions → Run workflow always runs the full suite. If the review decision is `CHANGES_REQUESTED`, stop automated merging and have a maintainer handle the review or merge manually. Otherwise squash-merge after the required checks are green and review requirements are satisfied. A maintainer may explicitly authorize bypassing required status checks for a specific PR; report remaining checks and known risks first. General merge authorization does not authorize a bypass, and review requirements still apply. Repository-administration procedures belong in root `AGENTS.internal.md`. +The unit suite cancels prior in-progress runs on each PR push. `[skip unit]` in the PR title no-ops the suite only for docs/chore changes with no logic risk, and a PR that changes only documentation (Markdown outside source sets, `docs/`, `screenshots/`, the license) skips it on its own; both still report green. **Release copy** applies the impact labels the PR body's checkboxes pick, then checks the developer copy and the listener notes against the labels as they are now, in one job. Actions → Run workflow always runs the full suite. If the review decision is `CHANGES_REQUESTED`, stop automated merging and have a maintainer handle the review or merge manually. Otherwise squash-merge after the required checks are green and review requirements are satisfied. A maintainer may explicitly authorize bypassing required status checks for a specific PR; report remaining checks and known risks first. General merge authorization does not authorize a bypass, and review requirements still apply. Protected inputs: `app/google-services.json` is gitignored; CI writes a non-secret stub. diff --git a/docs/recommendation-system.md b/docs/recommendation-system.md index 409c611ab..731775fd2 100644 --- a/docs/recommendation-system.md +++ b/docs/recommendation-system.md @@ -426,7 +426,7 @@ The API is documented **by path and payload shape only**. How it retrieves or ra | `GET /search/semantic` | Explore natural-language search (`country` + optional `languages`; oversample + `startsWith`; conditional ≤2 eps/show) | | `GET /trending` | Charts / Discover / queue tiers | -**Auth (client view):** app key on requests; optional App Check JWT when enforced; device UUID scopes per-device caches; app version for analytics slicing. +**Auth (client view):** requests carry an API session (see `core/network/README.md`); device UUID scopes per-device caches; app version for analytics slicing. ## Recommendations v2 vs legacy v1 @@ -513,7 +513,7 @@ Negative rewards + SHOW facet drop → future Home/Queue priors and learned scor ### E — Explore semantic search -API oversamples Qdrant (no exact `language` match), keeps hits whose language starts with +The API keeps hits whose language starts with the resolved prefixes (so `en-us` matches `en`), applies a per-show episode cap only when ≥3 distinct shows are in the pool, and returns relevance-ordered hits → client lightly re-ranks inside tie windows with `DISCOVERY`. diff --git a/feature/briefing/README.md b/feature/briefing/README.md index fe3c30065..f67493579 100644 --- a/feature/briefing/README.md +++ b/feature/briefing/README.md @@ -23,7 +23,7 @@ Owns the Daily Briefing presentation flow: region-aware briefing content, story - `BriefingIdentity` contains pure identity helpers for tests and UI mapping. - When a briefing answer carries no chapters or transcript link, `BriefingViewModel` builds it with `:core:catalog` `BriefingLinks`: chapters on `/v2/briefings/chapters/` while v2 discovery is on (related episodes come back under the IDs this phone holds), transcript on `/briefings/transcript/`. The answer's own signed links are always preferred. No layout or state changes, so phone, tablet, foldable and resized windows behave as before. - Episode id stays `briefing_{region}_{date}` for session continuity; Media3 audio cache keys include the signed URL `v=` hash (see `EpisodeMediaCacheKey`) so same-day regenerations do not replay stale bytes. -- Briefing markets: `us`, `in`, `gb`, `global` (ISO `gb` for the UK; legacy API path `/uk` still aliases to `gb` on the Worker). +- Briefing markets: `us`, `in`, `gb`, `global` (ISO `gb` for the UK). - `BriefingPlaybackLogic` resolves whether a briefing interaction pauses, resumes, starts, or seeks; choosing another story seeks directly instead of pausing. - `BriefingStoriesPager` and `BriefingStoryComponents` provide extracted story-presentation pieces. - Story cards share one height: the pager measures every card at the page width and current text size and takes the tallest, capped at 70% of the screen height so a card stays a comfortable read. Stories longer than the cap scroll inside their text area, and the last line fades while more text is below; shorter stories keep related episodes and Play pinned to the bottom. In a window shorter than 480dp (a phone on its side, a split screen) the whole card scrolls instead (`storyCardScrollsAsOne`), so the heading, story, related episodes and Play all stay reachable. Measured copies are never placed and are hidden from accessibility. Story text uses body-large type in the full on-surface colour. diff --git a/feature/explore/README.md b/feature/explore/README.md index 2f75343be..f6f1f9206 100644 --- a/feature/explore/README.md +++ b/feature/explore/README.md @@ -21,7 +21,7 @@ Owns Explore discovery and the Learn tab presentation: browse/search discovery, - For You and Top browse use Home's shared `ExpressiveFeatured` / `ExpressivePoster` discovery cards, with title-only three-line feet and matching font-scaled skeletons. Curated topic destinations use the same poster cards without genre overlays or artist subtitles. Find-a-show results use the same expressive poster cards, including Matches and Also found sections; generic “Podcast” genre chips are hidden in both sections, while specific genres and video badges retain their existing conditions. Ask-anything retains its compact 104dp Related shows rail and eight-show cap, while episode results use one expressive featured card followed by title-only posters with solid duration badges. The first result, source order, callbacks, impression/tap analytics, ranking, deduplication and pagination remain unchanged. - Explore has no app-logo header. Search and selectors share a rounded tonal backing at rest. Consumed vertical feed scroll progressively clips/fades the selectors and backing, leaving the search pill floating over the feed; the For You/Top switcher stays visible above playback/navigation. Horizontal chip viewports meet the rounded backing edge, with search inset separately so chips do not disappear at an inner rectangular boundary. Upward scroll reveals controls, and reaching the top or changing browse/search mode expands them. The grid reserves the measured expanded header inset rather than changing padding on every scroll frame. Search/mood-result controls remain expanded. - For You mood row (`ExploreVibeChipRow`) shares the collapsing header slot with genres (matched 8dp spacing). Connected tonal topic buttons use 2dp gaps, curved outer ends, tight inner corners and larger standalone primary icons. Existing mood titles can wrap to two lines with font-scaled height; they navigate rather than remain selected. Titles come from shared `CuratedMoods` (same as Home daypart rails). Topic destinations have separate floating Back to Explore and small icon-only Search pills without a shared backing and a scrollable, font-scaled topic heading/subtitle with a native expressive shape. Matching poster skeletons replace the large blocking loader; an empty result offers Back to Explore without inventing a retry or error cause. A topic uses its own grid position, starts at its heading, and returns to the saved browse position through either toolbar or system Back; predictive Back slides the topic with gesture progress over the real saved Explore pane, cancellation restores it, and toolbar/system Back finish the same 350ms slide before leaving. Repeated Back is consumed during completion; parent navigation resumes after the topic closes. Preview panes retain scroll state but suppress interaction, accessibility actions, pagination and selection callbacks. Topic rendering is independent of the last search-tab choice. -- Explore search chips: **Find a show** (search icon; Meili typeahead + hybrid **Also found**, 300ms debounce) and **Ask anything** (sparkle icon; one CF embed → podcast + episode vectors via `GET /search/semantic`, 1000ms debounce before embed; loader while waiting). Find-a-show pins the grid to the top via shared `ProgressiveSearchScrollLogic` when progressive catalog hits prepend over local substring matches or a Matches header appears; Ask anything is unchanged (one complete result set behind a loader). Search-field placeholder follows the selected chip. Example queries were checked live against `/search/semantic`. Ask-anything results: **Related shows** rail + **Episodes** header above the hero/bento feed. Discovery rails and staggered grid sections protect items against duplicate key collisions using `LazyListKeyPolicy`. +- Explore search chips: **Find a show** (search icon; typeahead + hybrid **Also found**, 300ms debounce) and **Ask anything** (sparkle icon; podcasts and episodes by meaning via `GET /search/semantic`, 1000ms debounce before embed; loader while waiting). Find-a-show pins the grid to the top via shared `ProgressiveSearchScrollLogic` when progressive catalog hits prepend over local substring matches or a Matches header appears; Ask anything is unchanged (one complete result set behind a loader). Search-field placeholder follows the selected chip. Example queries were checked live against `/search/semantic`. Ask-anything results: **Related shows** rail + **Episodes** header above the hero/bento feed. Discovery rails and staggered grid sections protect items against duplicate key collisions using `LazyListKeyPolicy`. - Search landing uses the shared fixed tonal mode capsule, centered text-only headings and compact suggestions. The collections heading is **Explore collections**, without a subtitle. Other titles and hints share a center axis; hints have a narrower reading width and wrap with system text size, without an offset decorative icon. Find-a-show topics use solid low-container cards with one primary icon accent and a font-scaled two-line title slot; all topics share the same accent hierarchy. With no topics, the landing still explains how to search. Ask-anything keeps its four original query strings and order in connected tonal rows with distinct subject icons, 64dp minimum targets and expanding text geometry. Idle/recovery headings and hints are resources; empty browsing and empty searching give guidance for their respective controls. Search loading uses a compact labelled, politely announced status rather than a large blank loader panel. Query dispatch, debounce, ranking and pagination are unchanged by the presentation work. - `LearnScreen` and `LearnViewModel` for the Learn route (curiosity cards, queue/play actions). Lore shares a ViewModel-owned artwork colour cache between the active card, stacked cards and page: the next four artwork keys are prepared with at most two loads at once, so promoting a prepared card does not restart extraction. The page halos and logo share a swipe-linked colour clock: dragging blends toward the next prepared card, cancellation reverses that blend, and promotion retains the destination without an old-colour flash. Palette endpoints stay frozen through an interrupted gesture; button changes and late palettes ease over 300ms. Cards retain their own artwork colours. Pointer updates are synchronous, disposed motions cannot apply actions, and exits keep their endpoint until deck replacement. Empty episode artwork falls back to a nonblank show image, including history and episode mappings. - Lore cards keep the complete question, explanation and podcast title, without line limits or a scrolling reading area. Actual font measurement reclaims spacing before reducing headings from 28sp to a 24sp floor; body text stays 16sp/24sp and system font scaling remains active. Narrow/short windows use wider cards and smaller outer header gaps. The card's ground is a solid deep shade of its cover accent at Material tone 18 that keeps the hue and much of its chroma (`loreCardColor`, via designsystem `artworkDeepShade`), so cards read as their show's colour rather than black, with two soft accent glows; button segments use the same colour at tone 28 (`loreSegmentColor`); the cover is no longer blurred behind a black scrim, which also keeps four-card swipes cheap to draw. Both control rows are connected Material 3 button groups (round outer ends, 8dp inner joins, 3dp gaps, 52dp tall; 48dp on narrow/short windows). The top group shows where the curiosity comes from: a wide segment with the show cover (concentric corners) and show and episode names, which opens the podcast, joined to a square **Details** segment. The bottom group follows the swipe directions: an icon-only **Skip** end, Play/Pause filling the rest of the bar (filled with the cover accent, black or white ink, whichever contrasts more: `loreOnAccent`, short Play/Resume/Pause label), then an icon-only **Add to queue** end. Every segment announces its full description (Skip, Play episode, Add to queue). Together the two rows take no more height than the old top actions and footer. The action bar is taller than the source row (60dp; 52dp on narrow windows) and sits 10dp (6dp) higher when the full text has room to spare; when the text needs that room the bar stays at the bottom (`LoreCardTextWithFooter`, decided in one layout pass from the text's measured height). Loading uses designsystem's shared Material 3 circular wavy indicator in the Play segment, while the loading label and disabled action remain intact. `LoreCardColorsTest` checks the Play glyph contrast and the dark ground on any accent. Touching the reading surface no longer scales the whole card: only a deliberate horizontal drag moves it. Controls use a small 3% press response over 120ms without a spring overshoot, including when a swipe cancels a press. diff --git a/feature/home/README.md b/feature/home/README.md index 457f1fa12..3a84e3370 100644 --- a/feature/home/README.md +++ b/feature/home/README.md @@ -32,7 +32,7 @@ Owns Home feed presentation, Daily and Offline mixes, and local debug surfaces f - **Duotone** cards (`DuotoneHeroCards.kt`) redraw the whole cover in two tones of one colour (`ArtworkDuotone` from `:core:designsystem`, a colour matrix on the image) and put the Cover copy and actions straight on it: the context line at the top, then the title, show and progress at the bottom, with Play / details in the shade's button colours. The tones follow the same choice as Cover's shade (`duotoneTones`): Dark is black and white, App theme uses the theme's primary and Artwork the cover's seed (black and white for covers with no meaningful colour); they ease between covers on the page colours' clock (`rememberDuotoneLook`). Collection cards keep the solid cards' rows (`HeroGridCard` takes `containerColor`, `contentColor` and a per-cover `artworkColorFilter`): the card takes the first cover's shadow tone and each row's cover is redrawn in its own tones. `DuotoneHeroCardsTest` covers play and details in every colour choice, collection routing and the tone sources. - **Block** cards (`HeroCard`, `HeroGridCard`) take the same colour choice since 2026-10-17 (`BlockHeroColors.kt`): App theme draws them as before, Artwork builds a Material scheme from each cover's seed the way pod info does (`artworkColorScheme`; a collection uses its first cover; the theme for a cover with no meaningful colour), and Dark uses `BlockDarkColors`, a neutral near-black card in light and dark mode alike. The cards read only a few roles, so swapping the scheme recolours the whole card, and the colours ease in on the page colours' clock once a cover is read. Never picked, Block stays in the app theme (`HeroCoverShade.forStyle`). `BlockHeroColorsTest` covers the unpicked theme, artwork and its fallback, and Dark's near-black card with readable words, Play and Details. - **Tide** cards (`TideHeroCards.kt`) show the whole cover across the top, uncropped, and a solid panel in its deep shade (`rememberCoverShade`, shown as **Card color**) rises over its lower part on a wavy edge with the wavy progress indicator's 24dp wavelength. The panel rises only as far as the words need (`tidePanelTop`: 40dp above them, between 65% of the width and 16dp above the cover's foot), so short titles show more of the cover. A Play / Pause disc sits in an 88dp notch on the edge, ringed with Resume progress: a wavy circular indicator that moves while the episode plays, or a plain round one when the listener picks **Circle** under **Progress ring** (`LocalHeroTideProgress`, provided by `HomeScreen` and the Appearance previews; same size, 4dp stroke and colours); the words are Cover's context line (kept clear of the disc), title and show name, and tapping the card opens the episode. `TideCardLayout` places the layers and is shared with the loading card. Collection cards fill the top with the covers (four as a 2×2 square with Resume progress and time left, or a new episode's length, on each; two or three in one row up to 34% of the card's height) under the same wavy panel in the first cover's shade, with the heading and the collection's size and total time ("4 episodes · 1h 53m left"). Each cover opens its episode and plays or pauses from the button in its corner; with two or three, the panel also lists each episode with its progress and time, and each row opens it. `TideHeroCardsTest` covers playing, pausing, progress and details in every colour choice, the disc clear of the words, collection routing and totals at two, three and four episodes, and where the panel's edge sits. -- **Marquee** (`MarqueeHeroCards.kt`, with its collection card in `MarqueeCollectionCard.kt` and the top bar's hand-offs, frost and page dots in `MarqueeChrome.kt`) replaces the carousel with one full-width card at a time, from the top of the screen under Home's top bar down to where Your shows' sheet begins with rounded corners (24dp over the hero's foot). With Marquee, `HomeScreen` lets the feed start at the top and floats the top bar over it. Home's body slides up over a still cover: the hero reads how far it has scrolled away (`LocalHeroScrollOffset`, while drawing) and moves its content down by as much, inside its own bounds, so only the sheet's rounded edge rises (`marqueePinned`). The bar never darkens over the cover: it is clear there, with frosted glass behind it that belongs to the bar (`MarqueeBarFrost`, 2026-10-17): Home makes its feed a Haze source and the bar blurs the live page under it (the still cover, the next card sliding in, the body rising), fixed to the top of the screen at the bar's full height, so it never moves with the cards, never splits mid-swipe and never shows a cover sharp while its blur loads. It is soft (20dp blur, a 12% tint toward black under light words or white under dark ones), full behind the bar, then fades from 20dp above the bar's foot to 40dp below it: the blur and tint are drawn on a layer of their own and faded by a Compose mask on that layer (`DstIn`, an S-curve from `marqueeBarFrostStops`, smoothstep): on the user's phone Haze ignored both its own mask and its progressive blur, which left a razor-sharp line at the strip's foot, and fades away with `marqueeBarOverArtwork` as the body passes under the bar. On Home the cards skip their own frost (`LocalMarqueeCardFrost`); the Settings preview and tests keep a card-level copy (`MarqueeTopFrost`, the same blur, tint and tail over a blurred copy of the cover's top). Blur needs Android 12; older phones keep the tint. `MarqueeBarFrostTest` covers the fade's shape (full, then steadily falling, gentle at both ends, no step over a fifth), and Home's scroll stops where the body's rounded edge meets the bar (`MarqueeDetentConnection`, `marqueeDetent`): a drag or fling that starts above that point halts there, so the whole page rests with the edge under the bar and nothing scrolls beneath it, and the next swipe carries the body on under the bar (scrolling back down never stops). The bar stays clear over the cover until then and fills with the body's own colour only as the edge passes under it (`marqueeBarOverArtwork`). The sheet's edge carries a short handle line (`MarqueeSheetEdge`) in the hero, the loading card and the Settings preview. Its wordmark and status icons take the settled card's words colour (`LocalMarqueeTopTone`): dark over light covers in every colour choice (judged by the shared picker's mean colour of the cover itself, since the two tones keep its light and dark parts; a stronger veil when a cover has none), light otherwise; the status icons return to the theme's when Home leaves. The bar's expanded height reaches the hero through `LocalHeroTopInset`, and the hero row bleeds past the feed's 16dp side padding; without heroes a spacer keeps the rest of Home below the bar. The hero is 48dp shorter than the other styles' cards plus the bar's room, and meets Your shows' sheet with no strip between. The background is `MarqueeBackdrop` (reworked 2026-10-17; the square cover used to be cropped and zoomed to fill the tall card): an ambient wash of the cover itself, fetched at 96px, scaled past the card's edges and blurred (56dp on Android 12 and later; elsewhere the tiny image's upscaling keeps it soft), deepened toward the card's shade from the middle down; the cover whole and sharp across the full width from the top, melting into the wash from 58% of its height; and the top bar's frost. Each card clips to its own bounds, since its wash reaches past them: unclipped, the neighbouring card's bright wash drew a raw slice over the current card mid-swipe. Before a swipe, `MarqueeWarmColours` reads every card's palette and prefetches its wash and cover images, so a card arriving mid-swipe draws its two-tone filter, wash and shade from its first frame (it used to show its plain cover until its palette was read). Both images take `rememberMarqueeLook`'s colours (the same choice, shown as **Cover colors**: Full color keeps the cover as it is; Artwork and App theme redraw it in two vivid tones of the cover's or the accent colour, `ArtworkDuotone` with `vivid`: a tone-10 shadow and a tone-80 highlight in the colour's own hue, so the cover keeps about the brightness and contrast it has in Full color). Until 2026-10-30 Marquee used Duotone's dark tones (8 to 38) under the same wash, plus an accent glow to lift them, which left the cover murky and hid the art; Duotone keeps the dark tones because its words sit straight on the cover. The words sit on a long, 90% scrim of the card's deep shade (`MarqueeWordsScrim`, `MarqueeWordsFade`), with Cover's title, show, progress and Play / details at the bottom and page dots (a pill for the current card) between Play and the sheet's edge. Every Marquee label is one solid chip in the card's colours above the title (`MarqueeChips.kt`): "#1" in an accent pill then "in US"; "Trending in" then the genre in bold; the episode's state in bold ("Resume", "New episode") then its time left or length; and on collections the name, the chosen episode's time and a "2/4" counter in the accent. Screen readers hear each chip as one phrase ("Jump back in, 2 of 4, 12m left"). Play keeps one width on every phone (`MarqueeActionsWidth`), and the room beside it shows up to three covers of the next cards, each moving the hero there ("Go to …"); swiping also moves between cards. The hero also moves on by itself (`marqueeDwellMillis`: 6 seconds on a card, or as long as a collection's strip takes to show every episode, 5 seconds each), holding while a finger is anywhere on it (`marqueeTouches`, which watches without taking the touch) and counting again from when it lifts, and resting while it is mostly scrolled away, while Home is off screen, with TalkBack exploring by touch or with animations off (`rememberMotionAllowed`, which the strip follows too). Collection cards show the chosen episode's cover, with its one chip (name, the episode's time, "2/4") above the episode's title, show, Play and details at the bottom (one column, so nothing overlaps), above a strip of covers (88–104dp, bigger with fewer episodes) that picks the episode: while the card is on screen the strip advances every 5 seconds; dragging it holds it, and after a tap or a drag it waits 8 seconds before carrying on (nothing is remembered once the card leaves the screen). The covers keep their own colours in front of the card's shade (no dimming, no two-tone filter); the chosen one is outlined and kept in view, and Resume covers show progress along their foot. `MarqueeHeroCardsTest` covers playing, pausing, progress and details in every colour choice, full colour leaving covers unfiltered, a light cover staying at least half as bright as Full color in Artwork and App theme (the dark tones gave a fifth) with dark top bar words in every choice, the still cover under scrolling and spanning the card's width uncropped, the top bar's frost and the words colour the card reports, light and dark top bar words, the bar filling only once the body passes under it, the scroll stopping at the edge and the next swipe going through (`MarqueeDetentTest`, including flings and a finger landing mid-fling), up-next covers and page dots moving with the hero, the hero moving on by itself and when it rests, and the strip advancing, pausing after a cover is chosen and carrying on, with Play and details using the chosen episode. +- **Marquee** (`MarqueeHeroCards.kt`, with its collection card in `MarqueeCollectionCard.kt` and the top bar's hand-offs, frost and page dots in `MarqueeChrome.kt`) replaces the carousel with one full-width card at a time, from the top of the screen under Home's top bar down to where Your shows' sheet begins with rounded corners (24dp over the hero's foot). With Marquee, `HomeScreen` lets the feed start at the top and floats the top bar over it. Home's body slides up over a still cover: the hero reads how far it has scrolled away (`LocalHeroScrollOffset`, while drawing) and moves its content down by as much, inside its own bounds, so only the sheet's rounded edge rises (`marqueePinned`). The bar never darkens over the cover: it is clear there, with frosted glass behind it that belongs to the bar (`MarqueeBarFrost`, 2026-10-17): Home makes its feed a Haze source and the bar blurs the live page under it (the still cover, the next card sliding in, the body rising), fixed to the top of the screen at the bar's full height, so it never moves with the cards, never splits mid-swipe and never shows a cover sharp while its blur loads. It is soft (20dp blur, a 12% tint toward black under light words or white under dark ones), full behind the bar, then fades from 20dp above the bar's foot to 40dp below it: the blur and tint are drawn on a layer of their own and faded by a Compose mask on that layer (`DstIn`, an S-curve from `marqueeBarFrostStops`, smoothstep): on a real phone Haze ignored both its own mask and its progressive blur, which left a razor-sharp line at the strip's foot, and fades away with `marqueeBarOverArtwork` as the body passes under the bar. On Home the cards skip their own frost (`LocalMarqueeCardFrost`); the Settings preview and tests keep a card-level copy (`MarqueeTopFrost`, the same blur, tint and tail over a blurred copy of the cover's top). Blur needs Android 12; older phones keep the tint. `MarqueeBarFrostTest` covers the fade's shape (full, then steadily falling, gentle at both ends, no step over a fifth), and Home's scroll stops where the body's rounded edge meets the bar (`MarqueeDetentConnection`, `marqueeDetent`): a drag or fling that starts above that point halts there, so the whole page rests with the edge under the bar and nothing scrolls beneath it, and the next swipe carries the body on under the bar (scrolling back down never stops). The bar stays clear over the cover until then and fills with the body's own colour only as the edge passes under it (`marqueeBarOverArtwork`). The sheet's edge carries a short handle line (`MarqueeSheetEdge`) in the hero, the loading card and the Settings preview. Its wordmark and status icons take the settled card's words colour (`LocalMarqueeTopTone`): dark over light covers in every colour choice (judged by the shared picker's mean colour of the cover itself, since the two tones keep its light and dark parts; a stronger veil when a cover has none), light otherwise; the status icons return to the theme's when Home leaves. The bar's expanded height reaches the hero through `LocalHeroTopInset`, and the hero row bleeds past the feed's 16dp side padding; without heroes a spacer keeps the rest of Home below the bar. The hero is 48dp shorter than the other styles' cards plus the bar's room, and meets Your shows' sheet with no strip between. The background is `MarqueeBackdrop` (reworked 2026-10-17; the square cover used to be cropped and zoomed to fill the tall card): an ambient wash of the cover itself, fetched at 96px, scaled past the card's edges and blurred (56dp on Android 12 and later; elsewhere the tiny image's upscaling keeps it soft), deepened toward the card's shade from the middle down; the cover whole and sharp across the full width from the top, melting into the wash from 58% of its height; and the top bar's frost. Each card clips to its own bounds, since its wash reaches past them: unclipped, the neighbouring card's bright wash drew a raw slice over the current card mid-swipe. Before a swipe, `MarqueeWarmColours` reads every card's palette and prefetches its wash and cover images, so a card arriving mid-swipe draws its two-tone filter, wash and shade from its first frame (it used to show its plain cover until its palette was read). Both images take `rememberMarqueeLook`'s colours (the same choice, shown as **Cover colors**: Full color keeps the cover as it is; Artwork and App theme redraw it in two vivid tones of the cover's or the accent colour, `ArtworkDuotone` with `vivid`: a tone-10 shadow and a tone-80 highlight in the colour's own hue, so the cover keeps about the brightness and contrast it has in Full color). Until 2026-10-30 Marquee used Duotone's dark tones (8 to 38) under the same wash, plus an accent glow to lift them, which left the cover murky and hid the art; Duotone keeps the dark tones because its words sit straight on the cover. The words sit on a long, 90% scrim of the card's deep shade (`MarqueeWordsScrim`, `MarqueeWordsFade`), with Cover's title, show, progress and Play / details at the bottom and page dots (a pill for the current card) between Play and the sheet's edge. Every Marquee label is one solid chip in the card's colours above the title (`MarqueeChips.kt`): "#1" in an accent pill then "in US"; "Trending in" then the genre in bold; the episode's state in bold ("Resume", "New episode") then its time left or length; and on collections the name, the chosen episode's time and a "2/4" counter in the accent. Screen readers hear each chip as one phrase ("Jump back in, 2 of 4, 12m left"). Play keeps one width on every phone (`MarqueeActionsWidth`), and the room beside it shows up to three covers of the next cards, each moving the hero there ("Go to …"); swiping also moves between cards. The hero also moves on by itself (`marqueeDwellMillis`: 6 seconds on a card, or as long as a collection's strip takes to show every episode, 5 seconds each), holding while a finger is anywhere on it (`marqueeTouches`, which watches without taking the touch) and counting again from when it lifts, and resting while it is mostly scrolled away, while Home is off screen, with TalkBack exploring by touch or with animations off (`rememberMotionAllowed`, which the strip follows too). Collection cards show the chosen episode's cover, with its one chip (name, the episode's time, "2/4") above the episode's title, show, Play and details at the bottom (one column, so nothing overlaps), above a strip of covers (88–104dp, bigger with fewer episodes) that picks the episode: while the card is on screen the strip advances every 5 seconds; dragging it holds it, and after a tap or a drag it waits 8 seconds before carrying on (nothing is remembered once the card leaves the screen). The covers keep their own colours in front of the card's shade (no dimming, no two-tone filter); the chosen one is outlined and kept in view, and Resume covers show progress along their foot. `MarqueeHeroCardsTest` covers playing, pausing, progress and details in every colour choice, full colour leaving covers unfiltered, a light cover staying at least half as bright as Full color in Artwork and App theme (the dark tones gave a fifth) with dark top bar words in every choice, the still cover under scrolling and spanning the card's width uncropped, the top bar's frost and the words colour the card reports, light and dark top bar words, the bar filling only once the body passes under it, the scroll stopping at the edge and the next swipe going through (`MarqueeDetentTest`, including flings and a finger landing mid-fling), up-next covers and page dots moving with the hero, the hero moving on by itself and when it rests, and the strip advancing, pausing after a cover is chosen and carrying on, with Play and details using the chosen episode. - **Halo** cards (`HaloHeroCards.kt`) cut the cover into a nine-sided cookie in the top end corner of a card tinted from it (`rememberCoverShade`, so they follow the same colour choice, shown as **Card color**), inside a ring that is the cookie's edge pushed outward (`HaloRing.kt`). `haloGeometry` sizes the cookie at three quarters of the card's width, capped so the ring ends by 55% of the height, and lets it run past the top and end edges; Resume progress fills the part of the ring the card shows, from the top edge round the start side to the end edge; it is drawn from just past the top edge and full progress runs just past the end edge, so its rounded ends never show however the lobes have turned. While the card's episode plays, the cookie and ring turn together like the compact player (`rememberHaloRotation`: one lobe every 2.4 seconds at up to 30fps, read while drawing, paused off screen), and the image stays upright. The solid cards' context block sits in the room beside the ring (across the top, with the cover below it, at text sizes over 130%), and Cover's title, show name and Play / details pair sit at the bottom. Right-to-left layouts mirror the cover and ring. Collection cards take the first cover's shade, with the shared heading (`HeroCollectionHeading`); each episode's cookie cover sits in its own ring, filled clockwise from the top with Resume progress in that cover's accent. Tapping the cover plays or pauses it (a small button on it shows which, drawn whole over the ring; only the ripple stays inside it); the rest of the tile opens the episode. Four make a 2×2 grid with the title and time below each cover; two or three become rows with larger covers and the title beside them. `HaloHeroCardsTest` covers play, progress and details in every colour choice, collection routing at two, three and four episodes, the collection play button's corner over the ring, right-to-left placement, the ring geometry across phone widths, text sizes and turns, and the turning pace. - Fresh Drop / New Episodes hero candidates are sorted by their selected episode’s publication date, newest first, before the solo/grid split and four-item grid limit. Equal dates use case-insensitive show names, then stable show IDs; missing or nonpositive dates come last. Oldest-first shows retain their resolved next episode. The existing one/two/many-candidate layouts and Resume / Spotlight ordering are preserved. - Collection heroes show a stationary spine label: **Jump back in** or **New episodes**, with medium-weight 22sp typography, 0.3sp letter spacing and a small episode count near the top of the peek. As the masked card begins expanding, the label and peek count fade out together; the original fixed header, count and episode rows fade in together. No heading rotates or travels during a swipe. The fades follow mask progress and reverse with the gesture, with only one accessible heading and no actions exposed by hidden rows. Visible peeks use a 40dp minimum instead of Material's smaller off-screen anchor, retaining readable typography at phone density. RTL, enlarged text and narrow startup masks remain supported. Card dimensions, ordering, playback/details callbacks and carousel telemetry remain unchanged. `HomeHeroPeekTest` checks native heading geometry at standard and phone density, stationary placement, count clearance and unique accessible counts, reverse swipes, narrow startup masks, large text, RTL and independent actions; `HeroPeekMotionTest` covers fade boundaries and unavailable geometry. diff --git a/feature/info/README.md b/feature/info/README.md index abd7c88df..7f6d18f61 100644 --- a/feature/info/README.md +++ b/feature/info/README.md @@ -23,7 +23,7 @@ Disclosure recognizes canonical RSS IDs even with stale source metadata and uses - Podcast Info retains the centered cover/title/publisher, horizontally scrolling metadata, About disclosure and existing episode-toolbar position. Subscribe, its independent notification/auto-download toggles, Sort and Search form one native Material 3 connected row with uniform 3dp gaps, 48dp targets, consistent neutral surfaces, pressed shapes and rounded outer ends. Subscribe animates its own pressed shape (`subscribeButtonShape`) rather than Material's built-in morph, which could leave it at the pressed shape after the tap that changed the subscription; at rest it is always its own shape. It takes the pressed shape only while the button reports a press and a finger is still on it (`subscribePressTarget`, `trackFingerDown`), so a press whose end never reaches the button can't hold the pressed shape; `SubscribePressShapeTest` covers held presses that subscribe and unsubscribe, a press cut off mid-way, and a press that never reports its end. The existing order and callbacks are preserved; only notification and auto-download settings have checked states. Subscription confirmation retains the podcast's genre icon (or the original heart fallback), using a coordinated small tilt and scale spring with an 800ms hold before crossfading to Subscribed. A shared critically damped spring controls the settings slot and the subscription button width, with staggered bell/download fades; Search and Sort stay anchored. Unsubscribing reverses from the current animation position without snapping. Partially revealed or exiting settings are excluded from interaction and accessibility until fully visible, and system-disabled animations skip the confirmation hold. The subscription label stays on one line and ellipsizes at large text sizes, with its full action available to accessibility. Blocked notifications keep the error-container bell and badge. Metadata uses compact neutral pills, with a tonal genre chip and a quieter Play trailer action. Episode cards and collapsed trailers use solid 24dp rounded surfaces without idle outlines; selection retains its primary outline. Episode rows show a two-line title and description, a one-line ellipsized metadata summary and wrapping Bonus/Trailer/Video tags. Like, Download, Queue and a wider Play/Resume/Pause button form one connected row. Episode and subscription toggles reuse the Episode Info group's surface-container-highest idle colors, primary-container active colors and native circular checked shapes, while blocked notifications retain their error colors. Narrow widths and larger text use an accessible icon-only play action rather than clipping its label. Listening progress and remaining time share one vertically centered row below the actions: a continuous 6dp rounded track with no moving gap or end dot, followed by a single-line time label at the logical end. The time label uses its natural width up to 45% of the row, then ellipsizes for enlarged text or long translations while preserving the full text for accessibility. Without a time label the bar uses the full row; unstarted and completed episodes reserve no progress space. Search keeps its mark-played action as a separate pill. Playback, subscription, notification disclosure, auto-download, selection, grouping, pagination and navigation callbacks are unchanged. - A page opened from a link, push or Android Auto with only an episode ID (and maybe its show) resolves it through `PodcastRepository.resolveEpisodeLink`: stored row, trusted alias, the API, then one read of the show's feed for an episode only the feed lists. While that read runs the page shows `EpisodeInfoUiState.ReadingFeed` ("Finding this episode", "Checking the show’s feed. This can take a moment."); when nothing lists the episode it shows `NotFound` ("Episode not found" and Back; with the link's show, "It may have been removed from the show." and View show; without one, "It may have been removed, or this link may not open on this phone.", since such a link can name an ID only the sharer's phone holds). An unreachable source keeps the existing load error. A link resolved through an alias shows the episode under the phone's own ID; `Success.requestedEpisodeId` keeps the ID the page was opened with, which deep-link autoplay and artwork match on; download state and actions read the page's episode ID (`EpisodeLoadRequests.pageEpisode`). A share from the page fills in the page's show when the episode has none (`EpisodeLoadRequests.shareEpisode`), and a page built from history or a download carries its show, so its link keeps `&show=`. `EpisodeLoadRequests` skips a second load of the same ID unless it brings the show, so the route's and the screen's calls don't read a feed twice. `EpisodeLinkStatus` centres both states in the safe area at a readable width (480dp) on phones, tablets, split windows and folds. - `EpisodeInfoScreen` and `EpisodeInfoViewModel` (similar episodes use prefs `content_languages` + region). The artwork-first Material 3 layout keeps a visible blurred cover backdrop within a bounded, overscanned blur layer with a gradual alpha mask that reaches transparency before its lower edge, uses theme-based tonal surfaces and plain Back/Share icons with 48dp touch targets, and limits episode titles to three lines with an overflow-only arrow inside a small translucent circle at the trailing end of the third line. The first two title lines retain the full width; expanded titles keep all text visible and place the collapse arrow at the end. The generic Episode toolbar label is omitted; the episode title still appears when scrolled. The hero podcast name uses one line with ellipsis; Read more / Show less use visible filled-tonal pills. Like, Download and Queue use connected tonal toggle buttons with rounded outer ends, small gaps, and rounded selected states; a wider filled Play button completes the same 56dp-high row; listening progress and its time summary sit below the action group; Bonus/Trailer/Video tags sit inside the artwork at the top-start corner as compact opaque tonal pills with a 12dp inset; paired tags wrap for larger text or longer translations. Date, duration and season/episode numbers use middle-dot separators below the title in a single-line row. The completion pill stays fixed at the logical end; metadata takes the remaining width and marquees only when it overflows, with a 2.5-second initial pause, 2-second pauses between passes, and a 24dp/s scroll speed. Short metadata stays still, missing values do not leave stray separators, and the complete metadata remains available to accessibility. The pill label stays on one line, with ellipsis only when an unusually long translation or enlarged text exceeds the available width. The pill shows Mark played before completion and Played after completion; tapping it toggles the existing completion action, with Mark unplayed exposed to accessibility. Its guidance appears next to the metadata when requested. Chapters appear above About, use remote Podcast 2.0 chapters when valid, and fall back to parsed description chapters. Expanded About preserves publisher formatting and timestamp seeking. Resource pills size to their labels and pack independently into two horizontally scrolling rows; one link uses one row and two links stack one per row. Links with recognized brand icons come first, followed by generic links, retaining publisher order within each group. Priority fills top then bottom before moving across (first/second in the first pair, third/fourth in the next). Recognition uses the same domain matching as the icon and tint, so lookalike hosts do not gain priority. Recognized social profiles with a parsed username show only that username beside the platform icon; accessibility still names the profile and platform. Root pages, posts without a parsed username, unknown platforms, and video/podcast/support links retain descriptive destination or publisher-context labels. Bubbles use solid platform-tinted surfaces with contrast-adjusted logos for recognized social, video, podcast and support domains (with Material purpose icons as the fallback). TikTok uses a cyan seed with a softly tinted bubble and a contrast-adjusted cyan icon; X and Threads retain monochrome theme-foreground icons rather than inventing a brand hue. Cross-promotion cards appear only after the shared detector and resolver identify another show. Try these episodes reuses the Home `FeedMediaCard` expressive poster treatment with episode-title-only text and duration on the artwork. More from keeps the compact artwork/title/date/duration row cards and lists the five latest distinct episodes vertically, excluding the current episode. The show heads its own list as the first row of one connected group (24dp top corners, 6dp joins), on `secondaryContainer` with `onSecondaryContainer` text so it reads as the header rather than another episode row (the rows stay on `surfaceContainer`): its artwork (the show's cover, `moreFromShowPicture`), a small "More from" label over the show name (two lines at most) and a chevron. The row is the section heading for accessibility ("More from …") with a "View …" action; it and the Explore podcast button below the list both open the same show. Try these episodes leads with its icon in a solid Cookie9Sided badge on the page's primary-container pair, beside a bold heading. The section shares the page scroll and retains related-content scroll and click analytics. Both retain show identity for accessibility. Playback, download, queue, navigation, recommendation ranking and analytics callbacks remain owned by their existing layers. Tapping download on a downloaded episode prompts for confirmation before removing local media. -- **Show-details failures.** Podcast Info no longer spins forever when the show-details request (`getPodcastDetails`, Worker `/podcast`) fails. When a local Room row is already showing, `logic/PodcastInfoLoadRecovery` keeps it and applies the episode page loaded alongside (including the RSS local catalog), with cached supplements. With no episodes, it stops loading and ends pagination. Analytics are unchanged. With no local row, the error state reads "Couldn’t load this podcast" and has a **Try again** button (`components/PodcastInfoLoadError`, `PodcastInfoViewModel.retryLoad`). Episode Info shares a single show-details request between More from (genre, location, license) and Try these episodes (categories, author) through `logic/sharedLookup`, where it used to make two identical calls. JVM coverage: `PodcastInfoLoadRecoveryTest`. +- **Show-details failures.** Podcast Info no longer spins forever when the show-details request (`getPodcastDetails`) fails. When a local Room row is already showing, `logic/PodcastInfoLoadRecovery` keeps it and applies the episode page loaded alongside (including the RSS local catalog), with cached supplements. With no episodes, it stops loading and ends pagination. Analytics are unchanged. With no local row, the error state reads "Couldn’t load this podcast" and has a **Try again** button (`components/PodcastInfoLoadError`, `PodcastInfoViewModel.retryLoad`). Episode Info shares a single show-details request between More from (genre, location, license) and Try these episodes (categories, author) through `logic/sharedLookup`, where it used to make two identical calls. JVM coverage: `PodcastInfoLoadRecoveryTest`. - `InfoViewModelAssembler` for podcast and episode ViewModel factories. - `InfoListeningProgressItem` and supporting components/sections for detail UI. Recommendation rails (`EpisodeRecommendationSection`) and episode search overlay (`PodcastInfoSearchOverlay`) protect item lists against duplicate keys using `LazyListKeyPolicy`. - Logic helpers under `logic/` and component-level formatters used by tests. `EpisodeArtworkLogic` is the Podcast Info row/sheet artwork URL (episode art, else `podcastImageUrl`, else the show image) so Missing episodes? extras without item itunes:image still show cover art. @@ -94,7 +94,7 @@ The page's description sits under the chips, where it is on every show page; tap The spotlight shows the newest full episode while the list is newest first and search is closed, with the list's like, download, queue and play actions, progress and played state (every card says Played in place of the episode's length once it is finished, the newer ones through `brandedDetails`); the list leaves that episode out. Sorting oldest first or searching hides the card and the episode returns to the list. With completed episodes hidden, a finished latest episode hides the card rather than letting an older episode stand in as the latest. A card's picture is the episode's own (`brandedEpisodePicture`): the show's cover counts as none, even at another size or address scheme, while query parameters that name a picture (`?id=`) keep different pictures apart. A card that shows a picture falls back on the show's cover (`brandedShowPicture`; the fallback image when a show has no cover). -`BrandedShowPages` lists the shows that have a page: The Vergecast (ultraviolet and lime, wordmark and sticker, sticker slap on Subscribe), Conspiracy Theories (an evidence board on white paper by day and black by night, both carrying the show's faded typed script: the official wordmark pinned on a scrap of the show's yellow paper, and round it only episodes from the feed, each by the headline before its colon, or after it for a label like "BEST OF 2025" (`boardClues`): a newspaper clipping for the newest, ringed with a red marker loop, a typed index card for the one before, and a handwritten sticky note for the shortest of the next four, each clamped at three lines with an ellipsis; three more clues peek in from the window's edges, clipped as if the board carries on, their threads running off the screen. On a window narrower than the design's 369dp board (a cover screen, a split window) the clues, pins and yarn shrink to fit it (`ConspiracyBoardLayout.boardWidth`) instead of running off its edges. Glossy pushpins hold each scrap by its top edge, and twisted red yarn (plied, a little fuzzy, wavering, with a soft shadow, built once at full length so drawing it on never changes what is drawn) runs only over empty board: an X under the wordmark and a drop to the sticky note. The board's middle stays centred on wide windows, its paper tiled, while the peeking clues keep to the window's edges. When the page first opens the scraps drop in, the pins go in, the yarn draws on and the marker circles the newest clue, then the yarn sways twice and rests (`ConspiracyBoardLayout`). The latest episode is a manila folder with a dated tab, a NEW stamp, the typed title and notes and the file's buttons; "It all connects" on Subscribe: the button becomes a patch of the board, three scraps land and are pinned, the yarn closes a triangle between them and a red marker eye is drawn inside it with one haptic and a small tug, then the board clears into Subscribed), Waveform: The MKBHD Podcast (Signal: a black page with one red, the logo traced as live bars across the top over a faint pattern of the show's gadget icons; the bars breathe while one of its episodes plays and replay the logo intro on Subscribe, with a red and cyan glitch. Its latest-episode card has the episode's waveform as a band across the top, white up to where you are and grey after with no other marker, then "LATEST", the title and two lines of the episode's own description; the band's bars breathe with the logo while the episode plays) and Crime Junkie (Fingerprint: the official splatter mark over a fingerprint drawn in code in the splatter's magenta, violet and blue, faded out before any text; the latest episode as an evidence tag with clipped corners, a tear line and an eyelet (on the left in every language, the text kept clear of them), the case type stamped from the title before its colon ("MURDERED: Name", 99% of the show's titles; any other title shows whole with no stamp), the name in Oswald capitals, the episode's art and two lines of its description, while screen readers hear the episode's own title; a Flashlight moment on Subscribe: the lights go out on the button, a torch beam sweeps across and back, finds the ink splatter with one tap, and the light floods the button into Subscribed) and The Joe Rogan Experience (The studio: the official badge over a red stage curtain drawn in code, lit warm behind the badge and falling into the dark page; the latest episode as its own picture with the template's top strip trimmed off, in a warm raised panel so its black edges stand off the page, with the episode cards' buttons inside the panel; a black strip across the top carries the "Latest episode" label in the template's style, and the picture is scaled to fit between it and the progress bar, a blurred, darkened copy filling the sides, so neither covers the title in the picture; the card keeps a phone's proportions on wide windows, centred under the badge. An episode with no picture of its own gets its number and guest set in Anton on the curtain, darkened behind the guest so the orange name holds 3:1 even on the brightest fold, scaled with the card like the picture it stands in for; the curtain also shows while a picture loads and stays if it never arrives, while screen readers hear the episode's own title; a Third eye moment on Subscribe: the button goes dark, an eye like the badge's opens in it, its dotted rays spread across the button, it pulses once with one haptic and closes into Subscribed, while the badge's own eye lights up on the same clock) and Rotten Mango (Her room: Apple's official show-page photo of Stephanie Soo in her red room, the wordmark on its wall, emerging from the dark under the top bar and fading into the page below her lap, kept at a phone's size and centred with faded sides on wide windows; the latest episode in an old, darkened frame, "Latest episode" in dull red and the title typed in Special Elite up to four lines, with the episode cards' buttons inside the frame and the episode's number on a brass plate hung from it ("Bonus" when the feed gives none); "Bada bing, bada boom" on tape on Subscribe: the button switches on like an old television, "BADA BING" plays grainy and jittery in VT323, a burst of static cuts in, "BADA BOOM" hits with split colours, a slip of the vertical hold and one haptic, and the set switches off to a line before Subscribed lands, about 1.5 seconds, no sound) and The Daily (Front page: newsprint and ink in light mode, a warm charcoal night edition with cream ink in dark mode; the official one-line wordmark large across the top, a double rule, today's dateline (turning over at midnight and checked again when you come back to the page) with the Times' masthead and the ©, and the description and chips set like the paper; the latest episode as the lead story: "Latest episode" in the show's blue with the day it came out and its length on the same line, the title as a serif headline, five lines of the episode's notes beside its photo (the show's cover when it has none of its own, over the show's dawn horizon while either loads), and the episode cards' buttons and progress between two fine rules; "Daybreak" on Subscribe (`DailyDaybreak`, timings in `DailyDawn`): Subscribe sinks away as the show's dawn rises up through the button from its bottom edge, a small sun clears the horizon with one light tap, then daylight washes the sky into the Subscribed colour and Subscribed comes up, about 1.2 seconds; it replaced "Off the press", a steel roller printing a fresh sheet)). Waveform's, The Joe Rogan Experience's and Rotten Mango's identities are dark only, so their pages (`BrandedShowPage.darkOnly`) stay dark with white status bar icons in light mode too. Each page lays out its own header (`BrandedShowPage.Header`) around the shared chip row and description; the © button and its popup are shared (`BrandedCopyrightButton`). The popup opens below the © or, when the window has more room above, above it with its caret flipped; it stays inside the window and scrolls when taller than it. Where a publisher's name sits beside the ©, a long name shortens with an ellipsis so the © keeps its place. The Rest Is History (the gallery wall: the show's claret velvet as a museum wall in both themes, hung with its seven latest episodes' own pictures in antique bronze frames, the newest in the big centre frame, the two before it in ovals beside it and four more small at the sides; David and Churchill, cut from the show's cover with their faces whole, stand at the window's edges with their straight cut on the wall's edge and the show's logo between them, and the Goalhanger logo with the © below. The first time the page opens the gallery starts in the dark and its lights come on one by one, about 1.8 seconds in all (`GalleryWall`, choreography in `HistoryWall`): the room warms, the centre frame's spotlight catches with a flicker, the two ovals' come on together, then the small frames' row by row, each spotlight flaring as it comes on and settling to a soft pool on the velvet; the busts are lit from below, the logo comes up and a gilt glint runs across it. The lights wait until the page has settled and the frames' pictures have arrived (at most 1.2 seconds), with a short dark beat before them, so no picture pops in while they come up; frames have no loading placeholder, only a dark canvas until their picture arrives. By day the page below the wall is ivory and claret and the wall ends in a fine claret line. The latest episode hangs on a piece of the same wall under a picture light: its own picture in the wall's bronze frame with its number on a brass nameplate, "Latest episode", its name and its series and part split from the title (`historyTitle`), while screen readers hear the episode's own title; Play is ivory by night and claret with an ivory ring by day. "Through the ages" on Subscribe: the button becomes velvet, the latest episodes' pictures (shared from the header through `BrandedHeaderMotion.pictures`) rush in from the right in small bronze frames, oldest first and blurred with speed, the newest lands in the middle, a brass picture lamp clicks on over it with one haptic, its light flickers, the frames either side dim, and the wall clears into Subscribed), Darknet Diaries (the show's own flag-bearer art cut out of its black in a red light, dark in both themes: the flag waves while the figure, pole and trident never move (`FlagMesh` warps the art with a bitmap mesh on every Android version the app supports, following `DarknetFlagWave`: a 260-unit travelling wave every 2.8 seconds, its strength read from an amplitude map), light and shade ripple along the cloth, the red light breathes, light shafts sway through the episode illustrations' ink grit and red ink flecks drift through the air; the header stays black until its art is decoded and the page has settled, then the art rises into place with the red light coming up (the first time), and the motion eases in and keeps going for as long as the header is on screen; it stops when you scroll it away or leave, and stays still with animations off or battery saver on. To keep each frame cheap the light shafts are drawn once into a half-resolution picture off the main thread and only turned and screened on each frame (`DarknetRays`), the cloth's light and shade is a masked shader blended straight onto the art (`FlagShade`, one shader moved by its matrices each frame, so the wave allocates nothing), and the art draws in its own layer. The flag carries the show's name, so there is no separate wordmark. The latest episode is a charred card with a scorched top edge and an ember glow, "// LATEST · EP 182", the episode's name in Anton capitals (`darknetTitle`; screen readers hear its own title), when it came out and how long it is, and its illustration taped up beside them like a flyer, tilted, that straightens while you press the card. "The masked host" on Subscribe: the button goes dark, Jack's masked avatar comes up still in the middle, his avatar's red ink burst lights up behind him with a hot core, his glasses flash red with one haptic, film grain runs over it all, and the button clears into Subscribed), Serial (its logo made real on the plum of its cover art, dark in both themes: the logo is a stack of black cards with white rims, and here each card is a season, the newest on top and six before it stepping down-left behind it, each with its cover and "18 · METRO" under it. Covers of seasons that have ended ship with the app (`SerialSeasons.Bundled`, seasons 12 to 18, each the picture on its trailer, or on episode 1 when it has none); a newer season comes from the feed (`serialDeck`; Serial's header gets the trailers too, `BrandedShowPage.headerKeepsTrailers`). Seasons such as METRO give every episode its own photo and put the season's art on the trailer and episode 1, so a new season's card shows its trailer's own picture, else episode 1's; while neither is loaded, the picture all its loaded episodes share, or else the show's cover (`serialSeasonPicture`). Its name is the one two of its episode titles share before " - ", or "Season 19" until then. When the covers are decoded and the page has settled, the deck deals itself once: the oldest card lands first and each season drops onto the one before with a small bump, 150ms apart, then Serial's own card slides half out from behind the newest season and settles at a tilt (`SerialDeal`, keyframes eased stretch by stretch as on the board, `SerialKeys`); it stands dealt after that, and always with animations off. A tap on the deck deals it again; screen readers skip the deck, and hear SERIAL as the page's heading. Serial's own card is drawn, not pictured: its S is traced from the show's art (`SerialMark`), cream with a red edge and a cream copy just below it, on a black card with the deck's rim (`drawSerialCard`). Under SERIAL sit the Times' T and Serial Productions' S, as the season covers sign them, then the ©. The latest episode is the top card of a stack, its own picture on a rimmed card with two plain cards behind it, "METRO · EPISODE 3" in red capitals and the title without the season's name in front (`serialEpisodeLine`; a title that is only a number after the name stays whole, and screen readers hear the episode's own title). "One more on the stack" on Subscribe: the button goes dark, a small stack of cards waits in its middle, Serial's S card drops onto it with a bump and one haptic, the stack steps down-left to make room, a red glow comes up behind the new top card, and the button clears into Subscribed), Sherlock Holmes Short Stories (The Strand Magazine, where the stories first ran, dark in both themes: an issue lies on a lamp-lit desk, George Charles Haité's 1891 cover with the show's wordmark on its banner and Hugh Bonneville on its hanging sign. When the art is decoded and the page has settled, the issue slides in and its cover turns over on the spine, opening on Sidney Paget's 1893 Reichenbach plate and a contents page with the wordmark as its masthead; a tap opens it again. The turn is projected by hand at the design board's 900dp perspective (`StrandLeaf`), so it looks the same on every device rather than depending on a layer's camera. The contents list the stories the show has read, newest first and each once, numbered 1, 17, 33… (`SherlockFeed.contents`: a title's story is the part before ":" or " Part ", a leading "The Adventure of " is dropped, and only full episodes count); the page shows as many lines as fit at the text size. On a window narrower than 369dp the issue shrinks to fit, and the desk fills any width. The latest episode is the story's opening page: its own picture as the plate with when it came out and how long it is under it (or Played), Haité's headpiece, the story in small capitals, "Part III. Read by Hugh Bonneville." (`SherlockFeed.part`), a double rule, and the description's first paragraph (`SherlockFeed.firstParagraph`) beside a drop cap, ending in an ellipsis; a story name longer than 17 characters steps down onto two lines and the text gives up a line. On Subscribe the button becomes 221B's green front door with its brass plate and knob: the letterbox flap lifts, the new issue is pushed through, hangs from the slot by its top edge, drops, and the flap snaps shut with a jolt and one haptic (`SherlockLetterbox`, `LetterboxKeys`), then the button clears into Subscribed) and The Desi Crime Podcast (the hosts' crime board from the show's cover, dark in both themes: cork under the studio lamp, crowded with prints of earlier cases. A title reads "212. The case | the people in it" (`DesiCrimeFeed.case`). Each print is a case's own picture cut to the photo band above its DESI CRIME lettering (the picture's top 34%); the three nearest the lamp are captioned with the case's people, or with the case when "|" names a guest ("Ft.") or nobody (`DesiCrimeFeed.caption`). A case whose picture is the show's cover, and an episode without a number (Chai & Chithi), gets no print. In the middle hangs the newest case's picture down to the torn edge every episode picture shares (`DesiCrimeTear`), so its DESI CRIME lettering sits on the board, with the case number on a sticky note; a Hindi paper's cutting, a "सबूत?" tape and two more notes are part of the board's art. Aishwarya and Aryaan peek up over their arms in front, each held to their own edge of the window; the prints keep to their own edge too (`Edge`, `boardX`), and a window 640dp or wider pins six more prints between them. When the page has settled the lamp flickers on, the newest print drops and is pinned, and its number is slapped on; a tap does it again. The latest episode is a sticky note on a strip of the cork: "#212 · newest case" in red pen, the case in their handwriting, and when it came out and how long it is, or Played. On Subscribe, chai and a chithi after the show's segment of listeners' letters: the button becomes a listener's blue inland letter with a stamp and postmark, a cutting-chai glass is set down with one haptic, chai pours in, froths and steams, and the glass lifts away leaving its ring on the letter (`DesiCrimeChithi`, `ChithiKeys`), then the button clears into Subscribed). Both pages run their intro on `rememberBrandedIntro` (once per page, after the art is decoded and the page has settled, at rest after that, after recreation and with animations off) and ease their design boards' keyframes stretch by stretch with `keyframes`. No subscribe moment spells out "Subscribed"; the button's own label lands after each. Headers with their own art decode it off the main thread and keep it for the next visit (`rememberBrandedArt`, `BrandedArtCache`; a new set of pictures starts empty, never showing the last set's), so opening a page never waits on decoding. Subscribe moments take their art from the same cache (`brandedArtNow`), which the page warms when it opens, so a tap starts on its first frame, and start their motion only once the page has settled (`awaitPageSettled`: at least 250ms in and six smooth frames in a row, or 1.5 seconds at most). Fonts bundled with a page keep their licence under `licenses/` (Anton, shared by The Vergecast, The Joe Rogan Experience and Darknet Diaries, Barlow, Caveat, Cinzel, Cormorant Garamond, Courier Prime, Libre Franklin, Newsreader, Old Standard TT, Oswald and VT323, SIL OFL 1.1, and IM Fell English with its italic and small capitals, and Kalam, SIL OFL 1.1, both cut down to Latin text since the pages' Hindi is part of their art; Caveat, Cinzel, Cormorant Garamond and Old Standard TT are bundled at the single weights the pages draw; Special Elite, shared by Conspiracy Theories and Rotten Mango, Apache 2.0). Oswald and Libre Franklin are bundled as variable fonts and drawn at weights 500, 600 and 700; Newsreader's variable font is drawn at the weight and optical size of each line. They are built with the platform's font builder (`rememberVariableFont`), as the app's Google Sans is, because some devices ignore Compose's font variation settings and draw the default weight. +`BrandedShowPages` lists the shows that have a page: The Vergecast (ultraviolet and lime, wordmark and sticker, sticker slap on Subscribe), Conspiracy Theories (an evidence board on white paper by day and black by night, both carrying the show's faded typed script: the official wordmark pinned on a scrap of the show's yellow paper, and round it only episodes from the feed, each by the headline before its colon, or after it for a label like "BEST OF 2025" (`boardClues`): a newspaper clipping for the newest, ringed with a red marker loop, a typed index card for the one before, and a handwritten sticky note for the shortest of the next four, each clamped at three lines with an ellipsis; three more clues peek in from the window's edges, clipped as if the board carries on, their threads running off the screen. On a window narrower than the design's 369dp board (a cover screen, a split window) the clues, pins and yarn shrink to fit it (`ConspiracyBoardLayout.boardWidth`) instead of running off its edges. Glossy pushpins hold each scrap by its top edge, and twisted red yarn (plied, a little fuzzy, wavering, with a soft shadow, built once at full length so drawing it on never changes what is drawn) runs only over empty board: an X under the wordmark and a drop to the sticky note. The board's middle stays centred on wide windows, its paper tiled, while the peeking clues keep to the window's edges. When the page first opens the scraps drop in, the pins go in, the yarn draws on and the marker circles the newest clue, then the yarn sways twice and rests (`ConspiracyBoardLayout`). The latest episode is a manila folder with a dated tab, a NEW stamp, the typed title and notes and the file's buttons; "It all connects" on Subscribe: the button becomes a patch of the board, three scraps land and are pinned, the yarn closes a triangle between them and a red marker eye is drawn inside it with one haptic and a small tug, then the board clears into Subscribed), Waveform: The MKBHD Podcast (Signal: a black page with one red, the logo traced as live bars across the top over a faint pattern of the show's gadget icons; the bars breathe while one of its episodes plays and replay the logo intro on Subscribe, with a red and cyan glitch. Its latest-episode card has the episode's waveform as a band across the top, white up to where you are and grey after with no other marker, then "LATEST", the title and two lines of the episode's own description; the band's bars breathe with the logo while the episode plays) and Crime Junkie (Fingerprint: the official splatter mark over a fingerprint drawn in code in the splatter's magenta, violet and blue, faded out before any text; the latest episode as an evidence tag with clipped corners, a tear line and an eyelet (on the left in every language, the text kept clear of them), the case type stamped from the title before its colon ("MURDERED: Name", 99% of the show's titles; any other title shows whole with no stamp), the name in Oswald capitals, the episode's art and two lines of its description, while screen readers hear the episode's own title; a Flashlight moment on Subscribe: the lights go out on the button, a torch beam sweeps across and back, finds the ink splatter with one tap, and the light floods the button into Subscribed) and The Joe Rogan Experience (The studio: the official badge over a red stage curtain drawn in code, lit warm behind the badge and falling into the dark page; the latest episode as its own picture with the template's top strip trimmed off, in a warm raised panel so its black edges stand off the page, with the episode cards' buttons inside the panel; a black strip across the top carries the "Latest episode" label in the template's style, and the picture is scaled to fit between it and the progress bar, a blurred, darkened copy filling the sides, so neither covers the title in the picture; the card keeps a phone's proportions on wide windows, centred under the badge. An episode with no picture of its own gets its number and guest set in Anton on the curtain, darkened behind the guest so the orange name holds 3:1 even on the brightest fold, scaled with the card like the picture it stands in for; the curtain also shows while a picture loads and stays if it never arrives, while screen readers hear the episode's own title; a Third eye moment on Subscribe: the button goes dark, an eye like the badge's opens in it, its dotted rays spread across the button, it pulses once with one haptic and closes into Subscribed, while the badge's own eye lights up on the same clock) and Rotten Mango (Her room: Apple's official show-page photo of Stephanie Soo in her red room, the wordmark on its wall, emerging from the dark under the top bar and fading into the page below her lap, kept at a phone's size and centred with faded sides on wide windows; the latest episode in an old, darkened frame, "Latest episode" in dull red and the title typed in Special Elite up to four lines, with the episode cards' buttons inside the frame and the episode's number on a brass plate hung from it ("Bonus" when the feed gives none); "Bada bing, bada boom" on tape on Subscribe: the button switches on like an old television, "BADA BING" plays grainy and jittery in VT323, a burst of static cuts in, "BADA BOOM" hits with split colours, a slip of the vertical hold and one haptic, and the set switches off to a line before Subscribed lands, about 1.5 seconds, no sound) and The Daily (Front page: newsprint and ink in light mode, a warm charcoal night edition with cream ink in dark mode; the official one-line wordmark large across the top, a double rule, today's dateline (turning over at midnight and checked again when you come back to the page) with the Times' masthead and the ©, and the description and chips set like the paper; the latest episode as the lead story: "Latest episode" in the show's blue with the day it came out and its length on the same line, the title as a serif headline, five lines of the episode's notes beside its photo (the show's cover when it has none of its own, over the show's dawn horizon while either loads), and the episode cards' buttons and progress between two fine rules; "Daybreak" on Subscribe (`DailyDaybreak`, timings in `DailyDawn`): Subscribe sinks away as the show's dawn rises up through the button from its bottom edge, a small sun clears the horizon with one light tap, then daylight washes the sky into the Subscribed colour and Subscribed comes up, about 1.2 seconds; it replaced "Off the press", a steel roller printing a fresh sheet)). Waveform's, The Joe Rogan Experience's and Rotten Mango's identities are dark only, so their pages (`BrandedShowPage.darkOnly`) stay dark with white status bar icons in light mode too. Each page lays out its own header (`BrandedShowPage.Header`) around the shared chip row and description; the © button and its popup are shared (`BrandedCopyrightButton`). The popup opens below the © or, when the window has more room above, above it with its caret flipped; it stays inside the window and scrolls when taller than it. Where a publisher's name sits beside the ©, a long name shortens with an ellipsis so the © keeps its place. The Rest Is History (the gallery wall: the show's claret velvet as a museum wall in both themes, hung with its seven latest episodes' own pictures in antique bronze frames, the newest in the big centre frame, the two before it in ovals beside it and four more small at the sides; David and Churchill, cut from the show's cover with their faces whole, stand at the window's edges with their straight cut on the wall's edge and the show's logo between them, and the Goalhanger logo with the © below. The first time the page opens the gallery starts in the dark and its lights come on one by one, about 1.8 seconds in all (`GalleryWall`, choreography in `HistoryWall`): the room warms, the centre frame's spotlight catches with a flicker, the two ovals' come on together, then the small frames' row by row, each spotlight flaring as it comes on and settling to a soft pool on the velvet; the busts are lit from below, the logo comes up and a gilt glint runs across it. The lights wait until the page has settled and the frames' pictures have arrived (at most 1.2 seconds), with a short dark beat before them, so no picture pops in while they come up; frames have no loading placeholder, only a dark canvas until their picture arrives. By day the page below the wall is ivory and claret and the wall ends in a fine claret line. The latest episode hangs on a piece of the same wall under a picture light: its own picture in the wall's bronze frame with its number on a brass nameplate, "Latest episode", its name and its series and part split from the title (`historyTitle`), while screen readers hear the episode's own title; Play is ivory by night and claret with an ivory ring by day. "Through the ages" on Subscribe: the button becomes velvet, the latest episodes' pictures (shared from the header through `BrandedHeaderMotion.pictures`) rush in from the right in small bronze frames, oldest first and blurred with speed, the newest lands in the middle, a brass picture lamp clicks on over it with one haptic, its light flickers, the frames either side dim, and the wall clears into Subscribed), Darknet Diaries (the show's own flag-bearer art cut out of its black in a red light, dark in both themes: the flag waves while the figure, pole and trident never move (`FlagMesh` warps the art with a bitmap mesh on every Android version the app supports, following `DarknetFlagWave`: a 260-unit travelling wave every 2.8 seconds, its strength read from an amplitude map), light and shade ripple along the cloth, the red light breathes, light shafts sway through the episode illustrations' ink grit and red ink flecks drift through the air; the header stays black until its art is decoded and the page has settled, then the art rises into place with the red light coming up (the first time), and the motion eases in and keeps going for as long as the header is on screen; it stops when you scroll it away or leave, and stays still with animations off or battery saver on. To keep each frame cheap the light shafts are drawn once into a half-resolution picture off the main thread and only turned and screened on each frame (`DarknetRays`), the cloth's light and shade is a masked shader blended straight onto the art (`FlagShade`, one shader moved by its matrices each frame, so the wave allocates nothing), and the art draws in its own layer. The flag carries the show's name, so there is no separate wordmark. The latest episode is a charred card with a scorched top edge and an ember glow, "// LATEST · EP 182", the episode's name in Anton capitals (`darknetTitle`; screen readers hear its own title), when it came out and how long it is, and its illustration taped up beside them like a flyer, tilted, that straightens while you press the card. "The masked host" on Subscribe: the button goes dark, the host's masked avatar comes up still in the middle, its red ink burst lights up behind it with a hot core, his glasses flash red with one haptic, film grain runs over it all, and the button clears into Subscribed), Serial (its logo made real on the plum of its cover art, dark in both themes: the logo is a stack of black cards with white rims, and here each card is a season, the newest on top and six before it stepping down-left behind it, each with its cover and "18 · METRO" under it. Covers of seasons that have ended ship with the app (`SerialSeasons.Bundled`, seasons 12 to 18, each the picture on its trailer, or on episode 1 when it has none); a newer season comes from the feed (`serialDeck`; Serial's header gets the trailers too, `BrandedShowPage.headerKeepsTrailers`). Seasons such as METRO give every episode its own photo and put the season's art on the trailer and episode 1, so a new season's card shows its trailer's own picture, else episode 1's; while neither is loaded, the picture all its loaded episodes share, or else the show's cover (`serialSeasonPicture`). Its name is the one two of its episode titles share before " - ", or "Season 19" until then. When the covers are decoded and the page has settled, the deck deals itself once: the oldest card lands first and each season drops onto the one before with a small bump, 150ms apart, then Serial's own card slides half out from behind the newest season and settles at a tilt (`SerialDeal`, keyframes eased stretch by stretch as on the board, `SerialKeys`); it stands dealt after that, and always with animations off. A tap on the deck deals it again; screen readers skip the deck, and hear SERIAL as the page's heading. Serial's own card is drawn, not pictured: its S is traced from the show's art (`SerialMark`), cream with a red edge and a cream copy just below it, on a black card with the deck's rim (`drawSerialCard`). Under SERIAL sit the Times' T and Serial Productions' S, as the season covers sign them, then the ©. The latest episode is the top card of a stack, its own picture on a rimmed card with two plain cards behind it, "METRO · EPISODE 3" in red capitals and the title without the season's name in front (`serialEpisodeLine`; a title that is only a number after the name stays whole, and screen readers hear the episode's own title). "One more on the stack" on Subscribe: the button goes dark, a small stack of cards waits in its middle, Serial's S card drops onto it with a bump and one haptic, the stack steps down-left to make room, a red glow comes up behind the new top card, and the button clears into Subscribed), Sherlock Holmes Short Stories (The Strand Magazine, where the stories first ran, dark in both themes: an issue lies on a lamp-lit desk, George Charles Haité's 1891 cover with the show's wordmark on its banner and Hugh Bonneville on its hanging sign. When the art is decoded and the page has settled, the issue slides in and its cover turns over on the spine, opening on Sidney Paget's 1893 Reichenbach plate and a contents page with the wordmark as its masthead; a tap opens it again. The turn is projected by hand at the design board's 900dp perspective (`StrandLeaf`), so it looks the same on every device rather than depending on a layer's camera. The contents list the stories the show has read, newest first and each once, numbered 1, 17, 33… (`SherlockFeed.contents`: a title's story is the part before ":" or " Part ", a leading "The Adventure of " is dropped, and only full episodes count); the page shows as many lines as fit at the text size. On a window narrower than 369dp the issue shrinks to fit, and the desk fills any width. The latest episode is the story's opening page: its own picture as the plate with when it came out and how long it is under it (or Played), Haité's headpiece, the story in small capitals, "Part III. Read by Hugh Bonneville." (`SherlockFeed.part`), a double rule, and the description's first paragraph (`SherlockFeed.firstParagraph`) beside a drop cap, ending in an ellipsis; a story name longer than 17 characters steps down onto two lines and the text gives up a line. On Subscribe the button becomes 221B's green front door with its brass plate and knob: the letterbox flap lifts, the new issue is pushed through, hangs from the slot by its top edge, drops, and the flap snaps shut with a jolt and one haptic (`SherlockLetterbox`, `LetterboxKeys`), then the button clears into Subscribed) and The Desi Crime Podcast (the hosts' crime board from the show's cover, dark in both themes: cork under the studio lamp, crowded with prints of earlier cases. A title reads "212. The case | the people in it" (`DesiCrimeFeed.case`). Each print is a case's own picture cut to the photo band above its DESI CRIME lettering (the picture's top 34%); the three nearest the lamp are captioned with the case's people, or with the case when "|" names a guest ("Ft.") or nobody (`DesiCrimeFeed.caption`). A case whose picture is the show's cover, and an episode without a number (Chai & Chithi), gets no print. In the middle hangs the newest case's picture down to the torn edge every episode picture shares (`DesiCrimeTear`), so its DESI CRIME lettering sits on the board, with the case number on a sticky note; a Hindi paper's cutting, a "सबूत?" tape and two more notes are part of the board's art. Aishwarya and Aryaan peek up over their arms in front, each held to their own edge of the window; the prints keep to their own edge too (`Edge`, `boardX`), and a window 640dp or wider pins six more prints between them. When the page has settled the lamp flickers on, the newest print drops and is pinned, and its number is slapped on; a tap does it again. The latest episode is a sticky note on a strip of the cork: "#212 · newest case" in red pen, the case in their handwriting, and when it came out and how long it is, or Played. On Subscribe, chai and a chithi after the show's segment of listeners' letters: the button becomes a listener's blue inland letter with a stamp and postmark, a cutting-chai glass is set down with one haptic, chai pours in, froths and steams, and the glass lifts away leaving its ring on the letter (`DesiCrimeChithi`, `ChithiKeys`), then the button clears into Subscribed). Both pages run their intro on `rememberBrandedIntro` (once per page, after the art is decoded and the page has settled, at rest after that, after recreation and with animations off) and ease their design boards' keyframes stretch by stretch with `keyframes`. No subscribe moment spells out "Subscribed"; the button's own label lands after each. Headers with their own art decode it off the main thread and keep it for the next visit (`rememberBrandedArt`, `BrandedArtCache`; a new set of pictures starts empty, never showing the last set's), so opening a page never waits on decoding. Subscribe moments take their art from the same cache (`brandedArtNow`), which the page warms when it opens, so a tap starts on its first frame, and start their motion only once the page has settled (`awaitPageSettled`: at least 250ms in and six smooth frames in a row, or 1.5 seconds at most). Fonts bundled with a page keep their licence under `licenses/` (Anton, shared by The Vergecast, The Joe Rogan Experience and Darknet Diaries, Barlow, Caveat, Cinzel, Cormorant Garamond, Courier Prime, Libre Franklin, Newsreader, Old Standard TT, Oswald and VT323, SIL OFL 1.1, and IM Fell English with its italic and small capitals, and Kalam, SIL OFL 1.1, both cut down to Latin text since the pages' Hindi is part of their art; Caveat, Cinzel, Cormorant Garamond and Old Standard TT are bundled at the single weights the pages draw; Special Elite, shared by Conspiracy Theories and Rotten Mango, Apache 2.0). Oswald and Libre Franklin are bundled as variable fonts and drawn at weights 500, 600 and 700; Newsreader's variable font is drawn at the weight and optical size of each line. They are built with the platform's font builder (`rememberVariableFont`), as the app's Google Sans is, because some devices ignore Compose's font variation settings and draw the default weight. ## Dependencies @@ -111,7 +111,7 @@ The spotlight shows the newest full episode while the list is newest first and s - `EpisodeInfoScreen` delegates description and link list entries to a focused show-notes builder, preserving their order, formatting, visibility and chapter-seeking callback. - `EpisodeInfoNotesLoader` parses publisher HTML on a background dispatcher, fetches chapters and promotion matches independently, and cancels superseded requests. A generation guard also rejects late responses during metadata refreshes for the same episode. Invalid/empty remote chapters retain the description fallback; promotion failures do not block description or chapters. - Episode lists never repeat an id: `remountWithSupplements` and the displayed list drop duplicates, because offset pages overlap when episodes publish between "load more" requests and the list keys rows by episode id. -- **Loading and empty states (30 Oct 2026).** Opening a show draws the page in its own shape (`ShowPageSkeleton`: cover, title, chips, description, buttons, two episode cards, shimmering in the page's surface tones) in place of the lone 80 dp loader, under the same `podcast_info_loading` tag. Loading more at the end, and the archive read for oldest first, show two loading episode cards (`EpisodeCardSkeleton`, `EpisodeListItem`'s geometry) in place of the wavy spinner. **Hide completed episodes** used to filter only the loaded episodes, and the next page loaded only when the last row was drawn, so with every loaded episode finished the page stayed blank (listener report on 0.0.29, worst after reversing to oldest first). Now, while fewer than `HideCompletedFill.MIN_VISIBLE` (8) unfinished episodes show and the show has more, the page asks for the next page by itself (`rememberHideCompletedFill`), showing "Completed episodes are hidden. Finding the ones you haven’t finished…" with the wavy progress and loading cards; a feed read's own line takes precedence. When the show runs out with none left, `ShowCaughtUpCard` says "You’ve finished every episode" and offers **Show completed episodes** (`toggleHideCompleted`). Search ignores all of this, as before. +- **Loading and empty states (30 Oct 2026).** Opening a show draws the page in its own shape (`ShowPageSkeleton`: cover, title, chips, description, buttons, two episode cards, shimmering in the page's surface tones) in place of the lone 80 dp loader, under the same `podcast_info_loading` tag. Loading more at the end, and the archive read for oldest first, show two loading episode cards (`EpisodeCardSkeleton`, `EpisodeListItem`'s geometry) in place of the wavy spinner. **Hide completed episodes** used to filter only the loaded episodes, and the next page loaded only when the last row was drawn, so with every loaded episode finished the page stayed blank. Now, while fewer than `HideCompletedFill.MIN_VISIBLE` (8) unfinished episodes show and the show has more, the page asks for the next page by itself (`rememberHideCompletedFill`), showing "Completed episodes are hidden. Finding the ones you haven’t finished…" with the wavy progress and loading cards; a feed read's own line takes precedence. When the show runs out with none left, `ShowCaughtUpCard` says "You’ve finished every episode" and offers **Show completed episodes** (`toggleHideCompleted`). Search ignores all of this, as before. - **Show page state across window changes.** Resizing, rotation, split-screen and fold posture changes keep the same live page, so everything on it stays put. Activity recreation (a density change while folding, or process death) and a return from the back stack also keep open search, the five dialogs and sheets (mark all played, mark all unplayed, show playback, tag/genre edit, missing episodes), the tag/genre draft, the scroll position and the episode selection, all through saved state. So a listener who leaves the page with episodes selected finds them still selected on return; opening an episode from search results closes search first, so Back returns to the full list. The sort, hide-completed and refresh state live in `PodcastInfoViewModel`, and so does the live search query. `loadPodcast` skips a repeat call for the requested route ID even when the page now carries another ID (a followed `rss:` feed linked to the show, or the catalog ID from show details; `PodcastInfoLoadGuard`), so recreation and back-stack returns never reload those shows either. After process death the new ViewModel starts with an empty query, so the page saves the query too and runs the search again once the first page has loaded; the result list's scroll position is not kept in that case. Restored search does not scroll the list or reopen the keyboard; only tapping Search does. Selection is saved as episode IDs, the anchor and the sort it was made in. A recreated page waits until its first page has loaded (`PodcastEpisodeSelectionLogic.canRestoreSelection`), then looks its selected episodes up again from the loaded list and from the selection window, read in the saved sort (`loadEpisodeSelectionWindow(sort)`), keeping that window's order for the actions. IDs neither can supply are dropped, so the count matches what the actions receive. While the lookup runs, the selection toolbar's actions are disabled (`EpisodeSelectionToolbarState.actionsEnabled`) and the range menu shows progress. A failed lookup (for example offline) keeps every ID with the actions still disabled and tries again when the page state next changes; Back or Clear ends the selection. A restored Missing episodes? dialog shows only while the reloaded page offers the feed check, and is dropped once the check has run. ## Persistence & identity @@ -135,7 +135,7 @@ The spotlight shows the newest full episode while the list is newest first and s - `PodcastEpisodePlaybackStateLogicTest` covers each row's progress: started episodes resume with their time left, finished, unstarted and unknown-length ones have none, the episode playing overrides its row only for this show (by its show ID or the player's show), and the hours/minutes label. `PodcastEpisodeSelectionWindowTest` covers the selection window: the pages then loaded episodes without repeats, paging to the end of a long show with no cap, date order, stopping when a page adds no new episodes, a failing page failing the whole window, a saved sort winning over the page's sort, and no read without a loaded page. `PodcastInfoPageStateTest` covers the shared page state: a pull refresh ends only on its own show, a feed-extras result for another show is dropped and one for this show keeps the follow state shown now, the loading row clears alone, the resolved show is recorded only for the route still requested, and page sizes per sort. `PodcastInfoPullRefreshTest` (Mockito doubles) covers pull to refresh's guards: a pull during a refresh or during the catalog refresh after following reads and changes nothing, and a refresh for a show the page left never touches the new show's page, whether the listener leaves before the refresh starts or while its first page is still loading (the page and its paging offset stay the new show's). `PodcastInfoSessionTrackerTest` records the analytics events: one session per visit with the follow state the page opened with, none before a show loads, a new session only after coming back from an exit, each episode played or opened counted once, and the route on screen views. The screen tests mock `PodcastInfoViewModel`, so they also hold its API in place. The load, paging and search paths need the real catalog repository and have no new test; they moved unchanged. - `PodcastInfoPostSubscribeLogicTest` covers the status line after subscribing: it shows while the catalog refresh runs with no spinner in the pill (an offer hides, an opted-in pill stays), clears when the refresh ends, fails or is cancelled, and never replaces or clears a feed-first, fallback or offline line, nor another show's page. The line and pill are existing states the adaptive test already drives across every window gate, so no adaptive test changed. - **Manual acceptance (show page).** Open a show with many episodes. (1) Phone: tap Search, type a word, scroll the results, rotate twice; the field, query, results and scroll stay. (2) Long-press an episode, choose Select all, rotate; the count stays and Play acts on the same episodes. On a show with more than 100 episodes the count is the whole show, and scrolling to the oldest shows them selected; with search open, Select all selects the results and keeps the episode you pressed; in airplane mode Select all leaves the selection and says it couldn’t load every episode. On a Showcase page (The Daily, Serial, Waveform), a selected card stays the card's colour with a light tint, ring and tick, its text readable. (3) Open each overflow item (Mark all as played, Mark all as unplayed, Playback for this show, Change tag / genre with a typed draft) and the Missing episodes? chip on an unfollowed show; rotate, then confirm, press Back or tap outside, and check each acts once. (4) Tablet or desktop window: repeat (1) to (3) while resizing across 600dp and 840dp wide and 480dp tall, and in split-screen. (5) Foldable: repeat while folding to tabletop and unfolding, which recreates the activity. (6) Developer options → Don't keep activities: leave and return; open search with its query, dialogs and selection come back. (7) Repeat (1), (2) and (6) on a show you follow through its feed, and on a serial show sorted oldest first. Device runs are not part of CI. -- **Manual acceptance (feed-first show page).** Turn `feed_first` on for the device (PostHog), restart the app, then open a show you don't follow. (1) Phone: the line "Checking the feed for new episodes…" shows, then disappears as the list fills; scroll to the end of the first 100 and "Loading all episodes from the feed…" shows while older episodes appear below without the list jumping. (2) Long-press an episode, then rotate and resize split-screen while the archive loads: the selection count and scroll stay. (3) Tap Search and type a word on a show opened for the first time: the archive line shows, then the results. (4) Sort oldest first on a long show: the oldest episode appears after the archive line. (5) Open the show again within an hour: the saved list shows with no line; pull to refresh reads the feed. (6) Airplane mode, reopen: the saved list shows with "You’re offline. Showing saved episodes."; a show never opened before says "You’re offline. Connect to load this show’s episodes." (7) A show whose feed is down: after a moment the line says "Can’t reach this show’s feed. Showing boxlore’s episode list instead." and episodes still play. Repeat (1)–(2) on a tablet (landscape split at 840dp) and a foldable (flat ↔ tabletop). With `feed_first` off, the same show lists its episodes from boxlore's list, and search still finds them. +- **Manual acceptance (feed-first show page).** With `feed_first` on, open a show you don't follow. (1) Phone: the line "Checking the feed for new episodes…" shows, then disappears as the list fills; scroll to the end of the first 100 and "Loading all episodes from the feed…" shows while older episodes appear below without the list jumping. (2) Long-press an episode, then rotate and resize split-screen while the archive loads: the selection count and scroll stay. (3) Tap Search and type a word on a show opened for the first time: the archive line shows, then the results. (4) Sort oldest first on a long show: the oldest episode appears after the archive line. (5) Open the show again within an hour: the saved list shows with no line; pull to refresh reads the feed. (6) Airplane mode, reopen: the saved list shows with "You’re offline. Showing saved episodes."; a show never opened before says "You’re offline. Connect to load this show’s episodes." (7) A show whose feed is down: after a moment the line says "Can’t reach this show’s feed. Showing boxlore’s episode list instead." and episodes still play. Repeat (1)–(2) on a tablet (landscape split at 840dp) and a foldable (flat ↔ tabletop). With `feed_first` off, the same show lists its episodes from boxlore's list, and search still finds them. - `InfoPhoneGoldenTest` captures the actual podcast and episode screens at both portrait phone sizes and short landscape in light/dark. Mocked ViewModel flows avoid constructing Application-backed ViewModels or making network requests. These local-only captures use `-PphoneGoldens=verify --tests '*PhoneGoldenTest'`; see [capture instructions](../../docs/screenshots/README.md). diff --git a/feature/onboarding/README.md b/feature/onboarding/README.md index c5f135fb1..7a1fe97ca 100644 --- a/feature/onboarding/README.md +++ b/feature/onboarding/README.md @@ -16,9 +16,9 @@ Owns first-run onboarding presentation: genre selection, search-based onboarding - **Choice icons.** `EchoChoiceIcons` matches whole words (stems of five or more letters), tone before topic, so “space” is science and never a spa. A set shows icons only when at least three of four choices match; the fourth gets a neutral icon, and fewer matches show no icons. - **No scrolling, on any screen; the layout adapts to spare space.** `EchoLayout` picks a tier from the content height (before the keyboard and footer, so sizes never change between turns), and larger text drops a tier: Spacious (≥680dp), Roomy, Regular and Compact set Echo's size (168 to 84dp), the cards' base height and whether opening tiles stack the badge above the label (Spacious and Roomy) or set them side by side. Echo is always centred like a messaging profile, with its bubble below and a small tail pointing up at it. `EchoBodyLayout` then measures Echo, gives the answers their minimum, gives the question the room left (its type shrinks to fit, `TextAutoSize`), and shares the spare height: cards grow into half of it (up to 16dp each) and the rest goes a little above Echo, mostly between the question and the answers, and the remainder below, so the answers sit just under the middle. An open keyboard folds Echo away, and on the tightest screens (under 400dp, or very large text) the question outranks Echo. When the keyboard closes, the field leaves its typing state and Echo slides back in. - **Status lives where the next question goes.** While a turn loads, the question and answers leave and a shimmer placeholder in the loaded geometry takes their place (two question lines at the top, four rows in the answer slot); Echo plays its thinking pose and the progress line ripples. The footer stays exactly as the listener last saw it with every action paused (`EchoScreenModel.busyFooter`), and the main button turns tonal and says Echo is thinking: an expressive `LoadingIndicator` beside a word that changes every 1.8 seconds (“Thinking…”, “Pondering…”, `echo_thinking_words`) with a primary-colour glint sweeping across the letters. After 4 seconds Echo's bubble shows a hard-coded, step-specific line (`EchoWaitStep`, `res/values/echo_onboarding.xml` arrays, rotating every 5 seconds) and a 15-second countdown; if it runs out, “Taking a bit longer than usual. Your answer is saved.” Building suggestions does the same on the suggestions screen's loading state for the Echo flow (`EchoBuildWaitNote`), with lines for gathering, finding and sorting and one 30-second countdown across them. Back during a request cancels it and returns the answered question with the answer still picked. -- **When the API refuses AI for this install (2026-10-17).** A next-turn answer of `verification_required` (a limited API session whose Turnstile step-up failed or is off) or `daily_ai_limit` hands over to the topic picker at once (`EchoRequestPolicy.picksTopicsAfter`, then `switchToLegacyOnboarding`), because Try again can't succeed. The step-up itself happens in the network layer while the normal thinking state is on screen. Synthesis and topic synthesis already fall back to charts on any failure, the show deck is skipped, and search/import suggestions keep their **Try again** / **Start without adding** panel, so no path blocks. +- **When the API refuses AI for this install (2026-10-17).** A next-turn answer of `verification_required` (the API's extra verification didn't pass) or `daily_ai_limit` hands over to the topic picker at once (`EchoRequestPolicy.picksTopicsAfter`, then `switchToLegacyOnboarding`), because Try again can't succeed. The verification itself happens in the network layer while the normal thinking state is on screen. Synthesis and topic synthesis already fall back to charts on any failure, the show deck is skipped, and search/import suggestions keep their **Try again** / **Start without adding** panel, so no path blocks. - **Errors and recovery.** A failed turn shows an error panel in the question area (“Couldn’t load the next question” / “Check your connection, then try again.”) with **Try again** and **Pick topics instead**. API `recovery` turns show Echo's question with **Find my shows** and/or **Tell Echo more** (opens the text box). A named show (`searchSuggestion`) adds a tonal **Search for “…”** button under the question. -- **Finishing.** Next-turn requests send `features: ["finish_hint", "show_probe"]`, `country` and `languages`. Before `guided.finish` allows it (fewer than three meaningful answers), the footer is just **Continue**. After that, whether the API says `available` or `prominent`, **Continue** stays the main button and finishing is a quiet one-line pill above it: “Up to N more questions · **I’m done →**” (“Last question” at one), which asks “Find your shows now?” in a small dialog with **Keep answering** / **Find my shows**. There is no “Echo has enough” state: finishing is always the listener’s choice, and more answers only improve the suggestions. Without the hint (older Worker) four submitted answers make it available. The seven-turn cap is unchanged. Progress moves one fifth per answer, stops at 80% and fills only when results exist. **Find my shows** (and API `ready` turns) open the shared suggestions screen at once in its loading state; there is no separate Echo generating or ready stage. Back from loading or failed suggestions cancels the build and restores the question it started from (`EchoRequestLifecycle.buildQuestion`); Back from loaded suggestions shows a centred ready stage (`EchoReadyStage`: a larger Echo in its presenting pose above “Your shows are ready”) with **See my shows** and **Change my answers**, which discards the results and the picks inside them (`OnboardingAiStart.withoutResults`) so a rebuild never subscribes shows the listener can no longer see. A restart keeps the stored region (`OnboardingAiStart.freshState`). +- **Finishing.** Next-turn requests send `features: ["finish_hint", "show_probe"]`, `country` and `languages`. Before `guided.finish` allows it (fewer than three meaningful answers), the footer is just **Continue**. After that, whether the API says `available` or `prominent`, **Continue** stays the main button and finishing is a quiet one-line pill above it: “Up to N more questions · **I’m done →**” (“Last question” at one), which asks “Find your shows now?” in a small dialog with **Keep answering** / **Find my shows**. There is no “Echo has enough” state: finishing is always the listener’s choice, and more answers only improve the suggestions. Without the hint (an older API) four submitted answers make it available. The seven-turn cap is unchanged. Progress moves one fifth per answer, stops at 80% and fills only when results exist. **Find my shows** (and API `ready` turns) open the shared suggestions screen at once in its loading state; there is no separate Echo generating or ready stage. Back from loading or failed suggestions cancels the build and restores the question it started from (`EchoRequestLifecycle.buildQuestion`); Back from loaded suggestions shows a centred ready stage (`EchoReadyStage`: a larger Echo in its presenting pose above “Your shows are ready”) with **See my shows** and **Change my answers**, which discards the results and the picks inside them (`OnboardingAiStart.withoutResults`) so a rebuild never subscribes shows the listener can no longer see. A restart keeps the stored region (`OnboardingAiStart.freshState`). - **Show deck, 2026-10-15.** After the second answer the app asks `probe-deck` in the background (`requestShowDeckIfDue`, `features: ["show_probe"]`; next-turn requests now send `finish_hint` and `show_probe`). Two to four usable cards make a deck (`EchoDeckLogic.loaded`); the deck is ready the moment its cards arrive, and every clip starts buffering behind it (`EchoClipPool.prefetch`, one Media3 player per clip prepared at `previewStartSeconds`); a clip still loading finishes on tap. The deck itself takes about 10 seconds, so it opens at whichever comes first once it is ready: the next Continue (before that answer is sent; the answer goes out with the reactions after the last card) or the feed build (Find my shows, the confirm dialog, the turn cap or Echo deciding it has enough), which then starts after the last card with the reactions included (`EchoDeck.next`). A deck held before the build shows even when Echo has said it has enough. If either step comes while the deck is still loading, it waits for it once, up to 8 seconds, behind the usual thinking placeholder (`waitForShowDeck`), then opens the deck, or carries straight on if the deck came back empty or is still loading (it no longer waits after that). Back during the wait returns to the question, answer kept. One deck per conversation: an empty or failed deck (often an off-topic early answer, so the API counted fewer than two) is asked for once more after the next answer, never again after that; rewinding before the second answer drops an unseen deck and its clips. Reactions are big connected buttons in the footer's place (Sounds good / Not for me / I already listen / Not sure → `like` / `dislike` / `listen` / `unknown`), no swiping; a new card ignores taps until it has turned face up. **I already listen** also adds the show to the shows the listener will follow (undone with Back). Back steps to the previous card, undoing its answer; from the first card it returns to the question with the answer still picked. Reactions travel on later next-turn and synthesize requests (`probeReactions`, up to 12). - **Deck look.** The card's question rides in Echo's bubble, in bold, with a smaller Echo beside a smaller bubble (its tail points back at Echo; mirrored in RTL), so the card gets the height; on the tightest screens the question takes the headline slot instead. The card is the one deliberate break from the app theme: a Prism collector card (`EchoPrismCard`, redesigned 2026-10-16 at the listener's request for something extravagant, so its colours are fixed in `PrismColors` rather than taken from the scheme, every text colour checked against its dark ground). A rainbow edge turns slowly (7 s a turn) round a deep-space ground with pink, sky and violet glows; a starburst turns behind the art (50 s a turn); glitter twinkles at its own angle as the card leans; a rainbow sweep and a glare follow the light. The square art sits tilted 2° in a white-edged, dark-ringed frame with a violet glow and a moving sheen. A foil sticker is slapped on its top corner, turned 12° with a white edge: “TASTE TEST · 01/03” (or “WILD CARD” with a sparkle for `related` shows) in Space Mono, so the deck reads as part of the questions, not as recommendations; screen readers hear “Taste test, 1 of 3”. The name is in Unbounded ExtraBold capitals with a pastel foil gradient (screen readers get the name as written), the publisher a spaced Space Mono line, and the pitch sits in a dark box with a rainbow edge. Unbounded and Space Mono come through the Google Fonts provider (`UnboundedFamily`, `SpaceMonoFamily` in `:core:designsystem`); the card re-plans once they arrive, and the system sans stands in if they never do. Sizing (`EchoPrismLayout`, measured with a `TextMeasurer` exactly as drawn): the words are measured first and the pitch is never cut. The name keeps 24sp while it fits two lines, then steps down to 18sp, and only then takes a third line (down to 16sp, “…” only for an extreme name); the publisher shows only beside a short name (one line, or two at 22sp and up). The art takes the height that is left, up to the card's inner width, and the card (at most 420dp, at least 280dp) narrows to hug the art so there is no empty foil at its sides. When the art would drop under 150dp (small phones, large text) the card switches to a compact arrangement: art (124dp down to 72dp) beside the tag (the role alone) and name, the pitch underneath; on the very tightest slots the pitch steps down to 11sp and, last of all, the art goes. The deck's buttons stop at 60dp at normal text sizes (`EchoDeckRowMaxHeight`) so the card keeps the room on tall screens; large text keeps the tier height. The card floats (a slow few-degree drift, paused in the background and still with animations off), leans away under a finger and wobbles back (`EchoCardPhysics`). Each card is revealed the first time it comes up (`EchoCardRevealTiming`): the first is dealt off the deck face down, turns over with a small overshoot and lands with a haptic tick, then a flash of light runs across the foil (760ms); later cards flip where they lie (560ms). Answered cards are thrown right, left, up or down by reaction while the next one turns over; the rest of the deck peeks out underneath. The clip button sits below the card (the card reacts to touch, so nothing on it is a button): “Play a clip · 0:45” with the episode's title underneath on roomier screens (the same 56dp height on every card, so the card never changes size), a wavy spinner while buffering, pause inside a wavy ring filling over a 45-second clip that fades in and out (`EchoClipTiming`). Answering a card stops its clip; the next card waits for its own tap. - **Motion.** `EchoMotion.handoff()` is the single change: the old content leaves in 110ms, the new content rises in on a grounded spring after 90ms, and size changes follow a non-bouncy spring; choices then stagger in 45ms apart. Selection morphs shape and colour on springs. All of it follows the system animation scale (instant at zero). The `EchoScreenModel` pure functions decide the screen kind, footer buttons, progress and Echo's pose. @@ -27,7 +27,7 @@ Owns first-run onboarding presentation: genre selection, search-based onboarding - Echo peeks over the top edge of Welcome's **Find shows for me** button (still with its sparkle icon). It is the new `EchoCharacter` (`welcome` sequence): its eyes start as it starts to rise: it looks down at the button edge it is climbing over, then darts up to the listener with a blink and an eye smile. From then on it reads the headline up and to its left, looks back, checks the button below, smiles, and now and then glances off to the side, curious (`EchoWelcomeIdle`, about 17 seconds a loop). Only the eyes move, never to the right, and something changes at least every 3.5 seconds. It is drawn behind the button so the button's edge sits at Echo's neck (`WelcomeEchoNeckFraction`) and hides its body. Its size adapts to the free space above the button (`welcomeEchoCell`: 96 to 136dp, measured from the room between the invitation and the button), so it fits every phone without fixed sizes; when even the smallest Echo can't fit (short windows, very large text), it stays hidden. Echo rises into place on the shared Welcome clock (420ms with a small overshoot, from 0.7s) and ducks 6dp while the button is pressed, popping back up on release. A faint 11dp contact shadow (20% black, drawn only over Echo's pixels) stays at the button's edge as Echo moves, so it reads as tucked behind the button. Echo is clipped to the button area so its body never shows below the button before it rises, and it never adds layout height, so the page never scrolls because of it. With animations off it shows its still pose. `WelcomeEchoPeekTest` covers the rise curve and the adaptive size; `WelcomeEchoFitTest` (Robolectric) checks fifteen window and text sizes, from a 320x568dp phone to an 800x1280dp tablet, plus an unfolded foldable, both landscape orientations and a split-screen half, at 1x to 2x text. Echo shows on common phones, the unfolded foldable and the portrait tablet, and never overlaps the invitation. These are static sizes; Echo's state across live window resizes is not covered. - Welcome uses a solid rounded cover panel with 12 curated bundled covers in two opposing, continuously tiled rows. The opening holds a brisk glide for 600ms, then slows over 1.8s directly into a continuous 16dp/s loop. The invitation “You bring the curiosity.” enters before a fixed “We’ve got the” prefix with rotating mysteries, laughs, headlines, big ideas, stories, discoveries and lore. The word and its matching Rounded icon use the theme's primary accent; masked word reveals and a short icon settle share the cover clock, with readable holds and a longer pause on lore. The subtitle reserves the longest localized word's width and height; large text stacks the prefix above the slot rather than shrinking it. TalkBack reads one stable “We’ve got the lore.” description. Headline type size adapts to the window width. The wordmark and all actions remain available from the first frame. The compact, single-line “Find shows for me” action includes an AI sparkle and opens the existing AI-guided discovery flow; connected “Search podcasts” / “Import library” actions include their search/download icons. These actions and “Skip setup” retain their existing callbacks. The normal phone layout fits without scrolling or overscroll stretch; short windows and larger system text can scroll only when content needs more room. - Onboarding hero titles use `rememberCondensedGoogleSansFamily()` from `:core:designsystem` so condensed Google Sans Flex follows Appearance lettering roundness; AI onboarding uses centralized Google Sans Flex weight tokens. -- `GenreOnboardingScreen`, `SearchOnboardingScreen`, `ImportOnboardingScreen`, `AiOnboardingScreen`, `AiChatOnboardingScreen`, and `AiSuggestionsScreen`. `ImportOnboardingScreen` presents library restoration options (OPML/JSON file import and Sync from Cloud account) with inbound download iconography (`Icons.Rounded.Download`). Search uses progressive Meili typeahead + hybrid `/search` (`searchPodcastsGrouped`) with **Matches** / **Also found** sections. When catalog hits prepend over local substring matches, the result list pins to the top via shared `ProgressiveSearchScrollLogic` so the best match stays on screen. Search genre chips use shared `PillFilterChip` from `:core:designsystem`. Search and suggestion grids safely deduplicate and disambiguate keys via `LazyListKeyPolicy` to protect Lazy layouts from collision crashes. AI suggestions use the shared 11-country `RegionSegmentedSelector` (persisted via `UserPreferencesRepository.setRegion`, which also seeds recommended content languages). Curriculum / genre-synth / similar-shows requests use `discoveryLocaleForRegion` so `country` and `languages` stay canonical together; the proxy expands and `startsWith`-filters podcast languages. +- `GenreOnboardingScreen`, `SearchOnboardingScreen`, `ImportOnboardingScreen`, `AiOnboardingScreen`, `AiChatOnboardingScreen`, and `AiSuggestionsScreen`. `ImportOnboardingScreen` presents library restoration options (OPML/JSON file import and Sync from Cloud account) with inbound download iconography (`Icons.Rounded.Download`). Search uses progressive typeahead + hybrid `/search` (`searchPodcastsGrouped`) with **Matches** / **Also found** sections. When catalog hits prepend over local substring matches, the result list pins to the top via shared `ProgressiveSearchScrollLogic` so the best match stays on screen. Search genre chips use shared `PillFilterChip` from `:core:designsystem`. Search and suggestion grids safely deduplicate and disambiguate keys via `LazyListKeyPolicy` to protect Lazy layouts from collision crashes. AI suggestions use the shared 11-country `RegionSegmentedSelector` (persisted via `UserPreferencesRepository.setRegion`, which also seeds recommended content languages). Curriculum / genre-synth / similar-shows requests use `discoveryLocaleForRegion` so `country` and `languages` stay canonical together; the API expands them and filters podcast languages. - **Suggestions screen, redesigned 2026-10-15** (`AiSuggestionsScreen`, shared by Echo, genres, search and library import). A top bar (Back, “Shows for you”) over one vertical list of lanes; each lane is an expressive-shape icon, its title and **Add all** / **Remove all**, then its shows in a sideways row (no lane descriptions), so every lane is visible at a glance. Each show is a `SuggestionShowCard`: square artwork, two-line name, one-line host; a tap adds or removes it (the badge blooms from a plus into a turned shape with a check, Echo’s selection mark, and the artwork gains a ring) and touch and hold opens the description sheet. **Nothing starts selected**: the old first-show-per-lane picks are gone from Echo and genre builds. Echo’s flow leads with Echo beside a bubble (“Here’s what I found. Tap to add, hold for details.”) and a **Your picks** lane holding the shows picked during the chat (a “Sounds good” or “I already listen” deck answer, or a search side trip; `aiPicks`, snapshotted at the build), selected and removed from the other lanes. Search and import lead with a summary card of the shows already in the library (they were subscribed at the handoff, so they are not offered again). One sticky button: a tonal **Start without adding**, or a filled **Add N shows** once anything is selected (search and import count only the suggestions picked here, `OnboardingSuggestionsPresentation.addCount`). Loading keeps the geometry: Echo beside its build-wait lines (or one line for other flows), skeleton lanes, and the button saying what is happening (“Finding shows…”); finishing shows “Adding your shows…”. A failed or empty load explains itself and offers **Try again** with **Start without adding** above it. The charts lane keeps the region picker. Copy lives in `res/values/onboarding_suggestions.xml`. - The updated flow opts into `discovery_v2` on synthesis, curriculum, genre and similar-show requests. This permits up to six useful lanes while older apps retain four. Existing components preserve server relevance order both across lanes and within each lane; no UI sorting or extra AI call is added. The four-answer build nudge and existing conversation completion handling remain unchanged. - `AiSuggestionCards`, AI onboarding components, option icons, chat input, and chat message list logic. diff --git a/feature/settings/README.md b/feature/settings/README.md index 14d2746d5..92f0dd74a 100644 --- a/feature/settings/README.md +++ b/feature/settings/README.md @@ -52,7 +52,7 @@ Owns the unified Settings hub, category pages (Account, Sync & Backups, Appearan - Dialogs: `AddRssFeedDialog`, `ResetAnalyticsDialog`, `LogsPreviewDialog`. - Automatic downloads uses the shared two-line settings header and solid grouped surfaces for Downloads, Storage, and While boxlore is closed. A connected 1/2/3/5 selector replaces scrolling quota chips; a short introduction explains per-show activation. **Backup background checks** is explicitly described as optional recovery when show notifications are off or do not arrive; notifications can already trigger downloads while closed. Its network/charging controls appear only when the off-by-default switch is enabled, retaining their preferences when hidden. Concise state-dependent copy explains foreground/push discovery or battery/data costs and Android delays; the header help dialog holds the full network, consent, discovery and retention explanation. Per-show auto-download never enables background checking. -- **Privacy page:** the opening card says analytics never includes the listener's name or email, and that an account is optional, only backs up and syncs the library, and is never linked to analytics (1 Nov 2026; it used to say there was no account). Wording changes here need the maintainer's approval of the exact text. +- **Privacy page:** the opening card says analytics never includes the listener's name or email, and that an account is optional, only backs up and syncs the library, and is never linked to analytics (1 Nov 2026; it used to say there was no account). - **Page views** (`TrackScreenView`, `:core:designsystem`): `settings` sends `settings_interaction` `screen_viewed` through the helper; every page sends `screen_viewed` (`account` with `mode`, `sync_and_backups`, `library_settings`, `recommendations_settings`, `appearance` and its `appearance_theme`/`_home`/`_player`/`_navigation`/`_text`, `playback_settings`, `downloads_settings`, `smart_downloads_settings`, `auto_download_settings`, `privacy`, `about`, `feedback` for "Share your thoughts", `feedback_list`, `feedback_thread` and `feedback_sent`), as do its dialogs. `support` keeps `support_page_viewed`. Account actions send `settings_interaction`: `account_sign_in_email_link`, `account_sign_in_google`, `account_sign_out`, `account_sync_now`, `account_delete_confirmed`. ## Internal structure diff --git a/scripts/README.md b/scripts/README.md index d0e0f5f33..5c8e75c28 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -8,7 +8,7 @@ Catalog sync refreshes charts, imports podcasts, syncs episodes, and vectorizes Scripts need Node.js 22.13 or newer (`engines` in `scripts/package.json`): stage 3 reads the feed-address registry and Apple's snapshot with `node:sqlite`, which needs a flag before 22.13. The runner uses Node 22, and so do the workflows that install `scripts/`. -A Git push does not deploy this pipeline. Changes to `scripts/sync/` or `scripts/package.json` require a separate deployment and verification that the active runner uses the updated code and configuration. Read the production runbook in root `AGENTS.internal.md` before deployment. If it is unavailable, obtain it from the maintainer before performing production operations. +A Git push does not deploy this pipeline. Changes to `scripts/sync/` or `scripts/package.json` require a separate deployment and verification that the active runner uses the updated code and configuration. Deployment follows the maintainer's private runbook; ask the maintainer before any production operation. ## Catalog components @@ -36,12 +36,12 @@ A Git push does not deploy this pipeline. Changes to `scripts/sync/` or `scripts | [`scripts/sync/lib/cadence.js`](sync/lib/cadence.js) | Per-show check schedule for stage 3, in shadow (`CADENCE_MODE`: `shadow` by default, `off`). Checks still follow the news/core/relaxed tiers; the run report adds what the schedule would have checked. Each show's interval follows its pace, the average gap between its newest 11 episodes: news shows hourly, others a quarter of the gap within 3 to 48 hours, and dormant shows weekly when both the newest episode and a fresh Apple answer are over 180 days old. A show with no known pace keeps its tier. A show Apple lists a newer episode for (`APPLE_LATEST_DB`, the applefeeds snapshot) is due until it has been checked 6 hours past that date. Pace and the newest date come from each fetched list and from the episode book about daily (400 shows per read); show records gain `g`, `p` and `i` (iTunes ID). The report line counts shows the schedule would skip or check earlier, new episodes it would have found later and by how much (only tips dated after the newest episode known, so Podcast Index re-indexing an old episode doesn't count), and checks a day against today's tiers. Drop times (24 Oct): for each new episode it reads the show's release pattern from the dates known before it (`arrivalPattern`: daily, weekly or a regular gap when most of the last 10 episodes fit; irregular otherwise) and reports how many landed on schedule, where timing checks would look, against off-schedule ones (bonus episodes, irregular shows) that the usual checks catch, plus how many hours after release this run found each kind. Timing checks would only ever add checks: every show keeps its usual ones. A book or snapshot failure only skips that part | | [`scripts/sync/lib/attribution.js`](sync/lib/attribution.js) | Vector attribution log: one JSON line per episode point stored or deleted (prune, cap trim), show vector stored, and show imported or deleted. Written to `SYNC_ATTRIBUTION_DIR` (default `scripts/data/attribution/`, gitignored) as daily files, gzipped after the day ends and kept 60 days. Best effort: a write failure never fails a stage. Stage 4 adds `stored`, `pruned` and `reasons …` to its run line | | [`scripts/sync/attribution-report.js`](sync/attribution-report.js) | Read-only report over that log: embeddings by reason (new episode, first coverage, returning show, re-embed after cap trim or prune, Podcast Index ID change, backfill), deletions, and how long chart entrants stay. `--days N`, `--json` | -| [`scripts/sync/lib/episode-book.js`](sync/lib/episode-book.js) + [`episode-book-cli.js`](sync/episode-book-cli.js) | The episode book in sqld: one permanent row per (show, catalog key) with its recipe ID and served ID, plus Podcast Index aliases, `served_show` and `show_window`. Every book write also marks the show in `served_show` when one of its episodes is listed by Podcast Index under an ID other than its served ID (renumbered, duplicated or recipe-served), so the Worker's v1 overlay translates that ID back. That check reaches a show's aliases through `episode_pi_alias_rid` (an index on `rid`); without it each check scanned every alias inside the write. `ensure` creates the tables, indexes and columns; `stats` counts. Writes go through `turso.atomic()` (one Hrana batch: every step runs only after the previous one succeeded, then COMMIT, else ROLLBACK). Rules: [`docs/EPISODE_IDS.md`](../docs/EPISODE_IDS.md) | +| [`scripts/sync/lib/episode-book.js`](sync/lib/episode-book.js) + [`episode-book-cli.js`](sync/episode-book-cli.js) | The episode book in sqld: one permanent row per (show, catalog key) with its recipe ID and served ID, plus Podcast Index aliases, `served_show` and `show_window`. Every book write also marks the show in `served_show` when one of its episodes is listed by Podcast Index under an ID other than its served ID (renumbered, duplicated or recipe-served), so the API's v1 overlay translates that ID back. That check reaches a show's aliases through `episode_pi_alias_rid` (an index on `rid`); without it each check scanned every alias inside the write. `ensure` creates the tables, indexes and columns; `stats` counts. Writes go through `turso.atomic()` (one Hrana batch: every step runs only after the previous one succeeded, then COMMIT, else ROLLBACK). Rules: [`docs/EPISODE_IDS.md`](../docs/EPISODE_IDS.md) | | [`scripts/sync/lib/episode-identity.js`](sync/lib/episode-identity.js) | Stage 4's episode identity: each window episode gets its catalog key and recipe ID, and its point is named by the book's served ID. A new episode's book row (served ID = its current Podcast Index ID) is written before its vector; a Podcast Index ID change keeps the point and adds an alias instead of re-embedding. Points are stamped `eid` and `catalog_key` (`eid_missing` without a key). A new key whose Podcast Index ID is already served for another key is deferred: today's naming, no row. Its point belongs to the owner: stamped with the owner's `eid` and `catalog_key` when the owner is in the same show (a changed GUID), or `eid_shared` with the owning show when it is another show (a duplicate feed: Podcast Index lists one podcast under two show IDs that share episode IDs). If the book write fails, the show keeps today's naming for that run and the bootstrap's `--catch-up` records it later; a finished catch-up pass starts over when run again | -| [`scripts/sync/bootstrap-book.js`](sync/bootstrap-book.js) + [`lib/book-bootstrap.js`](sync/lib/book-bootstrap.js) | One resumable pass over every show with episode points: maps each point's payload ID (its Podcast Index ID) to a catalog key through PI's episode list, writes the show's book rows in one transaction (served ID = that PI ID), then stamps `eid` and `catalog_key` on the points (`eid_missing` when unmapped). A point whose Podcast Index ID another key already serves gets no row, so the show's transaction can't fail on it: it is stamped with that key's identity when it is in the same show, or `eid_shared` with the owning show for a duplicate feed. Point IDs and vectors never change. `--dry`, `--limit-shows N`, `--minutes N`; progress in `SYNC_BOOTSTRAP_STATE`. `--catch-up` takes only shows that still have points with none of `eid`, `eid_missing` and `eid_shared`. `--retry-missing` (own-episode-IDs P3; monthly on the VPS) runs the pass again for shows with `eid_missing` points: a point mapped this time gets its stamp and loses the mark | -| [`scripts/sync/lib/charted-at.js`](sync/lib/charted-at.js) + [`stamp-charted-at.js`](sync/stamp-charted-at.js) | Last-charted dates on Qdrant points (own-episode-IDs P3b): every point in both collections carries `charted_at`, the UTC day (epoch seconds) its show was last seen on a chart, so a 90-day vector retention can outlive the show's catalog row and state record. Stage 6 stamps every show it keeps from its chart clock (`shows[id].s`) before deleting; a point is rewritten only when it has no date or one more than `CHARTED_AT_REFRESH_DAYS` (default 7) older than its show's, so dates only move forward and a charting show's points are rewritten about weekly. Stages 4 and 5 give new points the day they are embedded. Failures are logged and never block the cleanup. `stamp-charted-at.js` stamps every point once from the catalog and the chart clock without changing anything else (`--dry` counts only; run from the repository root under the shared job lock). Nothing deletes by this date yet | -| [`scripts/sync/lib/retention.js`](sync/lib/retention.js) | Vector retention after a show leaves the charts (own-episode-IDs P3b, decided 24 Oct 2026): each departed show keeps its newest `ARCHIVE_EPISODES` (5) episode points and its show vector for `RETENTION_DAYS` (90) after it was last on a chart, read from `charted_at`; its catalog row still goes after the 7-day grace. `RETENTION_MODE` (stage 6): `count` by default keeps today's deletes and adds what `on` would keep, delete and expire to the report; `on` keeps the newest episodes instead of deleting the show's vectors, stamps their exact last chart day, and deletes points (both collections) last charted more than `RETENTION_DAYS` ago; `off` reports nothing. Size guard on episode points: over `QDRANT_POINTS_ALERT` (1.8M) the run warns; over `QDRANT_POINTS_CEILING` (2.0M) retention steps down to 60 then 30 days, and an urgent alert goes out through `SYNC_NOTIFY_SCRIPT` while it stays over (resolved once under). Fails safe: expiry is held back (counted only) in a run whose date stamps failed; a departed show whose kept points couldn't be dated keeps its catalog row and chart clock until a later run succeeds; with `on`, a retention failure fails the stage. Numeric settings accept whole numbers only. Attribution logs kept episodes on `show-` (`kept`) and expired points as `exp` (`retention` in the report), written only after Qdrant confirms the delete. Qdrant's memory limit is 6 GB for this | -| [`scripts/sync/lib/run-budget.js`](sync/lib/run-budget.js) | Per-run time budget for the VPS runner's hourly runs (own-episode-IDs P3b). Once a run holds the shared job lock, the runner passes `SYNC_STAGE3_DEADLINE_AT` (half the budget) and `SYNC_DEADLINE_AT` (the whole budget) as epoch seconds; stage 3 stops starting checks and stage 4 stops starting shows when theirs passes, and the rest waits for the next run, so runs stay even. Both say so in the run report. Unset means no limit (manual runs). Stages 3 and 4 append one JSON line of counts and timings to `SYNC_METRICS_FILE` for the runner's usage log | +| [`scripts/sync/bootstrap-book.js`](sync/bootstrap-book.js) + [`lib/book-bootstrap.js`](sync/lib/book-bootstrap.js) | One resumable pass over every show with episode points: maps each point's payload ID (its Podcast Index ID) to a catalog key through PI's episode list, writes the show's book rows in one transaction (served ID = that PI ID), then stamps `eid` and `catalog_key` on the points (`eid_missing` when unmapped). A point whose Podcast Index ID another key already serves gets no row, so the show's transaction can't fail on it: it is stamped with that key's identity when it is in the same show, or `eid_shared` with the owning show for a duplicate feed. Point IDs and vectors never change. `--dry`, `--limit-shows N`, `--minutes N`; progress in `SYNC_BOOTSTRAP_STATE`. `--catch-up` takes only shows that still have points with none of `eid`, `eid_missing` and `eid_shared`. `--retry-missing` (run monthly) runs the pass again for shows with `eid_missing` points: a point mapped this time gets its stamp and loses the mark | +| [`scripts/sync/lib/charted-at.js`](sync/lib/charted-at.js) + [`stamp-charted-at.js`](sync/stamp-charted-at.js) | Last-charted dates on vector points: every point in both collections carries `charted_at`, the UTC day (epoch seconds) its show was last seen on a chart, so a 90-day vector retention can outlive the show's catalog row and state record. Stage 6 stamps every show it keeps from its chart clock (`shows[id].s`) before deleting; a point is rewritten only when it has no date or one more than `CHARTED_AT_REFRESH_DAYS` (default 7) older than its show's, so dates only move forward and a charting show's points are rewritten about weekly. Stages 4 and 5 give new points the day they are embedded. Failures are logged and never block the cleanup. `stamp-charted-at.js` stamps every point once from the catalog and the chart clock without changing anything else (`--dry` counts only; run from the repository root under the shared job lock). Nothing deletes by this date yet | +| [`scripts/sync/lib/retention.js`](sync/lib/retention.js) | Vector retention after a show leaves the charts: each departed show keeps its newest `ARCHIVE_EPISODES` (5) episode points and its show vector for `RETENTION_DAYS` (90) after it was last on a chart, read from `charted_at`; its catalog row still goes after the 7-day grace. `RETENTION_MODE` (stage 6): `count` by default keeps today's deletes and adds what `on` would keep, delete and expire to the report; `on` keeps the newest episodes instead of deleting the show's vectors, stamps their exact last chart day, and deletes points (both collections) last charted more than `RETENTION_DAYS` ago; `off` reports nothing. Size guard on episode points: over `QDRANT_POINTS_ALERT` (1.8M) the run warns; over `QDRANT_POINTS_CEILING` (2.0M) retention steps down to 60 then 30 days, and an urgent alert goes out through `SYNC_NOTIFY_SCRIPT` while it stays over (resolved once under). Fails safe: expiry is held back (counted only) in a run whose date stamps failed; a departed show whose kept points couldn't be dated keeps its catalog row and chart clock until a later run succeeds; with `on`, a retention failure fails the stage. Numeric settings accept whole numbers only. Attribution logs kept episodes on `show-` (`kept`) and expired points as `exp` (`retention` in the report), written only after Qdrant confirms the delete. | +| [`scripts/sync/lib/run-budget.js`](sync/lib/run-budget.js) | Per-run time budget for the runner's hourly runs. Once a run holds the shared job lock, the runner passes `SYNC_STAGE3_DEADLINE_AT` (half the budget) and `SYNC_DEADLINE_AT` (the whole budget) as epoch seconds; stage 3 stops starting checks and stage 4 stops starting shows when theirs passes, and the rest waits for the next run, so runs stay even. Both say so in the run report. Unset means no limit (manual runs). Stages 3 and 4 append one JSON line of counts and timings to `SYNC_METRICS_FILE` for the runner's usage log | | [`scripts/sync/lib/episode-recipe-id.js`](sync/lib/episode-recipe-id.js) | boxlore episode IDs from (show ID, catalog key); must match the app's `EpisodeRecipeId` and the shared fixture in `core/rss/src/test/resources/fixtures/` | | [`scripts/package.json`](package.json) | Sync Node deps + `npm run test:sync` | @@ -52,7 +52,7 @@ The runner orchestrator lives outside this repository. It invokes the stages in - Extend hermetic tests under `sync/lib/*.test.js` and run `npm run test:sync` from `scripts/`. - Keep large Turso reads on `fetchAllPaged` or country/category pages; avoid unbounded wide SELECTs. - Preserve the chart index: `charts.itunes_id` is TEXT, so casting that column to INTEGER disables the index and inflates `rows_read`. Use `c.itunes_id = CAST(p.itunes_id AS TEXT)`, [`sync/lib/chart-countries.js`](sync/lib/chart-countries.js) (`loadCountriesByItunesId` plus a JavaScript filter), or page podcasts by `id` and normalize IDs in JavaScript. This applies to stages 2, 3, 4, 5, and remediation. -- Keep credentials and environment files out of Git, including `.env`, Podcast Index keys, Turso tokens, and Telegram secrets. +- Keep credentials and environment files out of Git, including `.env`, Podcast Index keys, Turso tokens, and alerting secrets. Use Node.js 22.13 or newer to match the runner and the script workflows. From the repository root: @@ -107,13 +107,13 @@ The local updater fixture accepts `--notes-file` for long UTF-8 sample release n ### Check New Episodes -The checker runs **hourly on the VPS** (netcup-panel `stack/notifier`: a systemd timer runs this script from the sync runner's copy, with its own Firebase service account that can only read `tracked_podcasts` and send pushes). GitHub's scheduled runs came every 2–6 hours instead of every 30 minutes. The GitHub workflow that used to run it has been removed. It polls notification registrations independently of catalog sync. +The checker runs hourly on a private runner, outside GitHub Actions, with a Firebase service account that can only read `tracked_podcasts` and send pushes. It polls notification registrations independently of catalog sync. | What | Where | | :--- | :--- | | Script | [`scripts/check-new-episodes.js`](check-new-episodes.js) + [`check-new-episodes-lib.js`](check-new-episodes-lib.js) | -| Who to poll | Firebase RTDB `tracked_podcasts/{podcastIndexId}` (client writes this when **show notifications** are on). Rules allow `title`, `imageUrl`, and optional HTTPS `feedUrl` only — extra children are rejected. Anyone can write these entries, so the checker treats them only as a list of shows to check: pushes never carry a registered title or artwork, and catalog shows never read a registered address. | -| Last-notified state | `NOTIFIER_STATE_FILE` on the VPS (`/var/lib/boxlore-notifier/episode-tracker.json`, in the daily backup). [`scripts/data/episode-tracker.json`](data/episode-tracker.json) is the last state from GitHub Actions | +| Who to poll | Firebase RTDB `tracked_podcasts/{podcastIndexId}` (client writes this when **show notifications** are on). Entries hold `title`, `imageUrl` and an optional HTTPS `feedUrl`. The checker treats them only as a list of shows to check: pushes never carry a registered title or artwork, and catalog shows never read a registered address. | +| Last-notified state | `NOTIFIER_STATE_FILE` on the runner. [`scripts/data/episode-tracker.json`](data/episode-tracker.json) is the last state from GitHub Actions | | Tests | `npm ci` then `npm run test:check-new-episodes` from `scripts/` | Release selection and recovery: @@ -128,10 +128,10 @@ Release selection and recovery: Working addresses and episode IDs (with the `BOXLORE_SVC_NOTIFIER_KEY` secret; without it the checker behaves as before): -- At the start of a run the checker asks the boxlore API (`POST /internal/notifier/shows`) for each catalog show's working feed address: a confirmed working address, else Podcast Index's (`catalogFeedUrl`). It is used for the RSS check and in the push; Podcast Index fallback pushes carry `feedUrl` too. Without the registry (API down, no key) the address the state last read is kept. The state always records the address it read (`lastFeedUrl`), and the first read of a different address quietly seeds a baseline (`rss-address-changed`), because a moved feed can relabel old episodes. -- Before sending, it asks `POST /internal/notifier/episodes` for the episode's served ID, the ID that never changes, and puts it in `episodeId` and `route`. With none (a feed-only episode nobody has been served yet) or when the API fails, the push opens the show instead. +- At the start of a run the checker asks the boxlore API's notifier registry for each catalog show's working feed address: a confirmed working address, else Podcast Index's (`catalogFeedUrl`). It is used for the RSS check and in the push; Podcast Index fallback pushes carry `feedUrl` too. Without the registry (API down, no key) the address the state last read is kept. The state always records the address it read (`lastFeedUrl`), and the first read of a different address quietly seeds a baseline (`rss-address-changed`), because a moved feed can relabel old episodes. +- Before sending, it asks the API for the episode's served ID, the ID that never changes, and puts it in `episodeId` and `route`. With none (a feed-only episode nobody has been served yet) or when the API fails, the push opens the show instead. - Every state change is saved, including `unchanged` decisions that moved a key forward. The state file is written even when a run stops part-way, the commit step always runs, and a failed push leaves that show's state alone so it is retried (the run then shows as failed). -- **A stuck show can't hold the run (31 Oct 2026).** On 30 Oct a feed host's broken compressed copy left Node's fetch waiting forever, so every hourly run hung until systemd's 45-minute limit killed it, before the end-of-run save, and the same pushes went out again each hour. Now each show's state is written the moment it changes (`writeStateFile`: a temporary file, then a rename), so even a killed run never resends. Each show gets `NOTIFIER_SHOW_LIMIT_SEC` (default 180) and is then skipped until the next run (`withDeadline`, `checkEachShow`). No new show starts after `NOTIFIER_RUN_BUDGET_MIN` (default 40), so the run ends inside the limit. Feeds are requested uncompressed (`Accept-Encoding: identity`). Tests: the "a stuck show never holds the run" suite in `check-new-episodes-lib.test.js`. +- **A stuck show can't hold the run.** Each show's state is written the moment it changes (`writeStateFile`: a temporary file, then a rename), so a killed run never resends. Each show gets `NOTIFIER_SHOW_LIMIT_SEC` (default 180) and is then skipped until the next run (`withDeadline`, `checkEachShow`). No new show starts after `NOTIFIER_RUN_BUDGET_MIN` (default 40), so a run ends inside the runner's time limit. Feeds are requested uncompressed (`Accept-Encoding: identity`). Tests: the "a stuck show never holds the run" suite in `check-new-episodes-lib.test.js`. - New-episode pushes only go to `new_ep_` and `new_ep_rss_…` topics; broadcast topics are refused. - A manual run with `dry_run` decides and logs (never feed addresses) without sending or saving anything. @@ -149,8 +149,8 @@ Device RTDB rows use `rss:~~`. Group only ma Before mutation, [`backfill-tracked-podcast-feeds.js`](backfill-tracked-podcast-feeds.js) saves numeric registrations and aggregated public RSS show metadata to [`data/tracked-podcasts-backups/`](data/tracked-podcasts-backups/), retaining one snapshot per UTC ISO week for the latest 10 weeks. The workflow must successfully commit a changed snapshot to `master` before repairing rows. Backups preserve every accepted public URL scope without publishing device registration IDs. -Repair first reads the feed-address registry for every tracked catalog show (`/internal/notifier/shows`, with the read-only `BOXLORE_SVC_NOTIFIER_KEY` secret). It changes two kinds of rows: -- **Missing:** no valid HTTPS `feedUrl`. Uses the registry's HTTPS address when it has one. Otherwise it uses the authenticated boxlore `/podcast` endpoint (with the scoped read-only `BOXLORE_SVC_BACKFILL_KEY` secret when set, otherwise the legacy app key), probes HTTPS upgrades for legacy HTTP feeds, and falls back to an exact-title Apple directory match. +Repair first reads the feed-address registry for every tracked catalog show (with the read-only `BOXLORE_SVC_NOTIFIER_KEY` secret). It changes two kinds of rows: +- **Missing:** no valid HTTPS `feedUrl`. Uses the registry's HTTPS address when it has one. Otherwise it uses the boxlore API's show details (with the read-only `BOXLORE_SVC_BACKFILL_KEY` secret), probes HTTPS upgrades for legacy HTTP feeds, and falls back to an exact-title Apple directory match. - **Dead:** the registry proved the saved address dead and confirmed a different working one. This is the same rule the API uses to repair show pages. Each write is a transaction on the `feedUrl` leaf that commits only while the leaf still holds the value this run read. Newer app writes, `title` and `imageUrl` are always preserved. The pre-repair snapshot from the same run holds the old addresses. Without the registry, the run only fills missing addresses. Unresolved rows are logged and retried next run. RSS rows are skipped rather than searched in the catalog.