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.
-
AutoDownloadDao.seedExistingseeds bothlocal_episodesandrss_episodesin the activation transaction.RssEpisodeDao.getByAudioUrlsupports exact push hydration when a GUID is unavailable. Existing tables/keys/schema version are unchanged;RSS_REFRESH_AUTOMATICis a new value in the existing freshness capability field. -
PodcastDao.getRssNotificationRowsincludes every canonicalrss: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. -
PodcastScoringprovides interpretable listening, like, freshness, preference, and optional subscription-recency signals. Canonical Your Shows ranking disables its legacy subscription-recency term so:core:rankingcan apply one bounded recency policy after normalization; other callers retain the existing600 / (1 + hours/24)default. -
BoxLoreDatabaseand itsgetDatabasefactory. -
Entities:
PodcastEntity,ListeningHistoryEntity,ListeningSessionEntity,ListeningRollupEntity,DownloadedEpisodeEntity,RssEpisodeEntity,EpisodeSupplementEntity,EpisodeSupplementItemEntity,LocalEpisodeFeedEntity,LocalEpisodeEntity,FolderEntity,PodcastFolderCrossRef,AutoDownloadShowEntity,AutoDownloadReleaseEntity,EpisodeAliasEntity,EpisodeGroupStateEntity,UpgradeRepairShowEntity,entities.QueueItem, andentities.QueueMetadataEntity. String values for alias states, alias provenance and repair states live inEpisodeAliasState,EpisodeAliasProvenanceandUpgradeRepairState; stored values are never renamed. -
EpisodeListenerStore(fromBoxLoreDatabase.episodeListenerStore(), so the playback service reaches it withoutAppContainer) keeps one listener state per episode across its stored IDs (docs/EPISODE_IDS.md§5). AnEpisodeGroupis 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 throughEpisodeIdentityDao.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 andepisode_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,projectedRowsandprojectCompletedIdscount each episode once (the shared-alias query is watched as a Flow, so episodes with one ID cost nothing extra per emission);projectCompletedIdsalso counts an episode that only its group settings mark completed.projectRowsByMember,projectedRowsByMemberandprojectedLikedIdslist an episode once per stored ID, for screens that look an episode up by the ID they show.lastPlayedSessionis the projected last session for restores and Android Auto. Writes:setLikedwrites 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,recordCompletionand the batchedrecordCompletionsrecord a decision after the caller wrote the owner's row (an episode with one ID and no row records nothing);historyIdsworks out owners in batches;removeGroupState,restoreGroupStateandclearGroupStatesremove 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,groupTokensandqueueIdentitiesserve the queue, Smart Queue, Smart Downloads, sync and ranking;queueRemovalIdsnames 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,memberIdsFlowandaliasGroupsFlowexpand 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.ownerRowgives 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), andkeepingOwnerMetadatakeeps 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.showReplacementsFlowmaps 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, andallAliases/allGroupStatesfor the JSON library backup's identity block; when ownership may move and which states resolve a lookup belong toEpisodeListenerStore),UpgradeRepairDao(pending shows, per-show progress,hasShowsfor whether the upgrade seeded any, andshowCounts(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, anddao.QueueDao.PodcastDao.upsertandLocalEpisodeCatalogDao.upsertFeedkeep a stored row's Room v39 columns (see Version 39 below).ListeningHistoryDao.insertIfAbsentlets service-only playback establish a row without replacing concurrent UI/history state,enrichMetadataIfMissingfills incomplete service seeds without touching progress or likes,completeFromPlaybackatomically updates only playback-owned completion columns,reopenProgressresets completion and advances progress for intentional replays, andupdateLastPlayedAtrefreshes session recency.ListeningHistoryDao.getHistoryItems,setLikeStatusForIdsandsetCompletionForIdsandDownloadedEpisodeDao.getCompletedDownloadIds,getDownloads,countCompletedFlowandcountActiveFlowread and write an episode's rows under all its IDs.PodcastDao.observeSwitchedRssShowswatches the followed RSS shows that replaced a Podcast Index show the listener no longer follows.PodcastDao.markPodcastSyncedIfUnchanged,ListeningHistoryDao.markHistorySyncedIfUnchanged, andQueueDao.markQueueSyncedIfSequenceMatchesprovide multi-column optimistic concurrency flag clearing during cloud sync.ListeningHistoryDao.getDirtyListeningHistoryBatchreads 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.getDirtyCountFlowandListeningHistoryDao.getDirtyCountFlowobserve pending unsynced changes to drive reactive cloud sync ignition.PodcastDao.clearAllSubscriptionsForAccountSwitchcleanly 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.setFeedUrlwrites 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 throughPodcastDao.replaceFeedAddress(FeedAddressWrite)(P4 PR 14, used by:core:catalog'sFeedAddressUpdater): one transaction that writesfeedUrlandtransportUrl, keeps the old address inpreviousFeedUrl, recordsfeedUrlSourceandfeedUrlCheckedAt, clears the show's ETag and Last-Modified on its row, itslocal_episode_feedsrow and itsepisode_supplementsrow, 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 touchesrss:rows.PodcastDao.markFeedUrlCheckedstamps 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.listSupplementsis the export source for Missing episodes? opt-ins during cutover.LocalEpisodeCatalogDaopages the first-class PI local catalog (stickyepisodeId, unique(podcastId, guid));findOwnersreports which (show, key) holds each episode ID so writers check beforeupsertEpisodesreplaces a row.LocalEpisodeCatalogDao.getEpisodesAfterandRssEpisodeDao.getEpisodesAfterquery chronological forward-continuation episodes for same-show queue playback. Unsubscribe setsttlExpiresAt; a non-null TTL is not catalog-ready for live lists.deleteCatalogIfExpiredrechecksttlExpiresAtin 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 addscustomGenreandcustomGenreIconcolumns topodcastsfor subscribed podcast tag and icon overrides. Migration 33→34 createsfoldersandpodcast_folder_cross_reftables withCASCADEonfolderIdso deleting folders never deletes shows. Migration 34→35 addsshowPodcastGridcolumn tofolders. Migration 33→35 provides direct upgrade from version 33. Migration 35→36 addschaptersUrlandtranscriptUrlcolumns todownloaded_episodesfor offline chapter and transcript persistence. Migration 36→37 addsunsubscribedAt,isDirty, andsyncedAtcolumns topodcastswith index onisDirtyfor subscription tombstoning; addslikedAtcolumn tolistening_historywith indices onisDirtyandlastPlayedAtfor decoupled likes and delta tracking; and createsqueue_metadatatable for monotonic queue sequence versioning, device origin tracking, dirty sync state, and recent removed episode tombstones.LocalEpisodeCatalogDao.previousFeedUrlreadspodcasts.previousFeedUrl, so a catalog read still heals legacy rows minted under the address the saved one replaced (own-IDs P4 PR 14). -
EpisodeSupplementDao.replaceAll/upsertSupplementAndOptionalItemskeep validator rows and tip items in one Room transaction. -
LocalEpisodeCatalogDaowrites the v39 feed-first columns on their own (P4 PR 16; no schema change):setArchiveComplete,setLastOpenedAt, andsetTtlAndFollowed, which follow and unfollow use sofollowedstays 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 neitherlocal_episode_feeds.followednorpodcasts.isSubscribed, and without an unsubscribe TTL.deleteCatalogIfUnfollowedchecks that again in its transaction and deletes only the show'slocal_episodesandlocal_episode_feedsrows.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.seedFollowedsets a new feed row'sfollowedfrompodcasts.isSubscribed(a show followed before its first read), andsearchLimitedissearchwith aLIMITfor the show page. -
QueueDao.bumpQueueVersionkeepsqueue_metadata.recentRemovedEpisodes(a JSON list ofQueueRemovalIdentity: ID, recipe ID, show and catalog key, written with hand-named fields) in step with the plainrecentRemovedEpisodeIds. 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.richRemovalsWithinkeeps 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:catalogsync.QueueSyncResolver). -
PodcastScoring.calculateScorestakes an optionalepisodeKey, so:core:rankingcan count an episode stored under several IDs once; the default keeps each ID its own episode.ScorablePodcast.listenerShowIds(filled bytoScorablefromPodcast.listenerShowIdsandPodcastEntity.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.sortedByListeningorders shows for Most listened the same way. -
ListeningInsightsMaintenancefor rolling session/rollup maintenance (viaBoxLoreDatabase.listeningInsightsMaintenance()). -
ListeningRollupMergefor pure session→rollup merge logic. -
Convertersfor Room type conversion. -
Version 38 adds
auto_download_showsactivation boundaries, a durableauto_download_releasespending/handled/removed ledger, anddownloaded_episodes.downloadOrigin. Existing non-smart downloads migrate to protectedunknownprovenance; existing smart downloads retainsmart. Migration seeds currently cached episodes as handled, preserving all episode IDs and existing audio.AutoDownloadDaoatomically 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.
EpisodeListenerStorereads and writes the aliases and group state, and the local catalog refresh in:core:rssreads 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, andrss_episodes, the supplement tables and theirCHECK(id < 0)constraints are left untouched.- New tables:
episode_aliases(aliasIdprimary key,podcastId,catalogKey,recipeId,state,owner,provenance,createdAt; indexed on show + key and show + recipe ID; no foreign key, soLocalEpisodeCatalogDao.deleteCatalogleaves aliases in place),episode_group_state(shared like and completion settings per show + key; empty after the upgrade) andupgrade_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 unchangedrecentRemovedEpisodeIds). - Seeding:
local_episodes.legacyhas 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 loseslegacy, which is intended: its key now comes from the feed. Raw SQL that inserts intolocal_episodesmust set it.- Each Podcast Index show's
epNsis set to its show ID, andlocal_episode_feeds.followedcopies 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 fromEpisodeRecipeIdin: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 asambiguousotherwise. A catalog row whose ID history, the queue or downloads hold under another show isambiguoustoo: builds before the collision guard let a show that reads the same feed take another show's rows. Syntheticid:<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 areunresolveduntil the show's feed is read.briefing_*IDs andrss: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.upsertFeedkeepsfollowed,lastOpenedAt,archiveComplete,epNs,showMetadataandshowMetadataAt.PodcastDao.upsertalways keepsepNs, and keepsfeedUrlSource,feedUrlCheckedAt,transportUrlandpreviousFeedUrlwhile the write leavesfeedUrlunchanged (they describe that address). A new row takes the values it is given. These columns change only through their own updates;replaceFeedRowandupsertRowwrite every column as given and exist for those two methods. Follow and unfollow don't updatefollowedyet, 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 recordsRSS_FOLLOW_UPGRADE. The orphan rematch recordsORPHAN_REMATCHfor a moved ID andCATALOG_KEY_MATCHfor a catalog row that joins an evicted episode, and switching a Podcast Index show to its RSS feed recordsRSS_SOURCE_LINKfor 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, andPodcastDao.setEpNsIfUnset/LocalEpisodeCatalogDao.setEpNsIfUnset, which set a namespace only where none is stored (never forrss:shows, and never marking the podcast dirty). - The JSON library backup leaves the new
podcastscolumns out of its export (LibraryBackupFieldPolicyin:core:catalog). Backup version 7 carriesepisode_aliasesandepisode_group_statein an identity block of its own, read throughEpisodeIdentityDao.allAliasesandallGroupStates(no schema change).
- New tables:
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)
- 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.
BoxLoreDatabase.getDatabaseis 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
BoxLoreDatabasedirectly. - Completed episode ids are read in pages of 1,000 inside one transaction (
ListeningHistoryDao.getCompletedEpisodeIds). The Flow re-reads them on anylistening_historychange. A single cursor over tens of thousands of ids failed with "Couldn't read row … from CursorWindow". FolderDao.addPodcastToFolderIfExistsandsetPodcastsForFolderdo nothing when the folder no longer exists, so a link racing a folder deletion cannot violate the cross-ref foreign key.BoxLoreDatabase.getDatabasere-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 noAppContainer, so a migration may only use the database and:core:model(the 38→39 seeding usesEpisodeRecipeIdfrom 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.
- Room filename
boxlore_databasestores user data and must remain stable. - Legacy database rename behavior preserves installs that used
boxcast_database. BriefingArtworkCompatibilityrepairs 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.builderis the one database configuration, used bygetDatabaseand the upgrade tests. Upgrades from version 12 on runBoxLoreDatabase.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,RenumberedShowLinkerin: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 throughupsert(each keeps its stored namespace) with no other writer in between;LocalEpisodeCatalogDao.listForShowreads the identity, title and date of one show's stored rows for that link (LocalEpisodeLinkRow, no descriptions), andPodcastDao.getSubscribedRssPodcastLinkedTofinds the followedrss: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
legacymark 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. AliascatalogKeyvalues are the recipe-trimmed keys ofdocs/EPISODE_IDS.md. - Package root is
cx.aswin.boxlore.core.database. - Ranking persistence is owned by
:core:rankinginadaptive_ranking_database.
- Unit tests live under
core/database/src/test. BriefingArtworkCompatibilityTestcovers 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.setFeedUrlMarksACorrectedAddressForTheNextPushcovers a corrected address marking the row dirty and an unchanged one staying clean.PodcastFeedAddressDaoTestcoversreplaceFeedAddress: both addresses, the kept old address and every validator of the show cleared together (other shows untouched), a row changed since it was read andrss: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),markFollowedDirtyfor followed rows only, andupsertKeepingFeedAddresskeeping an address saved after the merged copy was read.PodcastDaoInMemoryTestverifies the in-memory Room DAO path and RSS notification cleanup selection, including disabled/unsubscribed rows with stale source metadata, and thatsetEpNsIfUnsetnever replaces a stored namespace or sets one for anrss:show.LocalEpisodeCatalogDaoOwnersTestcovers the catalog'ssetEpNsIfUnsetandupsertFeedkeeping it;EpisodeIdentityDaoTestcoversaliasesForShow.ListeningRollupMergeTestcovers session→rollup merge, including the empty-sessions guard.ListeningHistoryDaoInMemoryTestcovers 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.EpisodeListenerStoreTestalso covers which IDs a queue removal records.ListeningHistoryDao.repairPlaybackArtworkLinksandQueueDao.repairPlaybackArtworkLinksswap player-only.collageartwork 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.repairPlaybackArtworkLinksForSyncmarks 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.ListeningHistoryDaoInMemoryTestandQueueDaoInMemoryTestcover 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.RoomV39UpgradeTestbuilds databases from the exported 36, 37 and 38 schemas, and a phone that came up through the older migrations (from 30, with theCHECKconstraints,queue_metadataSQL 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 andrss:IDs, reserved IDs from the collision guard), then opens them throughBoxLoreDatabase.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.EpisodeAliasSeedingTestcovers every seeding rule without SQLite, including the recipe trim cases;EpisodeIdentityDaoTestcovers the new DAOs.RoomSchemaFixtureandLegacyRowsbuild these fixtures; reuse them for later upgrades.- The shipped-schema fixtures (
RoomSchemaFixture,LegacyRows) live insrc/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. EpisodeListenerProjectionTestcovers the projection rules without SQLite, including progress from another ID played later and an owner that holds only a like.EpisodeListenerStoreTestcovers, 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.SwitchedShowListeningTestcoverslistenerShowIds, a switched show's score and Most listened counting its old show's rows once, and the in-memory alias expansion.RoomV39UpgradeTestalso checkschooseOwnerpicks the owner the upgrade seeded.QueueRichRemovalsTestandQueueDaoInMemoryTestcover the rich removal list.Migration36To37Testverifies Room schema upgrade from version 36 to 37, addingunsubscribedAt,isDirty,syncedAttopodcasts,likedAtand indices tolistening_history, creatingqueue_metadata, and preserving existing row data.PodcastScoringTestcovers subscribe-recency priors (peak, 72h decay, missingsubscribedAt) and the recency-free listening prior used by canonical Your Shows scoring.ConvertersEpisodeIdentityTestcovers embeddedEpisodeJSON (queue, history and other rows) throughConverters' Gson path: rows written beforerecipeIdandcatalogKeyexisted 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 fullLongprecision.EpisodeToDomainArtworkTestcovers blank itemimageUrlfalling back to the show image on RSS and supplementtoEpisode.- Prefer repository or port fakes for feature tests instead of depending on Room directly.
./gradlew :core:database:testDebugUnitTestListeningHistoryDaoInMemoryTestreads completed ids across page boundaries.AutoDownloadMigrationTestopens a v37 database throughBoxLoreDatabase.builder, so it runs 37→38 and 38→39 as a phone does.- The feed-first DAO queries run on real Room in
:core:rssShowPageFeedCatalogTest(cleanup keeps followed shows, history, downloads, the queue and aliases; follow and unfollow writefollowed).
unit-tests.ymlruns 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.
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.