Architecture

July 14, 2026 · View on GitHub

Clean Architecture with three layers. Dependencies flow inward only.

graph TB
    subgraph Presentation
        Screens["Screens (Compose)"] --> ViewModels
        ViewModels --> UiMappers["UI Mappers"]
    end

    subgraph Domain
        UseCases["Use Cases (18)"]
        RepoInterfaces["Repository Interfaces (9)"]
        Services["Services (ChannelHealthScanner, ThumbnailExtractor, AnalyticsHelper)"]
        UseCases --> RepoInterfaces
    end

    subgraph Data
        RepoImpls["Repository Impls (8 bound)"]
        LocalSources["Local Sources → Room DAOs"]
        RemoteSources["Remote Sources → Retrofit + M3U/Xtream + XMLTV"]
        RepoImpls --> LocalSources
        RepoImpls --> RemoteSources
    end

    ViewModels --> UseCases
    RepoImpls -.implements.-> RepoInterfaces

Presentation Layer

ComponentRole
ComposeMainActivityEntry point. Uses Box overlay pattern: app shell composes behind opaque splash so ViewModels init during splash, not after. Orientation-adaptive: landscape uses side rail, portrait uses bottom nav. Owns PipController for the mobile player's picture-in-picture.
FireVisionNavGraph12 destinations (Jetpack Navigation Compose): Pairing, Home, Channels, Categories, Guide, Multiview, Search, Favorites, Settings, AddSource, Player, ChannelsByCategory. Player takes channelId + optional catchupStart/catchupDur args; Multiview takes an optional channelId.
ViewModels (9)ChannelsViewModel, FavoritesViewModel, SearchViewModel, PlayerViewModel, SettingsViewModel, PairingViewModel, GuideViewModel, MultiviewViewModel, AppUpdateViewModel — all @HiltViewModel.
UI MappersChannelUiMapper, CategoryUiMapper, GuideUiMapper — enrich domain models with health status, thumbnails, and EPG rows.
PlayerSplit into TV chrome and a mobile overhaul (presentation/ui/screens/player/): gesture overlays, mobile chrome, portrait tabs, tracks panel, quick actions, orientation, and a configurable D-pad key handler. PipController drives auto-enter PiP (API 31+) with prev/next channel remote actions.
GuideEPG program grid (screens/guide/): channels × 30-min time slots over a 12-hour window, sticky channel column, horizontal scroll, "now" line, All/Favorites/Category filter bar. Rows hydrate lazily as they scroll into view.
MultiviewMulti-channel grid (MultiviewScreen, MultiviewViewModel): up to 4 panes (capped to 2 on low-RAM devices), one ExoPlayer per pane via PlayerFactory, only the focused pane plays audio, LazyColumn channel picker.
Add SourceAddSourceScreen: tabbed import for the managed (paired) server, an M3U URL, or Xtream host/username/password.
UpdateUpdateAvailableScreen + AppUpdateViewModel present the update-available flow, backed by update/AppUpdater.

Domain Layer

Pure Kotlin, no Android dependencies.

ComponentRole
Domain Models (7)Channel, Category, ChannelHealthStatus, PlaybackState, SearchFilter, EpgProgram, StreamMetrics
Repository Interfaces (9)ChannelRepository, CategoryRepository, FavoriteRepository, PlaybackRepository, PlaylistRepository, SearchHistoryRepository, UserPreferencesRepository, EpgRepository, StreamMetricsRepository. (PlaylistRepository has no bound impl — M3U/Xtream import is handled inside ChannelRepositoryImpl via the playlist data sources.)
Use Cases (18)UseCase<P, R> (suspend, one-shot) and FlowUseCase<P, R> (reactive). Includes: PullFavoritesUseCase, ReportStreamStatusUseCase, ReportStreamPlayUseCase, SyncHealthResultsUseCase, GetGuideProgramsUseCase.
ServicesChannelHealthScanner (batch HTTP checks + server sync), ChannelThumbnailExtractor (frame extraction), AnalyticsHelper (Firebase).

Data Layer

ComponentRole
Room DatabaseFireVisionDatabase (v8, 7 migrations): 9 entities — ChannelEntity, CategoryEntity, FavoriteEntity, SearchHistoryEntity, PlaybackPositionEntity, ChannelHealthEntity, FavoriteCategoryEntity, StreamMetricsEntity, EpgProgramEntity. Since v7 the user-data tables (favorites, channel_health, stream_metrics) carry no foreign keys to channels, so channel syncs never wipe user data.
DAOs (9)Type-safe SQL queries with Flow return types
Repository Impls (8 bound)Offline-first: Room is source of truth, remote refreshes cache. EpgRepositoryImpl is multi-source (paired server guide + optional on-device XMLTV) with a Room-backed last-good store and an in-memory read cache hydrated from Room. ChannelRepositoryImpl also resolves the active source (paired / M3U / Xtream).
Remote sourcesFireVisionApiService (Retrofit): channels, single channel, categories, favorites (GET + POST), report-status, report-play, health-sync, playlist.m3u, EPG guide, demo-code. M3uDataSource / XtreamDataSource (OkHttp) for BYO playlists, XmltvEpgDataSource for on-device XMLTV guides. PinnedHttpClient (OkHttp) handles pairing and in-app update calls outside Retrofit.
MappersChannelMapper, CategoryMapper — bidirectional DTO ↔ Entity ↔ Domain

Dependency Injection

Hilt with 5 modules in SingletonComponent:

ModuleProvides
AppModuleContext / Application, coroutine dispatchers (qualifiers in DispatcherQualifiers)
NetworkModuleOkHttpClient, Retrofit, FireVisionApiService. OkHttp interceptor injects Accept: application/json and the X-TV-Code header (redacted in logs); no certificate pinning (Let's Encrypt + arbitrary BYO hosts).
DatabaseModuleFireVisionDatabase, all 9 DAOs
RepositoryModuleBinds 8 repo interfaces to impls
ImageLoadingModuleCoil ImageLoader (25% mem cache, 50MB disk, 300ms crossfade)

Data Flows

Read: Channels Screen

flowchart LR
    A[ChannelsScreen] -->|collects StateFlow| B[ChannelsViewModel]
    B -->|invokes| C[GetChannelsUseCase]
    C -->|calls| D["ChannelRepository.getChannels()"]
    D -->|combines| E["Room: channels + favorites Flows"]
    E -->|maps via ChannelMapper| F["Flow<List<Channel>>"]
    F -->|enriched by UiMapper + health| G["StateFlow<ChannelsUiState>"]
    G --> A

Write: Toggle Favorite

sequenceDiagram
    participant UI as ChannelsScreen
    participant VM as ChannelsViewModel
    participant UC as ToggleFavoriteUseCase
    participant Repo as FavoriteRepositoryImpl
    participant Room
    participant API

    UI->>VM: toggleFavorite(channelId)
    VM->>VM: Optimistic UI update
    VM->>UC: invoke(channelId)
    UC->>Repo: toggleFavorite(channelId)
    Repo->>Room: Add/remove FavoriteEntity
    Repo-->>API: Fire-and-forget syncFavorites()
    Room-->>VM: Flow emits updated list
    VM-->>UI: Recompose

Background: Channel Sync

WorkManager (6h) → ChannelSyncWorker → ChannelRepository.refreshChannels()
  → Retrofit GET /channels → Map DTOs → Room @Transaction: delete all + insert
  → Room Flow emits → active ViewModels recompose
flowchart TD
    Splash --> Pairing["PairingScreen (first launch)"]
    Splash --> Home["HomeScreen (returning user)"]

    subgraph "SideNavRail"
        Home
        Channels["Channels (by category)"]
        Guide["Guide (EPG program grid)"]
        Multiview["Multiview (multi-channel grid)"]
        Favorites["Favorites (reorderable)"]
        Search["Search (with history)"]
        Settings
    end

    Settings --> AddSource["AddSource (M3U / Xtream / paired)"]
    Home --> Player["Player (full-screen ExoPlayer)"]
    Channels --> Player
    Guide --> Player
    Favorites --> Player
    Search --> Player

Sidebar routes use popUpTo(Home), saveState, launchSingleTop, restoreState.

Key Design Decisions

DecisionRationale
Offline-first (Room as source of truth)Fire TV may have intermittent connectivity. App always usable with cached data.
Box overlay pattern for pre-warmingViewModel init{} fires at T=0, Room returns channels by ~T=50ms. When splash fades at ~T=1900ms, HomeScreen already populated. Previous Crossfade approach delayed ViewModel creation.
Non-blocking EPG enrichmentgetNowNextIfCached() is synchronous, returns null if not loaded. Channels render immediately; "Now Playing" appears asynchronously.
Room-backed multi-source EPGEpgRepositoryImpl merges the paired server guide (/tv/epg) with an optional on-device XMLTV feed, persists to Room (epg_programs), and keeps the last-good schedule if every source fails, so the guide never blanks after a restart or network drop.
Credential-free Xtream URLsImported Xtream stream URLs store {username}/{password} placeholders (StreamUrlTemplate) so the Room DB and TIF provider never hold credentials at rest. Real credentials live in EncryptedSharedPreferences and are substituted at use time (playback, probing).
In-app updaterAppUpdater checks /api/v1/app/version (with a GitHub Releases fallback) and downloads the APK via the system DownloadManager. The completion receiver is registered with RECEIVER_EXPORTED to fix downloads stalling on Android 13+.
Picture-in-picture (mobile)PipController (activity-owned) rebuilds PictureInPictureParams on play/pause and channel changes, uses setAutoEnterEnabled on API 31+, and clears auto-enter when leaving the player so browsing screens never trigger PiP.
Multiview decoder budgetPanes are capped at 4 (2 on low-RAM devices) to stay within hardware decoder limits; only the focused pane plays audio.
Health flow seedingchannelHealthDao.getAllHealth().debounce(500ms).onStart { emit(emptyList()) }onStart AFTER debounce seeds combine, so channels render instantly. Real health data updates ~500ms later.
Fire-and-forget health reportingSupervisorJob — failures silently swallowed, never blocks playback.
Unresponsive stream detectionbufferWatchJob fires after 30s continuous buffering → onStreamUnresponsive, reported separately from dead streams.
In-memory alternate stream slotsAlternates stored in StreamSlot queue (not Room) to avoid migrations. Empty on cold start, degrades to primary-only.
Lifecycle-aware resume refreshON_RESUME triggers refresh() (first resume skipped). Detects server-side changes.
Optimistic UI for favoritesUI updates immediately, persists in background. Rolls back on error.
Compose for TV over LeanbackModern declarative UI with better state management.