Skip to content

Latest commit

 

History

History
149 lines (125 loc) · 35.6 KB

README.md

File metadata and controls

149 lines (125 loc) · 35.6 KB

:core:database

Purpose

Owns the main Room database, entities, DAOs, type converters, and migrations for podcasts, queue items, listening history, downloads, RSS episodes, PI episode supplements (cutover dual-read), the first-class local episode catalog (local_episode_feeds / local_episodes), and episode identity storage: aliases for every stored episode ID, shared like and completion state, and per-show progress of the one-time feed address repair (see docs/EPISODE_IDS.md). It does not own repositories, ranking's separate Room database, playback services, download workers, or feature UI.

Public API

  • AutoDownloadDao.seedExisting seeds both local_episodes and rss_episodes in the activation transaction. RssEpisodeDao.getByAudioUrl supports exact push hydration when a GUID is unavailable. Existing tables/keys/schema version are unchanged; RSS_REFRESH_AUTOMATIC is a new value in the existing freshness capability field.

  • PodcastDao.getRssNotificationRows includes every canonical rss: row, including disabled/unsubscribed rows and stale source metadata, so notification registration migration can clean up device-owned remote state. This adds no schema migration.

  • PodcastScoring provides interpretable listening, like, freshness, preference, and optional subscription-recency signals. Canonical Your Shows ranking disables its legacy subscription-recency term so :core:ranking can apply one bounded recency policy after normalization; other callers retain the existing 600 / (1 + hours/24) default.

  • BoxLoreDatabase and its getDatabase factory.

  • Entities: PodcastEntity, ListeningHistoryEntity, ListeningSessionEntity, ListeningRollupEntity, DownloadedEpisodeEntity, RssEpisodeEntity, EpisodeSupplementEntity, EpisodeSupplementItemEntity, LocalEpisodeFeedEntity, LocalEpisodeEntity, FolderEntity, PodcastFolderCrossRef, AutoDownloadShowEntity, AutoDownloadReleaseEntity, EpisodeAliasEntity, EpisodeGroupStateEntity, UpgradeRepairShowEntity, entities.QueueItem, and entities.QueueMetadataEntity. String values for alias states, alias provenance and repair states live in EpisodeAliasState, EpisodeAliasProvenance and UpgradeRepairState; stored values are never renamed.

  • EpisodeListenerStore (from BoxLoreDatabase.episodeListenerStore(), so the playback service reaches it without AppContainer) keeps one listener state per episode across its stored IDs (docs/EPISODE_IDS.md §5). An EpisodeGroup is every trusted alias of one (show, catalog key), owner first; any other ID is a group of its own and reads and writes exactly as before aliases existed. It works out the owner (the stored mark while that ID holds listener state, else the v39 upgrade's order through EpisodeIdentityDao.chooseOwner) and stores it in the transaction of the write that needs it (historyId). Reads project (EpisodeListenerProjection): the owner's row is the base, and progress, position and play time come from the most recently played row (the owner's, unless a pull, a restore or older-build data put later listening under another ID; an owner that holds only a like never hides another ID's progress); liked and completed are the latest decision across the rows and episode_group_state, a group setting winning a tie, so an older alias row can't bring back a cleared like or completion; an episode with one stored ID reads its own row unchanged. projectRows, projectedRows and projectCompletedIds count each episode once (the shared-alias query is watched as a Flow, so episodes with one ID cost nothing extra per emission); projectCompletedIds also counts an episode that only its group settings mark completed. projectRowsByMember, projectedRowsByMember and projectedLikedIds list an episode once per stored ID, for screens that look an episode up by the ID they show. lastPlayedSession is the projected last session for restores and Android Auto. Writes: setLiked writes an absolute like to every trusted ID and the group settings (an episode with one stored ID keeps the clock's time on its row, as before); recordLike, recordCompletion and the batched recordCompletions record a decision after the caller wrote the owner's row (an episode with one ID and no row records nothing); historyIds works out owners in batches; removeGroupState, restoreGroupState and clearGroupStates remove an episode's settings with its history and put them back on Undo; downloadHolder(s) names the ID whose completed download plays an episode; memberIds, withAllAliases, groupTokens and queueIdentities serve the queue, Smart Queue, Smart Downloads, sync and ranking; queueRemovalIds names the IDs a queue removal records: the removed ones and every other trusted ID of their episodes, queued here or not (a cloud sync pull keeps one queue entry per episode, so the cloud can still hold another), except the IDs of an episode that stays queued or plays; withAllAliasesFlow, memberIdsFlow and aliasGroupsFlow expand IDs to every trusted ID of their episodes in memory from the watched shared aliases, so download and queue indicators read an episode under the ID a screen shows. ownerRow gives a new history row written under another ID of the playing episode (its owner) the owner's own show (its stored row, else its catalog row, else its alias), and keepingOwnerMetadata keeps a stored owner row's show and episode metadata when another ID's page saves progress, so the owner row of a show switched to its feed stays under the Podcast Index show it belongs to. showReplacementsFlow maps each Podcast Index show a followed RSS show replaced (and the listener no longer follows) to that RSS show, for listening read by show. Like times in the group settings stay monotonic within an episode even when the clock reads earlier than a recorded decision.

  • DAOs: EpisodeIdentityDao (alias and group-state reads and writes, upsertGroupStates, getGroupStates, completedGroupStates, deleteGroupState, deleteAllGroupStates, sharedTrustedAliases / sharedTrustedAliasesFlow, chooseOwner (the upgrade's owner order, read at runtime), setOwner, holdsListenerState, and allAliases / allGroupStates for the JSON library backup's identity block; when ownership may move and which states resolve a lookup belong to EpisodeListenerStore), UpgradeRepairDao (pending shows, per-show progress, hasShows for whether the upgrade seeded any, and showCounts (UpgradeRepairShowCounts, a query result) for the upgrade report), EpisodeIdentityDao.knownRecipeIds (analytics: an ID's own recipe ID or a trusted keyed alias's, never a guess; EpisodeRecipeIdReadsTest), PodcastDao, ListeningHistoryDao, ListeningSessionDao, ListeningRollupDao, DownloadedEpisodeDao, RssEpisodeDao, EpisodeSupplementDao, LocalEpisodeCatalogDao, FolderDao, and dao.QueueDao. PodcastDao.upsert and LocalEpisodeCatalogDao.upsertFeed keep a stored row's Room v39 columns (see Version 39 below). ListeningHistoryDao.insertIfAbsent lets service-only playback establish a row without replacing concurrent UI/history state, enrichMetadataIfMissing fills incomplete service seeds without touching progress or likes, completeFromPlayback atomically updates only playback-owned completion columns, reopenProgress resets completion and advances progress for intentional replays, and updateLastPlayedAt refreshes session recency. ListeningHistoryDao.getHistoryItems, setLikeStatusForIds and setCompletionForIds and DownloadedEpisodeDao.getCompletedDownloadIds, getDownloads, countCompletedFlow and countActiveFlow read and write an episode's rows under all its IDs. PodcastDao.observeSwitchedRssShows watches the followed RSS shows that replaced a Podcast Index show the listener no longer follows. PodcastDao.markPodcastSyncedIfUnchanged, ListeningHistoryDao.markHistorySyncedIfUnchanged, and QueueDao.markQueueSyncedIfSequenceMatches provide multi-column optimistic concurrency flag clearing during cloud sync. ListeningHistoryDao.getDirtyListeningHistoryBatch reads one push batch: up to the given number of unsynced rows, most recently played first, without episode descriptions, so a large backlog is never read in full. PodcastDao.getDirtyCountFlow and ListeningHistoryDao.getDirtyCountFlow observe pending unsynced changes to drive reactive cloud sync ignition. PodcastDao.clearAllSubscriptionsForAccountSwitch cleanly purges subscriptions for account switches without wiping cached episodes. Routine progress updates ignore already-completed rows so a delayed snapshot cannot undo terminal state. PodcastDao.setFeedUrl writes a publisher HTTPS URL found on the device and marks the row dirty so the next sync push sends it; an unchanged address writes nothing. App code replaces a saved address only through PodcastDao.replaceFeedAddress(FeedAddressWrite) (P4 PR 14, used by :core:catalog's FeedAddressUpdater): one transaction that writes feedUrl and transportUrl, keeps the old address in previousFeedUrl, records feedUrlSource and feedUrlCheckedAt, clears the show's ETag and Last-Modified on its row, its local_episode_feeds row and its episode_supplements row, and marks the row dirty unless the address came from the cloud. It is a compare-and-set on the address the caller read (feedUrl IS :expected) and never touches rss: rows. PodcastDao.markFeedUrlChecked stamps the daily address check without marking the row dirty. No schema change. Listening sessions can reassign an episode id while preserving stable session UUIDs; catalog restore repair merges rollup primary-key collisions before deleting the legacy episode id. Session/rollup DAOs can also reassign a podcast id for the exact legacy RSS repair. EpisodeSupplementDao.listSupplements is the export source for Missing episodes? opt-ins during cutover. LocalEpisodeCatalogDao pages the first-class PI local catalog (sticky episodeId, unique (podcastId, guid)); findOwners reports which (show, key) holds each episode ID so writers check before upsertEpisodes replaces a row. LocalEpisodeCatalogDao.getEpisodesAfter and RssEpisodeDao.getEpisodesAfter query chronological forward-continuation episodes for same-show queue playback. Unsubscribe sets ttlExpiresAt; a non-null TTL is not catalog-ready for live lists. deleteCatalogIfExpired rechecks ttlExpiresAt in the same transaction so a refresh that cleared TTL after the expiry scan is not deleted. Migration 31→32 copies extras keeping episodeIds and does not drop supplement tables. Migration 32→33 adds customGenre and customGenreIcon columns to podcasts for subscribed podcast tag and icon overrides. Migration 33→34 creates folders and podcast_folder_cross_ref tables with CASCADE on folderId so deleting folders never deletes shows. Migration 34→35 adds showPodcastGrid column to folders. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 adds chaptersUrl and transcriptUrl columns to downloaded_episodes for offline chapter and transcript persistence. Migration 36→37 adds unsubscribedAt, isDirty, and syncedAt columns to podcasts with index on isDirty for subscription tombstoning; adds likedAt column to listening_history with indices on isDirty and lastPlayedAt for decoupled likes and delta tracking; and creates queue_metadata table for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones. LocalEpisodeCatalogDao.previousFeedUrl reads podcasts.previousFeedUrl, so a catalog read still heals legacy rows minted under the address the saved one replaced (own-IDs P4 PR 14).

  • EpisodeSupplementDao.replaceAll / upsertSupplementAndOptionalItems keep validator rows and tip items in one Room transaction.

  • LocalEpisodeCatalogDao writes the v39 feed-first columns on their own (P4 PR 16; no schema change): setArchiveComplete, setLastOpenedAt, and setTtlAndFollowed, which follow and unfollow use so followed stays in step with the unsubscribe TTL. listUnfollowedCopies (UnfollowedCatalogCopy: last opened, last read, episode count) lists the copies the show page's cleanup may remove: followed by neither local_episode_feeds.followed nor podcasts.isSubscribed, and without an unsubscribe TTL. deleteCatalogIfUnfollowed checks that again in its transaction and deletes only the show's local_episodes and local_episode_feeds rows. listClearableCopyIds / deleteCatalogIfClearable (P4 PR 20) are the listener's manual clear: the same two records, but with or without an unsubscribe TTL, so a recently unfollowed show goes too, and again checked inside the delete's transaction. seedFollowed sets a new feed row's followed from podcasts.isSubscribed (a show followed before its first read), and searchLimited is search with a LIMIT for the show page.

  • QueueDao.bumpQueueVersion keeps queue_metadata.recentRemovedEpisodes (a JSON list of QueueRemovalIdentity: ID, recipe ID, show and catalog key, written with hand-named fields) in step with the plain recentRemovedEpisodeIds. A removal adds one entry per removed ID, with the episode's identity when the caller passes it; restoring the same episode under any ID (same ID, or same show and key) drops its entry, and a removal of an episode that stays queued under another ID isn't recorded there. Both lists keep the newest 50. QueueDao.richRemovalsWithin keeps the rich list in step after a pulled queue state replaced the plain list: only entries the plain list still holds and whose episode isn't queued again. Cloud sync pushes the rich list with the queue and pulls the server's (:core:catalog sync.QueueSyncResolver).

  • PodcastScoring.calculateScores takes an optional episodeKey, so :core:ranking can count an episode stored under several IDs once; the default keeps each ID its own episode. ScorablePodcast.listenerShowIds (filled by toScorable from Podcast.listenerShowIds and PodcastEntity.listenerShowIds) lets an RSS show the listener switched to from a Podcast Index show count that show's history, unless that show is scored itself. sortedByListening orders shows for Most listened the same way.

  • ListeningInsightsMaintenance for rolling session/rollup maintenance (via BoxLoreDatabase.listeningInsightsMaintenance()).

  • ListeningRollupMerge for pure session→rollup merge logic.

  • Converters for Room type conversion.

  • Version 38 adds auto_download_shows activation boundaries, a durable auto_download_releases pending/handled/removed ledger, and downloaded_episodes.downloadOrigin. Existing non-smart downloads migrate to protected unknown provenance; existing smart downloads retain smart. Migration seeds currently cached episodes as handled, preserving all episode IDs and existing audio. AutoDownloadDao atomically changes show settings and activation state and retains removal tombstones after download rows are deleted.

  • Version 39 adds episode identity storage and the columns the feed-first show page and address repair need. EpisodeListenerStore reads and writes the aliases and group state, and the local catalog refresh in :core:rss reads and writes the aliases and the episode namespaces; nothing reads the feed-first and repair columns yet. MIGRATION_38_39 (EpisodeIdentityMigration) only adds: no existing row's ID or column value changes, and rss_episodes, the supplement tables and their CHECK(id < 0) constraints are left untouched.

    • New tables: episode_aliases (aliasId primary key, podcastId, catalogKey, recipeId, state, owner, provenance, createdAt; indexed on show + key and show + recipe ID; no foreign key, so LocalEpisodeCatalogDao.deleteCatalog leaves aliases in place), episode_group_state (shared like and completion settings per show + key; empty after the upgrade) and upgrade_repair_shows (steps 1–3, attempts, state and the server's answer per followed show).
    • New columns, each with a default: podcasts.epNs, feedUrlSource, feedUrlCheckedAt, transportUrl, previousFeedUrl; local_episode_feeds.followed, lastOpenedAt, archiveComplete, epNs, showMetadata, showMetadataAt; local_episodes.legacy; queue_metadata.recentRemovedEpisodes (rich queue removals beside the unchanged recentRemovedEpisodeIds).
    • Seeding:
      • local_episodes.legacy has the schema default 1, so every existing catalog row reads as legacy without being rewritten (catalog rows carry long descriptions; rewriting them all would copy the whole table through the journal). Room binds the field on every write, so rows the app writes store 0, and a row replaced from a feed read loses legacy, which is intended: its key now comes from the feed. Raw SQL that inserts into local_episodes must set it.
      • Each Podcast Index show's epNs is set to its show ID, and local_episode_feeds.followed copies the follow state.
      • Each stored episode ID of a Podcast Index show gets one alias (EpisodeAliasSeeding). Catalog rows are stored under their key re-trimmed with the recipe set, with the recipe ID from EpisodeRecipeId in :core:model. A row that already holds a recipe-range ID keeps it, as the episode's own alias when it equals the computed recipe ID and as ambiguous otherwise. A catalog row whose ID history, the queue or downloads hold under another show is ambiguous too: builds before the collision guard let a show that reads the same feed take another show's rows. Synthetic id:<own ID> keys, phone-minted (negative) IDs keyed by their own audio address (the GUID may be hidden, and its saved item is deleted once Podcast Index lists the episode), and IDs found only in history, the queue or downloads are unresolved until the show's feed is read. briefing_* IDs and rss: shows get no alias.
      • One trusted alias per show + key is marked owner: the latest listening (a history row with no play or like time counts as none), else a completed download, else the ID that equals its recipe ID (a served ID), else the oldest catalog row.
      • Each followed Podcast Index show gets a pending repair row; a fresh install starts with none.
    • Whole-row writers keep the v39 columns of a stored row, so the one-time seed survives catalog refreshes, subscribe, restore and sync. LocalEpisodeCatalogDao.upsertFeed keeps followed, lastOpenedAt, archiveComplete, epNs, showMetadata and showMetadataAt. PodcastDao.upsert always keeps epNs, and keeps feedUrlSource, feedUrlCheckedAt, transportUrl and previousFeedUrl while the write leaves feedUrl unchanged (they describe that address). A new row takes the values it is given. These columns change only through their own updates; replaceFeedRow and upsertRow write every column as given and exist for those two methods. Follow and unfollow don't update followed yet, so whatever reads it first must also write it.
    • Catalog reads use version 39 without a schema change: new episodes of Podcast Index shows take recipe IDs, and the refresh records an alias for every catalog ID it writes, reserved IDs included, plus the provenance of each stored key it heals (EpisodeAliasProvenance.FEED_RECIPE, FEED_RESERVED, FEED_STORED_ROW, HEAL_RETRIMMED_KEY, HEAL_SYNTHETIC_KEY, HEAL_AUDIO_URL_KEY), settles unresolved aliases it can attribute (FEED_CONFIRMED_KEY, FEED_OWN_MINT, FEED_LISTENER_MATCH), and the legacy RSS subscription upgrade records RSS_FOLLOW_UPGRADE. The orphan rematch records ORPHAN_REMATCH for a moved ID and CATALOG_KEY_MATCH for a catalog row that joins an evicted episode, and switching a Podcast Index show to its RSS feed records RSS_SOURCE_LINK for the feed's episodes (trusted only for an exact, unique key) instead of moving listener state; see :core:rss. DAO additions: EpisodeIdentityDao.aliasesForShow, PodcastDao.getEpNs, and PodcastDao.setEpNsIfUnset / LocalEpisodeCatalogDao.setEpNsIfUnset, which set a namespace only where none is stored (never for rss: shows, and never marking the podcast dirty).
    • The JSON library backup leaves the new podcasts columns out of its export (LibraryBackupFieldPolicy in :core:catalog). Backup version 7 carries episode_aliases and episode_group_state in an identity block of its own, read through EpisodeIdentityDao.allAliases and allGroupStates (no schema change).

Internal structure

src/main/java/cx/aswin/boxlore/core/database/
  BoxLoreDatabase.kt
  Converters.kt
  DownloadedEpisodeDao.kt
  DownloadedEpisodeEntity.kt
  ListeningHistoryDao.kt
  ListeningHistoryEntity.kt
  ListeningInsightsMaintenance.kt
  ListeningRollupDao.kt
  ListeningRollupMerge.kt
  ListeningSessionDao.kt
  PodcastDao.kt
  PodcastEntity.kt
  RssEpisodeDao.kt
  RssEpisodeEntity.kt
  EpisodeSupplementDao.kt
  EpisodeSupplementEntity.kt
  EpisodeSupplementItemEntity.kt
  LocalEpisodeFeedEntity.kt
  LocalEpisodeEntity.kt
  LocalEpisodeCatalogDao.kt
  EpisodeAliasEntity.kt         # alias entity plus state and provenance values
  EpisodeAliasSeeding.kt        # which alias the v39 upgrade seeds for each stored ID (pure)
  EpisodeGroupStateEntity.kt
  EpisodeIdentityDao.kt
  EpisodeIdentityMigration.kt   # migration 38→39
  EpisodeGroup.kt               # every trusted ID of one episode, owner first
  EpisodeListenerProjection.kt  # projected like, completion and progress (pure)
  EpisodeListenerStore.kt       # owner choice, projected reads, absolute writes
  UpgradeRepairShowEntity.kt
  UpgradeRepairDao.kt
  dao/
    QueueDao.kt
  entities/
    QueueItem.kt
    QueueMetadataEntity.kt
    QueueRemovalIdentity.kt     # rich queue removal entries (JSON)

Dependencies

  • Project dependencies: :core:model, :core:network.
  • Libraries: Room runtime, Room KTX, Room compiler through KSP, and Gson.
  • Reverse-edge rule: database must not depend on catalog, playback, downloads, designsystem, or feature modules.

Threading / lifecycle

  • BoxLoreDatabase.getDatabase is a process singleton created from application wiring.
  • DAO operations expose suspend functions and flows; callers should keep large database work off the main thread.
  • Feature modules should depend on ports or repositories rather than injecting BoxLoreDatabase directly.
  • Completed episode ids are read in pages of 1,000 inside one transaction (ListeningHistoryDao.getCompletedEpisodeIds). The Flow re-reads them on any listening_history change. A single cursor over tens of thousands of ids failed with "Couldn't read row … from CursorWindow".
  • FolderDao.addPodcastToFolderIfExists and setPodcastsForFolder do nothing when the folder no longer exists, so a link racing a folder deletion cannot violate the cross-ref foreign key.
  • BoxLoreDatabase.getDatabase re-checks the instance inside its lock so two threads never open separate Room instances on the same file.
  • Migrations run on the first open after an update, from whichever caller opens the database first. That can be the playback service (PlaybackHistoryStore) with no AppContainer, so a migration may only use the database and :core:model (the 38→39 seeding uses EpisodeRecipeId from there). The 38→39 upgrade rewrites no catalog row; on the JVM it took about 0.4 s for 20,000 catalog rows with 2–20 KB descriptions (a 240 MB database), where rewriting every row to mark it legacy had copied about 240 MB through the journal.

Persistence & identity

  • Room filename boxlore_database stores user data and must remain stable.
  • Legacy database rename behavior preserves installs that used boxcast_database.
  • BriefingArtworkCompatibility repairs legacy numeric Brief artwork references on open and on later history, queue and download writes (including cloud/backup restores). Region aliases resolve to the shared named-resource contract in :core:model. Only artwork columns change; progress, likes, audio, queue order and sync flags are preserved. Conditional SQLite triggers are idempotent and installed after schema migrations; schema version and table identities stay unchanged.
  • Entity/table schemas and migrations are release-critical persisted contracts. Never edit a migration or an exported schema once a phone may have run it: Room refuses to open a database whose schema hash differs at the same version. Later schema changes go to a new version (v40 and up).
  • BoxLoreDatabase.builder is the one database configuration, used by getDatabase and the upgrade tests. Upgrades from version 12 on run BoxLoreDatabase.ALL_MIGRATIONS; a version with no path fails to open with an error instead of silently erasing history, queue, downloads and likes. Versions 1–11, which predate every migration, and opening a newer file with an older build (a downgrade) still recreate the database empty.
  • EpisodeAliasProvenance.ORPHAN_REMATCH (orphan_rematch) marks a phone-minted ID whose state the orphan rematch moved onto a catalog row it matched by its exact enclosure address. EpisodeAliasProvenance.CATALOG_KEY_MATCH (catalog_key_match) marks a catalog row's ID that joined an episode because a trusted alias already held its (show, key) under another ID (the episode's row was evicted and the feed read again). EpisodeAliasProvenance.RSS_SOURCE_LINK (rss_source_link) marks an RSS episode ID of the feed a Podcast Index show was switched to, recorded under the Podcast Index show as a non-owner (ownership then follows the owner rule): trusted when an old ID's trusted key is the RSS episode's key and no other RSS episode has it, or an old ID's exact audio address names it alone, ambiguous otherwise until a later link finds exact evidence. EpisodeAliasProvenance.RSS_SOURCE_AUDIO_MATCH (rss_source_audio_match) marks an old ID of the Podcast Index show, with no usable key before, that such a link settled by its exact audio address. EpisodeAliasProvenance.CLOUD_SYNC (cloud_sync) marks an ID a cloud sync pull recorded: trusted when the pulled row named, by catalog key (else by a recipe ID exactly one key of the show has), an episode this phone already holds under a trusted ID; unresolved when the phone doesn't know the episode yet. Such an alias is never the owner when it is recorded. Stored provenance and state strings are never renamed.
  • EpisodeAliasProvenance.RENUMBERED_SHOW_MATCH (renumbered_show_match) marks an ID of a Podcast Index show that Podcast Index renumbered, with no usable key before (no alias, or an unresolved one under the old show), that joined one stored episode of the show it now answers as: recorded trusted under the new show with that episode's key, as a non-owner, while its history, queue and download rows keep their ID and old show (docs/EPISODE_IDS.md, RenumberedShowLinker in :core:rss). It also marks the trusted aliases of an old show's (show, key), the shape of a show whose catalog was read before the renumbering, moved to the new show's (show, key) with that episode's recipe ID, as non-owners. PodcastDao.moveRenumberedFollow { … } runs a follow move in one transaction: the caller reads both rows, merges them and writes them through upsert (each keeps its stored namespace) with no other writer in between; LocalEpisodeCatalogDao.listForShow reads the identity, title and date of one show's stored rows for that link (LocalEpisodeLinkRow, no descriptions), and PodcastDao.getSubscribedRssPodcastLinkedTo finds the followed rss: feed linked to a show even when an unfollowed one is linked too. DAO additions only; Room stays at version 39.
  • Episode aliases, group state, the legacy mark and alias provenance are permanent: old local IDs (Podcast Index, phone-minted negative and recipe IDs) keep resolving through them after their catalog rows are evicted. Alias catalogKey values are the recipe-trimmed keys of docs/EPISODE_IDS.md.
  • Package root is cx.aswin.boxlore.core.database.
  • Ranking persistence is owned by :core:ranking in adaptive_ranking_database.

Testing notes

  • Unit tests live under core/database/src/test.
  • BriefingArtworkCompatibilityTest covers upgrade/reopen, restored rows, all region aliases, recursive-trigger safety, unchanged non-Brief artwork and preservation of playback/download/queue state. Opening recreates trigger definitions transactionally, including after a database comes from another application variant, so future restores use the current authority and aliases.
  • PodcastDaoExtendedTest.setFeedUrlMarksACorrectedAddressForTheNextPush covers a corrected address marking the row dirty and an unchanged one staying clean.
  • PodcastFeedAddressDaoTest covers replaceFeedAddress: both addresses, the kept old address and every validator of the show cleared together (other shows untouched), a row changed since it was read and rss: rows left alone with nothing cleared, a missing address filled, a cloud address leaving the row clean, undo, the daily-check stamp (kept by writes the registry didn't confirm), markFollowedDirty for followed rows only, and upsertKeepingFeedAddress keeping an address saved after the merged copy was read.
  • PodcastDaoInMemoryTest verifies the in-memory Room DAO path and RSS notification cleanup selection, including disabled/unsubscribed rows with stale source metadata, and that setEpNsIfUnset never replaces a stored namespace or sets one for an rss: show. LocalEpisodeCatalogDaoOwnersTest covers the catalog's setEpNsIfUnset and upsertFeed keeping it; EpisodeIdentityDaoTest covers aliasesForShow.
  • ListeningRollupMergeTest covers session→rollup merge, including the empty-sessions guard. ListeningHistoryDaoInMemoryTest covers insert-if-absent conflict safety and playback completion updates preserving listener-owned fields such as likes, and that a push batch reads only its own rows, newest first and without descriptions. EpisodeListenerStoreTest also covers which IDs a queue removal records. ListeningHistoryDao.repairPlaybackArtworkLinks and QueueDao.repairPlaybackArtworkLinks swap player-only .collage artwork links saved by older builds for the episode's cover (local, then RSS catalogue), else the show's, else the row's own show cover, else null. A history row is marked dirty only when every dead link found a real cover, so an emptied field never overwrites a cover another device uploaded; QueueDao.repairPlaybackArtworkLinksForSync marks a changed queue dirty in the same transaction without changing its sequence, so the cloud copy drops the dead links, and its order is untouched. ListeningHistoryDaoInMemoryTest and QueueDaoInMemoryTest cover each source, the row's own show cover, emptied rows staying clean, the queue marked dirty only when a row changed, untouched rows and a second run changing nothing.
  • RoomV39UpgradeTest builds databases from the exported 36, 37 and 38 schemas, and a phone that came up through the older migrations (from 30, with the CHECK constraints, queue_metadata SQL defaults and supplement-copied rows), fills them with every kind of stored ID (Podcast Index, negative, served recipe-range IDs in the catalog, history, queue and downloads, SQLite-trimmed, synthetic and address keys, Brief and rss: IDs, reserved IDs from the collision guard), then opens them through BoxLoreDatabase.builder, so Room runs the production migrations and its own schema check. It asserts that every pre-existing value is unchanged, the untouched tables keep their exact SQL, the Brief artwork triggers don't interfere, aliases, owners, namespaces and repair rows are seeded as described above (including IDs held under another show, address keys whose saved item is gone, and each owner rule), eviction keeps aliases, later catalog refreshes and podcast writes keep the seeded columns, a fresh install and an upgraded phone have identical tables, columns, defaults and indices, a failure inside the seeding rolls the file back to v38 and the next open succeeds, a second seeding pass changes nothing, a missing upgrade path fails without erasing data, and downgrades and pre-12 versions still open empty. EpisodeAliasSeedingTest covers every seeding rule without SQLite, including the recipe trim cases; EpisodeIdentityDaoTest covers the new DAOs. RoomSchemaFixture and LegacyRows build these fixtures; reuse them for later upgrades.
  • The shipped-schema fixtures (RoomSchemaFixture, LegacyRows) live in src/upgradeFixtures/java, added to this module's and :core:catalog's test sources, so the catalog's end-to-end upgrade gate (UpgradeGateTest) builds the same v36–v38 databases.
  • EpisodeListenerProjectionTest covers the projection rules without SQLite, including progress from another ID played later and an owner that holds only a like. EpisodeListenerStoreTest covers, through Room: IDs without a shared alias (no alias, unresolved, ambiguous, a single trusted alias) reading and writing exactly as before; a like of an episode with one ID keeping the clock's time on its row; clearing a like and a completion across two IDs; repeated pulls of an older alias row; an alias found later; ownership moving only when the owner holds no state; an owner that already holds a served recipe ID, then a newer pulled row under the other ID; a like-only owner that never hides another ID's progress; a completion only the group settings hold; the download holder; lists, lookups by member, liked IDs, tokens and completed IDs; batched owners and completions; group settings removed and restored; the projected last session; and queue identities; the alias flows, an owner row seeded under the owner's show, and the shows a followed RSS show replaced. SwitchedShowListeningTest covers listenerShowIds, a switched show's score and Most listened counting its old show's rows once, and the in-memory alias expansion. RoomV39UpgradeTest also checks chooseOwner picks the owner the upgrade seeded. QueueRichRemovalsTest and QueueDaoInMemoryTest cover the rich removal list.
  • Migration36To37Test verifies Room schema upgrade from version 36 to 37, adding unsubscribedAt, isDirty, syncedAt to podcasts, likedAt and indices to listening_history, creating queue_metadata, and preserving existing row data.
  • PodcastScoringTest covers subscribe-recency priors (peak, 72h decay, missing subscribedAt) and the recency-free listening prior used by canonical Your Shows scoring.
  • ConvertersEpisodeIdentityTest covers embedded Episode JSON (queue, history and other rows) through Converters' Gson path: rows written before recipeId and catalogKey existed read back unchanged with both null, an episode without them is written without their keys (the bytes an older build reads), and both round trip at full Long precision.
  • EpisodeToDomainArtworkTest covers blank item imageUrl falling back to the show image on RSS and supplement toEpisode.
  • Prefer repository or port fakes for feature tests instead of depending on Room directly.
./gradlew :core:database:testDebugUnitTest
  • ListeningHistoryDaoInMemoryTest reads completed ids across page boundaries.
  • AutoDownloadMigrationTest opens a v37 database through BoxLoreDatabase.builder, so it runs 37→38 and 38→39 as a phone does.
  • The feed-first DAO queries run on real Room in :core:rss ShowPageFeedCatalogTest (cleanup keeps followed shows, history, downloads, the queue and aliases; follow and unfollow write followed).

CI relevance

  • unit-tests.yml runs database JVM tests with the project suite.
  • Compile and KSP failures in this module block app and repository tests that depend on entities or DAOs.

See also

Release optimization

Release checks both generated Room implementations and Gson-backed converter/backup model field names, default constructors and TypeToken signatures. DAOs and unrelated entities remain eligible for R8 optimization; database/storage identities stay unchanged.