Native Android client for WaveFlow — a local-first music player. Kotlin + Jetpack Compose + Media3.
Status: local-first. Plays, browses, searches and organises the device's own files, and streams from a WaveFlow server — see Server.
- Language: Kotlin
- UI: Jetpack Compose + Material 3 (WaveFlow emerald theme)
- Audio: Media3 / ExoPlayer via
a
MediaSessionService— background playback + lock-screen / notification controls out of the box - Library source:
MediaStore(device audio, read-only for now) - Local store: Room — playlists only; tracks are never duplicated out of
MediaStore(schemas versioned underapp/schemas/) - Images: Coil
- Server: OkHttp + kotlinx.serialization; session tokens in DataStore
- DI: manual container for now (
AppContainer); Hilt later - Min SDK: 26 (Android 8.0) · Target SDK: 36 · Compile SDK: 37
app/src/main/java/app/waveflow/
├─ WaveFlowApp.kt Application + manual DI container
├─ MainActivity.kt Scaffold → permission gate → LibraryScreen
├─ model/
│ ├─ Song.kt Domain model, source-agnostic
│ ├─ Album.kt / Artist.kt Derived from the song list
│ ├─ Library.kt Loaded library; albums / artists / index derived lazily
│ ├─ Playlist.kt Local playlist + entries
│ ├─ Grouping.kt List<Song> → albums / artists
│ └─ Search.kt Accent-insensitive filtering over a Library
├─ data/
│ ├─ LibraryStore.kt Application-scoped library, loaded once
│ ├─ MusicRepository.kt Library abstraction (Flow<List<Song>>)
│ ├─ MediaStoreMusicRepository.kt MediaStore query + ContentObserver
│ ├─ PlaylistRepository.kt Local playlist abstraction
│ ├─ RoomPlaylistRepository.kt Room-backed implementation
│ ├─ local/ Room entities, DAO, database
│ └─ remote/ WaveFlow server: HTTP, auth, session, catalogue
├─ playback/
│ ├─ PlaybackService.kt Media3 MediaSessionService (ExoPlayer)
│ ├─ PlaybackController.kt Playback facade + PlaybackState
│ ├─ Media3PlaybackController.kt MediaController connection → StateFlow
│ ├─ MediaItemMapper.kt Song / RemoteSong → MediaItem, and back
│ ├─ PlayingTrack.kt What the player holds, whatever its source
│ └─ RemoteStreamResolver.kt Marker URI → ticketed stream URL
└─ ui/
├─ theme/ Material 3 emerald theme
├─ DurationFormat.kt m:ss / h:mm:ss
├─ Labels.kt Unknown artist / album fallbacks
├─ components/ Artwork, MediaRow, SongRow, LibraryStateContainer
├─ navigation/
│ └─ WaveFlowNavigation.kt Routes + bottom bar
├─ browse/
│ ├─ AlbumsScreen.kt Adaptive cover grid
│ ├─ AlbumDetailScreen.kt Header + tracks
│ ├─ ArtistsScreen.kt Artist list
│ ├─ ArtistDetailScreen.kt Header + tracks
│ └─ DetailHeader.kt Shared header (play / shuffle)
├─ playlists/
│ ├─ PlaylistsViewModel.kt Playlist state + writes
│ ├─ PlaylistsUiState.kt Playlists + resolved tracks
│ ├─ PlaylistsScreen.kt Playlist list + creation
│ ├─ PlaylistDetailScreen.kt Header + tracks + drag-to-reorder
│ ├─ AddToPlaylistSheet.kt Long-press a song → add
│ ├─ PlaylistNameDialog.kt Create / rename prompt
│ └─ PlaylistMenu.kt Rename / delete
├─ search/
│ ├─ SearchViewModel.kt Query → filtered library
│ ├─ SearchScreen.kt Songs / albums / artists sections
│ └─ SearchField.kt Query input
├─ server/
│ ├─ ServerViewModel.kt Sign in / out, error mapping
│ ├─ ServerUiState.kt Session + progress + last failure
│ ├─ ServerScreen.kt Sign-in form and account screens
│ └─ catalog/ Remote albums / artists, paginated
├─ permission/
│ └─ AudioPermissionGate.kt Grant / deny / permanently-denied flow
├─ player/
│ ├─ PlayerViewModel.kt Owns the MediaController; playback commands
│ ├─ PlayerUiState.kt Player state
│ ├─ ArtworkAccent.kt Dominant colour from cover (Palette)
│ ├─ MiniPlayer.kt Compact bar above the library
│ └─ NowPlayingScreen.kt Full-screen player
└─ library/
├─ LibraryViewModel.kt Thin access point to LibraryStore
└─ LibraryScreen.kt Song list
One LibraryStore at the application level holds the loaded library; four
ViewModels read from it — LibraryViewModel (browsing), PlayerViewModel
(playback), PlaylistsViewModel (playlists), SearchViewModel (search).
Adding a screen means adding a ViewModel, never a second MediaStore query.
Open in Android Studio (uses its bundled JBR 21) and run the app config,
or from the command line:
./gradlew :app:assembleDebug # debug APK
./gradlew :app:installDebug # install on a connected device/emulatorThe build requires JDK 21 (Android Studio's bundled JBR). Newer system JDKs (e.g. 25) are not yet supported by the Android Gradle Plugin — build via Studio or point
org.gradle.java.homeat a JDK 21.
./gradlew testDebugUnitTest # everything below, no emulator neededAll tests run on the JVM. Robolectric provides a real android.net.Uri and an
in-memory SQLite for Room, so the DAO is exercised without a device.
| Suite | Covers |
|---|---|
PlaylistDaoTest |
duplicate adds, updatedAt bumping, positions, reorder normalisation, createWithSong atomicity, cascade delete |
LibraryStoreTest |
loading, read failures, single subscription, retry |
PlayerViewModelTest |
contextual play queue, local vs remote queue, controller release |
PlaylistsViewModelTest |
flow failures, write failures, resolution order, atomic creation, reorder rollback and staleness |
DragStateTest |
drag arithmetic: target rank, visual offset, bounds, moved |
PlaylistDetailScreenTest |
reorder accessibility actions, order restored after a failed write |
SearchTest |
matching by title / album / artist, accent and case folding, prefix ranking |
SearchViewModelTest |
query → results, clearing, following the library |
GroupingTest |
album / artist derivation, sorting, missing tags |
DurationFormatTest |
m:ss / h:mm:ss formatting |
HttpServerApiTest |
request shapes, error classes, URL handling, unknown fields |
ServerSessionRepositoryTest |
token refresh and rotation, session lifetime, sign-out |
ServerViewModelTest |
validation, error wording, connection progress |
ServerScreenTest |
sign-in form, connected account, no token on screen |
HttpCatalogApiTest |
paging params, flattened details, track ordering |
CatalogRepositoryTest |
token plumbing, retry after a refused token |
CatalogViewModelTest |
paging, end of list, in-flight guard, clear on sign-out |
MediaItemMapperTest |
local vs remote track identity, unreachable marker URI |
RemoteStreamResolverTest |
ticket swap, local passthrough, DataSpec preserved |
RemoteAlbumDetailScreenTest |
Play and Shuffle are distinct, empty album, track tap |
ArtworkUrlsTest |
URL only when a cover exists, proxy prefix, invalid address |
ServerImageAuthInterceptorTest |
signing scope, third-party host, refresh on 401 |
Fakes and the Dispatchers.Main rule live in src/test/java/app/waveflow/testing/.
Compose tests run on the JVM too: createComposeRule() works under Robolectric,
so a screen can be composed and driven without a device. What still cannot be
covered that way is the pointer gesture itself — its arithmetic is extracted
into DragState and tested there instead.
- Local file playback (MediaStore + Media3)
- Full-screen player (seek, shuffle, repeat, artwork-tinted background)
- Album / artist browsing (Navigation Compose + bottom bar)
- Local playlists (Room): create, rename, delete, add / remove tracks
- Drag-to-reorder inside a playlist
- Search across songs, albums and artists
- Compose UI tests (Robolectric, no device)
- Sign in to a WaveFlow server (session, refresh, sign-out)
- Browse the server catalogue (albums, artists, paginated)
- Stream from the server (ticketed URLs, seeking)
- Server user-data sync (playlists, favorites, ratings) — see below
- Android Auto (Media3
MediaLibraryService)
The Server tab signs in to a WaveFlow Server, keeps the session alive and browses its catalogue — albums, artists, and what each contains. A remote album plays like a local one: tap a track, or use Play / Shuffle. Nothing of the local library is sent anywhere.
The two sources stay separate by design: the tab is its own section rather than
a filter over the existing screens, and RemoteAlbum / RemoteArtist /
RemoteSong are distinct types from their local counterparts. Their ids are
UUIDs rather than MediaStore integers, and nothing can currently say that a
remote track is the same file as a local one.
Playback goes through a stream ticket: POST /tracks/{id}/stream-ticket
returns a URL that needs no Authorization header, which is what lets ExoPlayer
consume it directly — range requests for seeking included. The ticket is minted
when the player opens the track, not when the queue is built: it lives an hour,
and a long queue would outlast it before reaching its last tracks. A
ResolvingDataSource does the swap, so local files and remote tracks share one
player and one queue mechanism.
Listing endpoints return a bare array — no total, no cursor — so the end of a list is inferred from a page shorter than requested.
Cover art comes from /api/v2/artwork/{artwork_hash}, behind the same bearer as
the rest of the native API. Coil knows nothing about the session, so an
interceptor signs those requests — and only those: an origin other than the
connected server's (scheme, host and port) is never handed the token.
The URL is keyed on the hash rather than on the entity id, which the endpoint would also accept. The hash names the content, so replacing a cover changes the URL and the stale image is not served from cache for a day; and an album and its tracks share one hash, hence one cache entry and one download instead of one per row. No hash means no cover, and no URL — otherwise every coverless row would cost a 404.
Sign-in posts to /api/v2/auth/login with the device model as the session
name, so the server lists it among the account's devices. The access token
lasts fifteen minutes and is renewed through /api/v2/auth/refresh; the
refresh token rotates on every use, which is why all token work is
serialised behind one mutex — two concurrent renewals would start from the same
token and one would be rejected, dropping a session that was perfectly valid.
Tokens live in a DataStore, protected by the app sandbox rather than by
encryption: security-crypto never left alpha and is no longer maintained. A
rooted or unlocked device therefore exposes the refresh token — the mitigation
is that it can be revoked from the server. The file is excluded from cloud
backup and device transfer, so a stored token is not copied onto another
device; signing in there simply asks for the password again.
Cleartext HTTP is permitted, because a self-hosted server usually sits on a LAN without a certificate. An address typed without a scheme is joined over HTTPS.
The sync protocol — /api/v2/sync/snapshot, /changes, /ack and a wake-up
socket — already exists server-side, specified in its RFC-003. Syncing the
local library is nonetheless out of reach, and not because of this app: the
protocol carries server track UUIDs, and RFC-003 states it "never guesses a
local/server track match". Reconciliation is a later milestone with its own
RFC. Until that exists, local playlists stay local.
Whenever it does land, playlist_songs will need a Room migration: it keys on
MediaStore ids, which do not survive a device re-index, let alone identify a
track to a server.
GPL-3.0 — same as WaveFlow desktop.