Skip to content

Architecture

Aswin C edited this page Oct 15, 2026 · 3 revisions

System Architecture

boxlore is a multi-module Android project. :app is the shell, shared behaviour lives in :core:*, and screens live in :feature:*. Dependencies point inward, features never depend on each other, and one explicit container wires the whole app.

The repository's ARCHITECTURE.md is the source of truth for module boundaries and identity contracts. This page is a guided summary of it.


Principles

  1. Dependencies point inward Features depend on playback, catalog and lower core modules. Core modules never depend on features or UI.
  2. Features stay isolated A feature module may not declare a Gradle dependency on another feature or import its code. Flows that cross features go through the :app navigation graph.
  3. One composition root AppContainer in :app builds the runtime object graph by hand. There is no Hilt, Dagger or Koin.
  4. Rules as code Most boundaries are checked in CI by Konsist tests in :core:testing. A few are checked by scripts or kept by convention; the list below says which.

Modules

boxlore/
├── app/                    # Application shell, AppContainer, navigation host, FCM, WorkerFactory
├── core/
│   ├── model/             # Shared models and enums
│   ├── auth/              # Sign-in domain and Firebase Auth implementation (optional accounts)
│   ├── network/           # HTTP client for the boxlore API (BoxLoreApi) and network DTOs
│   ├── domain/            # Thin ports and small result types
│   ├── database/          # Main Room database (boxlore_database), entities, DAOs, migrations
│   ├── prefs/             # DataStore and SharedPreferences façades
│   ├── analytics/         # Analytics façade; features never call the vendor SDK directly
│   ├── catalog/           # PodcastRepository, subscriptions, content sections, backup and restore
│   ├── rss/               # RSS fetch and parse, direct RSS shows, episode supplements, rss:/negative IDs
│   ├── ranking/           # On-device re-ranking: LinUCB-style model, taste meters, own Room database
│   ├── downloads/         # Download repository, Smart Downloads and auto-download workers
│   ├── playback/          # PlaybackRepository, queue, Media3 services, Smart Queue helpers
│   ├── designsystem/      # Theme and shared composables; no data or network ownership
│   └── testing/           # Shared fixtures, fakes and architecture guards
└── feature/
    ├── home/              # Home feed, Daily and Offline mix
    ├── settings/          # Settings hub: account and cloud sync, appearance, playback, downloads, privacy
    ├── history/           # Listening history timeline and insights
    ├── player/            # Player overlay (PlayerSheetScaffold), chapters, transcripts
    ├── info/              # Show and episode pages, including deep links
    ├── explore/           # Explore, search, and Lore (named Learn in code)
    ├── library/           # Library hub: subscriptions, folders, downloads, liked
    ├── onboarding/        # First-run flows: AI suggestions, genres, search, import
    ├── briefing/          # The boxlore brief
    └── widgets/           # Home-screen widgets (RemoteViews)

The folder path matches the Gradle id: core/playback is :core:playback.


Dependency direction

flowchart TB
    app[":app"]

    subgraph features ["Features (isolated from each other)"]
        home[":feature:home"]
        settings[":feature:settings"]
        history[":feature:history"]
        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"]
    downloads[":core:downloads"]
    ranking[":core:ranking"]
    rss[":core:rss"]
    analytics[":core:analytics"]
    prefs[":core:prefs"]
    domain[":core:domain"]
    database[":core:database"]
    network[":core:network"]
    auth[":core:auth"]
    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 --> analytics
    features --> ranking
    features --> model
    playback --> catalog
    playback --> downloads
    playback --> ranking
    playback --> analytics
    downloads --> catalog
    catalog --> rss
    catalog --> prefs
    catalog --> domain
    catalog --> database
    catalog --> ranking
    catalog --> network
    catalog --> model
    rss --> database
    rss --> domain
    ranking --> database
    ranking --> domain
    ranking --> prefs
    database --> network
    domain --> network
    network --> auth
    rss --> model
    ranking --> model
    analytics --> model
    prefs --> model
    domain --> model
    database --> model
    network --> model
    auth --> model
    design --> analytics
    design --> model
Loading

Not every feature uses every edge shown for the group; each module's build.gradle.kts has the exact list. The main runtime stack is playback → catalog → prefs / domain / database / network / model.

Boundary rules

Checked by Konsist in :core:testing:

  • Features do not depend on, or import from, other features.
  • :core:playback depends on :core:catalog; catalog never depends on playback.
  • :core:catalog does not depend on :core:designsystem.
  • :core:catalog does not expose :core:analytics or :core:ranking through api(); ranking is implementation only.
  • No Hilt, Koin, Dagger or MockK in production sources or Gradle test dependencies.

Checked by a CI script:

  • Feature sources never import PostHog directly (scripts/ci/check-feature-no-posthog.sh). Analytics go through :core:analytics.

Kept by convention (documented, not enforced in CI):

  • Kotlin files under */src/main/** stay under 1,000 lines. Larger units are split into same-package helpers.

Composition root

AppContainer owns the shared object graph and is created only in BoxLoreApplication. MainActivity, the navigation host, workers and services read it; none of them build their own repositories.

┌────────────────────────────────────────┐
│          BoxLoreApplication            │
│  └── container = AppContainer(this)    │
└───────────────────┬────────────────────┘
                    │ reads the container
     ┌──────────────┴──────────────┐
     ▼                             ▼
MainActivity / NavHost      WorkerFactory / Services
  • Construction order: database → PodcastRepository → QueueRepository → PlaybackRepository → QueueManager → SmartDownloadManager, with RSS and ranking peers created alongside the database.
  • ViewModel assemblers: Home, Settings and Info ViewModels are built by HomeViewModelAssembler, SettingsViewModelAssembler and InfoViewModelAssembler.
  • Ports for tests: :core:domain defines narrow ports such as LocalEpisodeCatalogPort and EpisodeSupplementPort, so ViewModels and workers can take fakes from :core:testing in fast JVM tests without a real database.

Playback

Playback runs in BoxLorePlaybackService, a Media3 MediaLibraryService.

  1. One playback repository Exactly one UI-scoped PlaybackRepository exists. Navigation routes and workers must never create a second one.
  2. Smart Queue refill Only BoxLorePlaybackService refills the queue when it runs low. See Features for what it adds.
  3. Android Auto The service's browse tree (AutoBrowseContract) offers Home (continue listening, queue, new episodes, a drive mix), Library (subscriptions, liked, history), Downloads and Discover. AutoPlaybackResumptionHandler reads the saved player state so playback can resume after a reconnect.
  4. Google Cast Cast support hands playback to a Cast receiver and keeps the in-app player in sync.

Upgrade bridges and identity contracts

boxlore was once called boxcast. These names are part of the shipping app; renaming them breaks upgrades, WorkManager, Media3 or deep links, so they stay.

Area Contract Bridge
Application ID cx.aswin.boxlore Never changes
Packages cx.aswin.boxlore.* Standard root namespace
Main Room database boxlore_database Renamed from legacy boxcast_database on startup
Ranking Room database adaptive_ranking_database Owned by :core:ranking only
DataStore user_preferences Preferences DataStore
SharedPreferences boxlore_* PrefsFileMigrator copies legacy boxcast_* files; key strings stay stable
Legacy workers cx.aswin.boxcast.core.data.* and cx.aswin.boxlore.core.data.* LegacyWorkerFactory maps them to :core:downloads workers
Legacy services cx.aswin.boxlore.core.data.service.* Permanent stub classes kept by R8; the manifest registers the core.playback.service classes
Deep links boxlore:// and boxcast:// Both schemes stay, plus the HTTPS share paths
Episode IDs rss: prefix and negative IDs Stable identity for direct RSS shows and episode supplements