# Boxlore architecture Reference for how the Android app is structured: module boundaries, dependency direction, composition root, and upgrade-safe identity. Module-local detail lives in each folder’s `README.md`. ## Overview Boxlore is a multi-module Gradle project. `:app` is the application shell. Shared behavior lives under `:core:*`. Screen and flow UI lives under `:feature:*`. There is no Hilt or Koin: a single `AppContainer` is the composition root. The graph is layered so playback and features depend inward on catalog and lower cores. Features do not depend on other features. Catalog does not depend on the design system. Feature modules never talk to PostHog directly; they use `:core:analytics`. ### Decoupling and module granularity Keep cohesive domains and flows in focused modules: settings belongs in `:feature:settings`, listening history in `:feature:history`, and authentication in `:core:auth`. Avoid placing unrelated capabilities in general-purpose modules. Obtain explicit maintainer approval before extracting modules or changing architectural boundaries. ## Identity and storage These values are part of the shipping product. Renames or recreations break upgrades, WorkManager, Media3, and deep links. | Concern | Contract | | :--- | :--- | | `applicationId` | `cx.aswin.boxlore` | | Code packages / Android namespaces | `cx.aswin.boxlore.*` | | SharedPreferences **files** | Opened as `boxlore_*`; `PrefsFileMigrator` copies from legacy `boxcast_*` when needed. **Key** strings inside those files stay stable. | | DataStore | File name `user_preferences`; the API session uses its own file `boxlore_api_session` (`:core:network`), excluded from backup and device transfer | | API session device key | Android Keystore alias `boxlore_api_session_device_key` (non-exportable EC P-256 that signs session renewals) | | Room (main) | Database filename and tables stay stable. Shipped migrations and exported schemas are never edited; an upgrade with no migration path fails to open instead of erasing data ([`core/database/README.md`](core/database/README.md)) | | Room (ranking) | Separate adaptive database owned by `:core:ranking` | | WorkManager | `LegacyWorkerFactory` plus permanent `core.data.*` stubs resolve historical FQCNs | | Deep links | `boxlore://` and `boxcast://`, plus both HTTPS share-path prefixes. Patterns are only added: episode links may also carry `show=`, which fills the route's `podcastId` | | Episode share registration | Unique work names `episode-share-registration-` (`EpisodeShareRegistrationWorker`); never renamed | | Notification re-registration | Unique work names `podcast-notification-registration-` (`PodcastNotificationRegistrationWorker`, after a saved feed address changes); never renamed | | Feed address repair after the update | Unique work name `feed-address-upgrade-repair` (`UpgradeRepairWorker`); step 4 and sync catch-up record `noBackupFilesDir/upgrade_repair_notifications`; per-show progress in Room `upgrade_repair_shows`; never renamed | | BuildConfig | Prefer `BOXLORE_*`; Gradle still dual-reads `BOXCAST_*` fallbacks | | Episode / media IDs | `rss:` prefixes, negative RSS IDs, mediaId prefixes, and `customCacheKey` schemes stay as implemented. PI **episode supplements** also use negative episode IDs (generated under an `rss:` feed namespace) but keep `Episode.podcastId` as the Podcast Index show id — they are not `rss:` library rows. Episode identity (show ID, catalog key), recipe IDs, collisions and aliases follow [`docs/EPISODE_IDS.md`](docs/EPISODE_IDS.md) | | Playback instance | One UI-scoped `PlaybackRepository`; routes and workers must not construct a second one | | Object graph order | DB → `PodcastRepository` → `QueueRepository` → `PlaybackRepository` → `QueueManager` → `SmartDownloadManager` | | Smart Queue refill | Owned by `BoxLorePlaybackService` only | ## Module map ```text :app :core:model | :core:auth | :core:network | :core:domain | :core:database | :core:prefs :core:analytics | :core:catalog | :core:rss | :core:downloads | :core:playback | :core:ranking :core:designsystem | :core:testing :feature:home | :feature:settings | :feature:history | :feature:player | :feature:info | :feature:explore :feature:library | :feature:onboarding | :feature:briefing | :feature:widgets ``` On disk, the folder path matches the Gradle id (`core/playback` → `:core:playback`). ### Ownership | Module | Responsibility | README | | :--- | :--- | :--- | | `:app` | `Application`, `AppContainer`, navigation host, FCM, `WorkerFactory` | [`app/README.md`](app/README.md) | | `:core:model` | Shared models and enums | [`core/model/README.md`](core/model/README.md) | | `:core:auth` | Authentication domain, `AuthRepository`, and Firebase Auth implementation | [`core/auth/README.md`](core/auth/README.md) | | `:core:network` | HTTP clients (`BoxLoreApi`, `BoxLoreApiV2` for `/v2` / `NetworkModule`) and network DTOs | [`core/network/README.md`](core/network/README.md) | | `:core:domain` | Thin ports and small result types (no Room or repositories) | [`core/domain/README.md`](core/domain/README.md) | | `:core:database` | Main Room database, entities, DAOs, migrations | [`core/database/README.md`](core/database/README.md) | | `:core:prefs` | DataStore and SharedPreferences façades (`UserPreferencesRepository`, `BoxcastPrefs`) | [`core/prefs/README.md`](core/prefs/README.md) | | `:core:analytics` | Analytics façade (`AnalyticsHelper`, `Analytics`, `RecordingAnalytics`); PostHog init stays in `:app`. Event names and properties: [`docs/ANALYTICS_EVENT_GLOSSARY.md`](docs/ANALYTICS_EVENT_GLOSSARY.md) | [`core/analytics/README.md`](core/analytics/README.md) | | `:core:catalog` | Catalog orchestration: `PodcastRepository`, subscriptions, content sections, backup/restore | [`core/catalog/README.md`](core/catalog/README.md) | | `:core:rss` | RSS fetch/parse, `RssPodcastRepository`, PI episode supplement (`EpisodeSupplementRepository`), `rss:` / negative IDs | [`core/rss/README.md`](core/rss/README.md) | | `:core:ranking` | Adaptive scoring, LinUCB, feedback, `AdaptiveRankingDatabase`. Behavior detail: [`docs/recommendation-system.md`](docs/recommendation-system.md) | [`core/ranking/README.md`](core/ranking/README.md) | | `:core:downloads` | `DownloadRepository`, Smart Downloads, related WorkManager workers | [`core/downloads/README.md`](core/downloads/README.md) | | `:core:playback` | `PlaybackRepository`, queue, Media3 services, smart-queue helpers | [`core/playback/README.md`](core/playback/README.md) | | `:core:designsystem` | Theme and shared composables; no data or network ownership | [`core/designsystem/README.md`](core/designsystem/README.md) | | `:core:testing` | Shared fixtures, dispatcher helpers, architecture guards | [`core/testing/README.md`](core/testing/README.md) | | `:feature:home` | Home feed presentation, Daily/Offline mix, and local Debug | [`feature/home/README.md`](feature/home/README.md) | | `:feature:settings` | Unified Settings hub, Account & Cloud Sync, Appearance, Privacy, Playback, and Download policies | [`feature/settings/README.md`](feature/settings/README.md) | | `:feature:history` | Listening history timeline, activity charts, pattern insights, and streak cards | [`feature/history/README.md`](feature/history/README.md) | | `:feature:player` | Player overlay (`PlayerSheetScaffold`); not a NavHost destination | [`feature/player/README.md`](feature/player/README.md) | | `:feature:info` | Podcast and episode detail, including deep links | [`feature/info/README.md`](feature/info/README.md) | | `:feature:explore` | Explore plus Learn / LearnHistory | [`feature/explore/README.md`](feature/explore/README.md) | | `:feature:library` | Library hub, subscriptions, downloads, liked | [`feature/library/README.md`](feature/library/README.md) | | `:feature:onboarding` | First-run flows (AI, genre, search, import) | [`feature/onboarding/README.md`](feature/onboarding/README.md) | | `:feature:briefing` | Daily briefing screen | [`feature/briefing/README.md`](feature/briefing/README.md) | | `:feature:widgets` | Home-screen Now Playing, compact bar, playback-control, subscriptions, and new-episodes widgets (RemoteViews) | [`feature/widgets/README.md`](feature/widgets/README.md) | ### Core package roots | Module | Package | | :--- | :--- | | `:core:prefs` | `cx.aswin.boxlore.core.prefs` | | `:core:analytics` | `cx.aswin.boxlore.core.analytics` | | `:core:rss` | `cx.aswin.boxlore.core.rss` | | `:core:ranking` | `cx.aswin.boxlore.core.ranking` | | `:core:downloads` | `cx.aswin.boxlore.core.downloads` | | `:core:playback` | `cx.aswin.boxlore.core.playback` | | `:core:database` | `cx.aswin.boxlore.core.database` | | `:core:catalog` | `cx.aswin.boxlore.core.catalog` | ## Dependency direction ```mermaid flowchart TB app[:app] subgraph features [feature] home[:feature:home] player[:feature:player] info[:feature:info] explore[:feature:explore] library[:feature:library] onboarding[:feature:onboarding] briefing[:feature:briefing] widgets[:feature:widgets] end playback[:core:playback] catalog[:core:catalog] rss[:core:rss] analytics[:core:analytics] downloads[:core:downloads] ranking[:core:ranking] prefs[:core:prefs] domain[:core:domain] database[:core:database] network[:core:network] design[:core:designsystem] model[:core:model] app --> features app --> playback app --> catalog app --> downloads app --> design features --> playback features --> catalog features --> downloads features --> design features --> model playback --> catalog playback --> downloads downloads --> catalog catalog --> rss catalog --> prefs catalog --> domain catalog --> database catalog --> ranking catalog --> network catalog --> model features --> analytics features --> ranking playback --> ranking playback --> analytics design --> analytics rss --> database rss --> domain rss --> model ranking --> database ranking --> domain ranking --> prefs ranking --> model analytics --> model prefs --> model domain --> network domain --> model database --> network database --> model design --> model network --> model ``` The primary runtime stack is **playback → catalog → prefs / domain / database / network / model**. ### Rules enforced in the graph - Features do not declare Gradle dependencies on other features (Konsist). - Features do not import other feature packages (Konsist). - `:core:playback` depends on `:core:catalog`; catalog does not depend on playback (Konsist). - `:core:catalog` does not depend on `:core:designsystem` (Konsist). - `:core:catalog` must not `api(` `:core:analytics` or `:core:ranking` (Konsist; ranking may be `implementation` only). - Features that need analytics or ranking depend on `:core:analytics` / `:core:ranking` directly. - Feature sources do not import PostHog (`scripts/ci/check-feature-no-posthog.sh`). - No Hilt, Koin, Dagger, or MockK in production sources or Gradle test deps (Konsist). - Enums shared by catalog and UI (for example `AutoTranscriptState`) live in `:core:model`. - `:core:domain` holds ports; `:core:catalog` implements catalog-facing ports. - `:core:network` is HTTP/API only. `RssFeedClient` lives in `:core:rss` and reaches callers through `:core:catalog`’s `api(rss)` edge. - `getInstance` call sites stay on the AppContainer / Room / WorkManager / Calendar / MessageDigest / JCA key (KeyStore, KeyPairGenerator, Signature) / Firebase allowlist (Konsist). - Extracted core modules keep `package` equal to the module root (permanent `core.data.*` stubs allowlisted). ## Composition root `AppContainer` (in `:app`) owns the shared object graph and is created only in `BoxLoreApplication`. `MainActivity`, `BoxLoreNavHost`, and feature assemblers read `application.container`. They do not build repositories or a second graph. Home, Settings, and Info ViewModels are built through assemblers (`HomeViewModelAssembler`, `SettingsViewModelAssembler`, `InfoViewModelAssembler`). Source-aware `SubscribedEpisodeCatalog` composes the PI and true RSS adapters behind the existing `LocalEpisodeCatalogPort`, preserving their separate tables and show identities. Narrow ports under `core.domain.ports` let ViewModels and workers take fakes without depending on full repositories or `BoxLoreDatabase`. Production wiring uses `RoomLocalCatalog` and `RoomEpisodeOfflineLookup` from `AppContainer`. `ListeningHistoryBackupPort` lives in `core.catalog.ports`. ## Product surfaces | Surface | Module | Notes | | :--- | :--- | :--- | | Home, Settings hub, Add RSS | `:feature:home` | Settings hosts the RSS dialog | | Learn / LearnHistory | `:feature:explore` | Learn is a bottom-nav tab | | Player overlay | `:feature:player` | `PlayerSheetScaffold`, not a NavHost route | | Podcast / episode detail | `:feature:info` | Dual episode routes and deep links | | Playback, queue, Media3 | `:core:playback` | Includes permanent `core.data.service` stubs | | HTTP API | `:core:network` | Separate from RSS | | Ranking | `:core:ranking` | Own adaptive Room database; personalization detail in [`docs/recommendation-system.md`](docs/recommendation-system.md) | | RSS catalog | `:core:rss` | Negative / `rss:` IDs; exposed through catalog | | PI episode supplement | `:core:rss` + `:core:database` | Feed-only extras under PI podcast id; not a subscription | The app shell owns adaptive player policy. Only Floating navigation with available player chrome can compact. The main Lore route temporarily forces compact presentation and restores the prior browsing state on exit; feature screens use the shared designsystem progress without importing the player feature. Lore may reclaim the released row in its bounded card viewport, while scrollable feed/list tail clearance remains stable. Playback ownership and persisted settings are unchanged. ## Adaptive UI and state Every Android UI or behavior change must support the affected phone, tablet, foldable, and resizable-window states, including changes initially requested for phones and shared components used by multiple screens. Assess these states before editing and document the affected behavior and verification in the module README. Documentation-only changes do not require unrelated UI tests. - Use the shared `BoxloreWindowLayout` and fold/posture policy rather than assuming a device category or duplicating breakpoints. The current tablet gate is width **at least 600dp** and height **at least 480dp**; split layout additionally requires width **at least 840dp** and landscape dimensions (`widthDp > heightDp`). Narrow or short tablet windows must retain the compact behavior. - Resizing, rotation, split-screen, and fold/posture changes must adapt presentation without losing pending work. Retain content and its lifecycle/ViewModel/saved-state owners, unsaved drafts and reorderings, selection/navigation state, and scroll position across the affected transitions. Modal hosting must preserve content state and apply the current action, Back, and outside-dismiss callbacks/properties without duplicate invocations or unintended dismissal. - Adapt pane bounds, insets, keyboard clearance, and fold-safe placement while keeping controls reachable and preserving accessibility and reduced-motion behavior. Presentation changes must preserve the existing playback, data, navigation, analytics, and ranking ownership; never create another object graph or playback repository to support a layout. Changed adaptive behavior requires hermetic JVM regression coverage under `src/test`, using the existing Robolectric/Compose JVM fixtures when composition lifetime matters. Do not add Compose instrumentation, Espresso, or emulator CI. Select relevant cases from this mandatory coverage contract: - Exercise the same live content/state owner across compact-to-tablet **and** tablet-to-compact transitions, including both the 600dp width gate and the 480dp height gate when affected. Exercise the 840dp split-layout threshold, portrait/landscape changes, and fold/posture transitions when the changed surface uses them. Include boundary values on either side of the affected gates. - Assert retained drafts/reorderings, selection, scroll, and owner identity as applicable, then invoke the current callbacks and verify save, cancel, Back, and outside-dismiss behavior. Cover relevant loading, empty, error, and recovery states as well as populated content. Bug fixes must reproduce the actual failure and shared consumers where applicable. - Separate static geometry/policy checks from state-transition regressions: mounting fresh content independently at several sizes does not prove continuity. Policy tests alone do not prove that modal content survives recomposition or host changes. Report source inspection, executed JVM checks, and device verification separately. Include a manual acceptance path for the affected phone/tablet/foldable states and transitions; an untested state or unavailable device remains an explicit verification limitation. ## Upgrade failsafes Historical FQCNs and preference file names remain reachable so upgrades from older installs keep working. These bridges are permanent. ### Workers | Historical FQCN | Implementation | Bridge | | :--- | :--- | :--- | | `cx.aswin.boxcast.core.data.SmartDownloadWorker` | `cx.aswin.boxlore.core.downloads.SmartDownloadWorker` | `LegacyWorkerFactory` | | `cx.aswin.boxcast.core.data.AutoDownloadWorker` | `cx.aswin.boxlore.core.downloads.AutoDownloadWorker` | `LegacyWorkerFactory` | | `cx.aswin.boxcast.core.data.PurgeSmartDownloadsWorker` | `cx.aswin.boxlore.core.downloads.PurgeSmartDownloadsWorker` | `LegacyWorkerFactory` | | `cx.aswin.boxlore.core.data.SmartDownloadWorker` | `cx.aswin.boxlore.core.downloads.SmartDownloadWorker` | factory + stub | | `cx.aswin.boxlore.core.data.AutoDownloadWorker` | `cx.aswin.boxlore.core.downloads.AutoDownloadWorker` | factory + stub | | `cx.aswin.boxlore.core.data.PurgeSmartDownloadsWorker` | `cx.aswin.boxlore.core.downloads.PurgeSmartDownloadsWorker` | factory + stub | ### Services / providers | Historical FQCN | Implementation | Bridge | | :--- | :--- | :--- | | `cx.aswin.boxlore.core.data.service.BoxLorePlaybackService` | `cx.aswin.boxlore.core.playback.service.BoxLorePlaybackService` | Manifest + stub | | `cx.aswin.boxlore.core.data.service.MediaDownloadService` | `cx.aswin.boxlore.core.playback.service.MediaDownloadService` | Manifest + stub | | `cx.aswin.boxlore.core.data.service.AutoCollageProvider` | `cx.aswin.boxlore.core.playback.service.AutoCollageProvider` | Manifest + stub | ### SharedPreferences files | Legacy file | Current file | Opened by | | :--- | :--- | :--- | | `boxcast_prefs` | `boxlore_prefs` | `BoxcastPrefs` via `PrefsFileMigrator` | | `boxcast_theme_fast_cache` | `boxlore_theme_fast_cache` | `UserPreferencesRepository` | | `boxcast_analytics_prefs` | `boxlore_analytics_prefs` | `AnalyticsHelper` | | `boxcast_player` | `boxlore_player` | `PlaybackRepository` / `PodcastRepository` | | — | `android_auto_artwork_sources` | `AutoArtworkSourceStore` / `AutoCollageProvider` (Android Auto art URI map; do not rename) | | — | `boxlore_now_playing_widget` | `NowPlayingWidgetSnapshotStore` (`:feature:widgets`; last rendered Now Playing snapshot JSON; do not rename) | | — | `boxlore_library_widget` | `LibraryWidgetSnapshotStore` (`:feature:widgets`; last rendered Subscriptions / New Episodes snapshot JSON; do not rename) | | — | `boxlore_discovery_last_good` | `PodcastRepository` (`:core:catalog` `RecommendationsLastGood`; last good `/v2` picks per surface and country; do not rename) | | — | `boxlore_feed_reports` | `AppContainer` (`:core:catalog` `SharedPreferencesFeedReportStamps`; last broken-feed report time per show; do not rename) | | — | `boxlore_notification_reregistrations` | `AppContainer` (`:core:catalog` `SharedPreferencesPendingNotificationRegistrations`; shows whose notifications wait to be registered again after an address change; do not rename) | | `boxcast_api_config` | `boxlore_api_config` | `BoxLoreAppRoot` | | `boxcast_referrer_prefs` | `boxlore_referrer_prefs` | `InstallReferrerManager` | R8 keeps worker names/constructors and permanent `core.data.service.*` component aliases, plus the JSON field/default-constructor contracts used by caches and backups. Library consumer rules protect generated Room implementations and SDK entry points. Core implementations are otherwise eligible for optimization. The release-artifact gate verifies these contracts after optimization. ## Source file size Kotlin sources under `*/src/main/**` stay under **1000 lines**. Larger units are split into same-package helpers or composables without changing public APIs or the identity/FQCN contracts above. ## Verification How the tree is tested — commands, coverage floors, and layer status — is in [`docs/TESTING.md`](docs/TESTING.md). | Layer | Role | Where | | :--- | :--- | :--- | | JVM unit | Logic, ports, ViewModel slices | [`docs/TESTING.md`](docs/TESTING.md) | | Maestro | Device E2E smoke (local; YAML validated in CI) | [`maestro/README.md`](maestro/README.md) | | Architecture-as-code | Konsist and filesystem guards | `:core:testing` | | Screenshots | Visual baselines (optional local Roborazzi) | [`docs/screenshots/README.md`](docs/screenshots/README.md) | Shared test fixtures live in `:core:testing`. The project does not use MockK or Hilt. ## Related documents | Document | Contents | | :--- | :--- | | [`docs/MODULE_README_TEMPLATE.md`](docs/MODULE_README_TEMPLATE.md) | Module README shape | | [`maestro/README.md`](maestro/README.md) | Local Maestro flows |