# 08 — Android App Design Status: **Normative design contract** for the FablePool Android client. Audience: Android team; server team (for API expectations this client encodes). --- ## 1. Product Scope (v1) - Browse library (artists / albums / songs / genres / playlists), full-text search. - Gapless local playback with background service, notification, lock screen, Bluetooth/AVRCP controls, Android Auto media browsing. - **Chromecast** sender per `docs/06-chromecast.md`. - **Auto-play next** with user-configurable window N (doc 07), togglable from the now-playing screen. - Queue management (reorder, swipe-remove, save-as-playlist). - Playlist CRUD, like/dislike. - Offline downloads (per album/playlist) with transparent offline playback. - Multi-server support (users may run several FablePool instances). - Talks **FablePool REST v1** natively; *not* the Subsonic surface (Subsonic exists for third-party clients). Out of scope v1: tablet-optimized layouts beyond responsive defaults, Wear OS, widgets, ReplayGain DSP toggles (server normalizes via transcode profile). --- ## 2. Tech Stack (targets stated for build reproducibility) | Concern | Choice | Target version | |---|---|---| | Language | Kotlin | 2.0.x | | UI | Jetpack Compose + Material 3 | Compose BOM `2024.09.x` | | Min/target SDK | minSdk 26 (Android 8.0), targetSdk 35 | — | | Playback | AndroidX **Media3** (`media3-exoplayer`, `media3-session`, `media3-ui`, `media3-cast`, `media3-datasource-okhttp`) | `1.4.x` | | Cast | `play-services-cast-framework` | `21.5.x` | | DI | Hilt | `2.5x` | | Networking | Retrofit 2 + OkHttp 4 + kotlinx-serialization | Retrofit `2.11.x`, OkHttp `4.12.x` | | Local DB | Room | `2.6.x` | | Images | Coil 3 (Compose) | `3.x` | | Background work | WorkManager | `2.9.x` | | Paging | AndroidX Paging 3 | `3.3.x` | | Secure storage | AndroidX `security-crypto` (EncryptedSharedPreferences) for tokens | `1.1.x` | (Exact dependency coordinates are fixed when the app milestone starts; the implementation milestone must re-verify current stable versions — Media3 and Compose move quickly.) --- ## 3. Architecture MVVM + unidirectional data flow, layered: ```mermaid flowchart TD subgraph UI["UI layer (Compose)"] SCR[Screens: Library, Album, Search,
NowPlaying, Queue, Playlists,
Downloads, Settings, ServerSetup] VM[ViewModels
StateFlow<UiState>] SCR --> VM end subgraph Domain["Domain layer"] UC[Use cases:
PlayQueueUseCase, AutoPlayUseCase,
DownloadUseCase, SearchUseCase] end subgraph Data["Data layer"] REPO[Repositories:
LibraryRepo, PlaylistRepo,
HistoryRepo, SettingsRepo, AuthRepo] API[FablePool API client
Retrofit + OkHttp] DB[(Room cache:
tracks, albums, artists,
playlists, downloads, pending_ops)] REPO --> API REPO --> DB end subgraph Playback["Playback layer"] SVC[PlaybackService :
MediaSessionService] PM[PlayerManager
ExoPlayer ⟷ CastPlayer switch] SVC --> PM end VM --> UC --> REPO UC --> PM PM -. player events .-> UC ``` Key decisions: - **Repositories are offline-first**: reads serve Room immediately, then refresh from network (stale-while-revalidate); library lists use Paging 3 with a `RemoteMediator` against the server's cursor pagination. - **Single `PlayerManager`** owns the active `Player` (ExoPlayer or CastPlayer) behind Media3's common `Player` interface; ViewModels never see which is active. Route transfer logic per doc 06 §8.1. - **Pending operations queue** (`pending_ops` table): play-history posts, reactions, and playlist edits made offline are journaled and flushed by a WorkManager job with exponential backoff — history correctness matters because it feeds the recommender. --- ## 4. Server Connection & Auth - **Server setup flow:** user enters base URL → app calls `GET /api/v1/system/ping` (unauthenticated; returns server version + auth capabilities) → login via `POST /api/v1/auth/token` (password grant per doc 05 §3) → store `{accessToken, refreshToken}` in EncryptedSharedPreferences, keyed by server profile ID. - OkHttp `Authenticator` performs synchronized refresh on 401 (`POST /api/v1/auth/refresh`); refresh failure logs the profile out and routes to login, preserving the navigation stack. - Multi-server: a `ServerProfile` Room entity; one active profile at a time; switching tears down player + repositories via Hilt-scoped components. - Cleartext HTTP is allowed **only** for `10.0.0.0/8`, `192.168.0.0/16`, `172.16.0.0/12`, and `.local` hosts via network security config (self-host reality), with a persistent in-app warning banner; Cast requires HTTPS regardless (doc 06 §2) and the Cast button is disabled with an explanatory tooltip on cleartext profiles. --- ## 5. Playback ### 5.1 Service `PlaybackService : MediaSessionService` (Media3) with a `MediaSession` whose `Player` is the `PlayerManager` facade. This gives notification + lock screen + BT controls + Android Auto from one implementation. Foreground service type `mediaPlayback`; `MediaButtonReceiver` for legacy wired/BT intents. ### 5.2 Data source & streaming - `OkHttpDataSource.Factory` with the authenticated client → bearer token on stream requests; ExoPlayer issues `Range` requests natively for seek. - Stream URL selection: app calls `GET /stream-decision/{trackId}?client=android` once per track (cached per session). Direct-play set for ExoPlayer: flac, mp3, aac/m4a, ogg/opus, ogg/vorbis, wav — broader than Cast's; the server returns `raw` accordingly. Transcoded streams (rare on Android) have unknown length; the seek bar uses metadata duration and the server's time-based seek parameter (`?t=` per doc 04 §5.3) for far seeks. - **Gapless:** queue is fed via `Player.addMediaItem` with ExoPlayer preloading the next item (`DefaultLoadControl` tuned: `bufferForPlaybackMs=2500`, max buffer 2 min); FLAC/MP3 gapless metadata honored by ExoPlayer where present. ### 5.3 Queue & auto-play - The queue lives in the `Player` (single source of truth); a Room-persisted mirror restores the queue + position after process death. - `AutoPlayUseCase` listens for `onMediaItemTransition`/`onTimeline` events; when **fewer than 2 items remain** and auto-play is enabled, it calls `POST /api/v1/recommendations/next` with the user's `windowN` and appends the result (deduped against queue). The same use case answers Cast `NEXT_ITEM_REQUEST` messages (doc 06 §3.3) so behavior is identical local vs cast. - Now-playing screen exposes: auto-play toggle, **window-N slider (1–100)** writing through `PATCH /me/settings/autoplay`, and the `reasons[]` chip row ("Because it sounds like *X*") for the upcoming auto-picked track. ### 5.4 History reporting - `started` event at 5 s of playback, `completed` at ≥50 % or ≥4 min (matching doc 06 §7 thresholds); skips reported with `completed:false` and `positionMs`. All via `HistoryRepo` → `pending_ops` journal → network, so offline plays (downloads) still scrobble later with the **original timestamp** (`playedAt` field is client-supplied, server validates skew ≤ 30 days per doc 02 §6). --- ## 6. Offline Downloads - Media3 **DownloadService/DownloadManager** with a `CacheDataSource` is *not* used; downloads are explicit full-file fetches (the formats are already progressive audio), stored in app-private storage (`context.filesDir/downloads//.`), tracked in Room (`downloads` table: state machine `QUEUED→DOWNLOADING→DONE/FAILED`). Rationale: simpler resume semantics (HTTP `Range` continuation via OkHttp), and we control eviction. - Download requests use `format=raw` unless the user enables "Save space" (then `format=opus&bitrate=128`, server transcodes; doc 04 §5). - WorkManager constraint defaults: unmetered + charging-not-required, user-overridable. - Playback resolution order in `PlayerManager`: local file → stream. Offline mode (airplane or user toggle) filters browse UIs to downloaded content via a Room flag join. - Quota: per-server max download size setting; LRU eviction prompt (never silent deletion of explicit downloads — eviction only with confirmation). --- ## 7. Cast Integration (binding to doc 06) - `CastOptionsProvider` reads receiver app ID from `/system/cast-config` (cached in Room; falls back to the default media receiver ID, which disables auto-play per doc 06 §11.6). - `CastPlayer` (media3-cast) registered with `SessionAvailabilityListener`: - **onCastSessionAvailable:** snapshot local queue + position → mint cast tokens in batches of 20 via `POST /cast/token` → build CAF queue → pause local player → switch facade to CastPlayer. - **onCastSessionUnavailable:** read last receiver position from media status → rebuild local queue → resume ExoPlayer at position. - Custom-namespace channel (`urn:x-cast:org.fablepool.cast`) registered on the `CastSession`; handles `NEXT_ITEM_REQUEST`, `TOKEN_REFRESH_REQUEST`, `SCROBBLE_HINT` exactly per doc 06 §3.3, all delegated to the same use cases as local playback. --- ## 8. Screens & Navigation Single-activity, Compose Navigation. Route map: ``` serverSetup → login → home (bottom nav): ├─ library (tabs: Artists | Albums | Songs | Genres) │ ├─ artist/{id} → album/{id} │ └─ album/{id} ├─ search (debounced 300ms, sections: artists/albums/songs/playlists) ├─ playlists → playlist/{id} └─ downloads nowPlaying (modal bottom sheet, swipe-up from mini-player) queue (from nowPlaying) settings (server, playback, autoplay, downloads, about/licenses) ``` UX requirements worth binding now: - Mini-player persists across all home destinations. - Every track row's overflow: Play next / Add to queue / Add to playlist / Download / Like / Dislike / Go to artist / Go to album / **Start radio** (calls `POST /recommendations/radio` with the track as seed, doc 07 §8.2). - Album art via Coil with the server's `/art/{id}?size=` endpoint; sizes 128 (lists), 512 (now playing), with disk cache keyed by `{serverId, artId, size}`. - Dark/light follows system; dynamic color (Material You) on Android 12+. --- ## 9. Android Auto Media3's `MediaLibraryService` browse tree (the `PlaybackService` implements `MediaLibrarySession.Callback`): ``` root ├─ Recently played (history endpoint, last 20) ├─ Playlists ├─ Albums (A–Z, paged) └─ Downloads ``` Voice "play X" maps to search → first song result → play with auto-play on. Auto-play behaves identically in Auto sessions (same use case). --- ## 10. Error Handling & Resilience Matrix | Condition | Behavior | |---|---| | Token refresh fails (revoked) | Logout profile, retain downloads (re-encrypted access on next login), route to login | | Stream 5xx / backend offline | Retry 2× with backoff; then auto-skip to next queue item + transient snackbar; track row shows "source offline" badge from library sync flags | | Recommendation call fails | Auto-play silently retries once; on failure queue just ends (never crash playback) | | Server unreachable | Offline mode banner; browse limited to Room cache + downloads; `pending_ops` accumulate | | Clock skew on offline scrobbles | Server clamps per doc 02 §6; client sends `playedAt` in UTC | | Process death during playback | Queue + position restored from Room mirror; playback resumes paused | --- ## 11. Testing & Release Criteria - Unit: ViewModels, use cases (auto-play trigger logic gets exhaustive tests: remaining-items thresholds, dedupe, disabled state, cast vs local parity). - Instrumented: Room migrations, `pending_ops` flush, auth refresh single-flight. - Screenshot tests (Roborazzi) for core screens, both themes. - Manual device matrix: Android 8 / 12 / 15 phone, Android Auto head-unit emulator, Chromecast Gen 3 + Google TV. - Release: F-Droid-compatible build flavor (no Play Services ⇒ Cast features compile-time gated behind the `gms` flavor), plus Play Store flavor. Reproducible Gradle build, CI-signed.