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.
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.
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.
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 |
| 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 |
| 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 |
| 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 |
: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).
| Module | Responsibility | README |
|---|---|---|
:app |
Application, AppContainer, navigation host, FCM, WorkerFactory |
app/README.md |
:core:model |
Shared models and enums | core/model/README.md |
:core:auth |
Authentication domain, AuthRepository, and Firebase Auth implementation |
core/auth/README.md |
:core:network |
HTTP client (BoxLoreApi / NetworkModule) and network DTOs |
core/network/README.md |
:core:domain |
Thin ports and small result types (no Room or repositories) | core/domain/README.md |
:core:database |
Main Room database, entities, DAOs, migrations | core/database/README.md |
:core:prefs |
DataStore and SharedPreferences façades (UserPreferencesRepository, BoxcastPrefs) |
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 |
core/analytics/README.md |
:core:catalog |
Catalog orchestration: PodcastRepository, subscriptions, content sections, backup/restore |
core/catalog/README.md |
:core:rss |
RSS fetch/parse, RssPodcastRepository, PI episode supplement (EpisodeSupplementRepository), rss: / negative IDs |
core/rss/README.md |
:core:ranking |
Adaptive scoring, LinUCB, feedback, AdaptiveRankingDatabase. Behavior detail: docs/recommendation-system.md |
core/ranking/README.md |
:core:downloads |
DownloadRepository, Smart Downloads, related WorkManager workers |
core/downloads/README.md |
:core:playback |
PlaybackRepository, queue, Media3 services, smart-queue helpers |
core/playback/README.md |
:core:designsystem |
Theme and shared composables; no data or network ownership | core/designsystem/README.md |
:core:testing |
Shared fixtures, dispatcher helpers, architecture guards | core/testing/README.md |
:feature:home |
Home feed presentation, Daily/Offline mix, and local Debug | feature/home/README.md |
:feature:settings |
Unified Settings hub, Account & Cloud Sync, Appearance, Privacy, Playback, and Download policies | feature/settings/README.md |
:feature:history |
Listening history timeline, activity charts, pattern insights, and streak cards | feature/history/README.md |
:feature:player |
Player overlay (PlayerSheetScaffold); not a NavHost destination |
feature/player/README.md |
:feature:info |
Podcast and episode detail, including deep links | feature/info/README.md |
:feature:explore |
Explore plus Learn / LearnHistory | feature/explore/README.md |
:feature:library |
Library hub, subscriptions, downloads, liked | feature/library/README.md |
:feature:onboarding |
First-run flows (AI, genre, search, import) | feature/onboarding/README.md |
:feature:briefing |
Daily briefing screen | feature/briefing/README.md |
:feature:widgets |
Home-screen Now Playing, compact bar, playback-control, subscriptions, and new-episodes widgets (RemoteViews) | feature/widgets/README.md |
| 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 |
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.
- Features do not declare Gradle dependencies on other features (Konsist).
- Features do not import other feature packages (Konsist).
:core:playbackdepends on:core:catalog; catalog does not depend on playback (Konsist).:core:catalogdoes not depend on:core:designsystem(Konsist).:core:catalogmust notapi(:core:analyticsor:core:ranking(Konsist; ranking may beimplementationonly).- Features that need analytics or ranking depend on
:core:analytics/:core:rankingdirectly. - 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:domainholds ports;:core:catalogimplements catalog-facing ports.:core:networkis HTTP/API only.RssFeedClientlives in:core:rssand reaches callers through:core:catalog’sapi(rss)edge.getInstancecall sites stay on the AppContainer / Room / WorkManager / Calendar / MessageDigest / JCA key (KeyStore, KeyPairGenerator, Signature) / Firebase allowlist (Konsist).- Extracted core modules keep
packageequal to the module root (permanentcore.data.*stubs allowlisted).
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.
| 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 |
| 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.
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
BoxloreWindowLayoutand 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.
Historical FQCNs and preference file names remain reachable so upgrades from older installs keep working. These bridges are permanent.
| 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 |
| 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 |
| 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) |
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.
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.
How the tree is tested — commands, coverage floors, and layer status — is in docs/TESTING.md.
| Layer | Role | Where |
|---|---|---|
| JVM unit | Logic, ports, ViewModel slices | docs/TESTING.md |
| Maestro | Device E2E smoke (local; YAML validated in CI) | maestro/README.md |
| Architecture-as-code | Konsist and filesystem guards | :core:testing |
| Screenshots | Visual baselines (optional local Roborazzi) | docs/screenshots/README.md |
Shared test fixtures live in :core:testing. The project does not use MockK or Hilt.
| Document | Contents |
|---|---|
docs/MODULE_README_TEMPLATE.md |
Module README shape |
maestro/README.md |
Local Maestro flows |