# 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.