Skip to content

WaveFlow for Android

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.

Stack

  • 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 under app/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

Project layout

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.

Build

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/emulator

The 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.home at a JDK 21.

Tests

./gradlew testDebugUnitTest # everything below, no emulator needed

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

SuiteCovers
PlaylistDaoTestduplicate adds, updatedAt bumping, positions, reorder normalisation, createWithSong atomicity, cascade delete
LibraryStoreTestloading, read failures, single subscription, retry
PlayerViewModelTestcontextual play queue, local vs remote queue, controller release
PlaylistsViewModelTestflow failures, write failures, resolution order, atomic creation, reorder rollback and staleness
DragStateTestdrag arithmetic: target rank, visual offset, bounds, moved
PlaylistDetailScreenTestreorder accessibility actions, order restored after a failed write
SearchTestmatching by title / album / artist, accent and case folding, prefix ranking
SearchViewModelTestquery → results, clearing, following the library
GroupingTestalbum / artist derivation, sorting, missing tags
DurationFormatTestm:ss / h:mm:ss formatting
HttpServerApiTestrequest shapes, error classes, URL handling, unknown fields
ServerSessionRepositoryTesttoken refresh and rotation, session lifetime, sign-out
ServerViewModelTestvalidation, error wording, connection progress
ServerScreenTestsign-in form, connected account, no token on screen
HttpCatalogApiTestpaging params, flattened details, track ordering
CatalogRepositoryTesttoken plumbing, retry after a refused token
CatalogViewModelTestpaging, end of list, in-flight guard, clear on sign-out
CatalogSearchTestdebounce pinned both ways, blank queries, failures, sign-out
MediaItemMapperTestlocal vs remote track identity, unreachable marker URI
RemoteStreamResolverTestticket swap, local passthrough, DataSpec preserved
RemoteMediaCacheTestsecond read costs no bytes and no ticket, local bypass
RemoteAlbumDetailScreenTestPlay and Shuffle are distinct, empty album, track tap
ArtworkUrlsTestURL only when a cover exists, proxy prefix, invalid address
ServerImageAuthInterceptorTestsigning 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.

Roadmap

  • 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 (device and server, separately)
  • 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
  • Playback cache for streamed tracks
  • Android Auto (Media3 MediaLibraryService)

Server

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.

Search stays split in two. The magnifier in the top bar searches the device; the Server tab has its own field. RFC-003 forbids guessing that a local track and a server track are the same recording, so a single merged result list would show the same album twice with nothing to explain why — it would pose on screen the question the protocol has not answered. When a device search finds nothing and a server is connected, an explicit Chercher sur le serveur button hands the query over; results never drift in on their own.

Unlike the local search, which filters in memory, each keystroke here would hit the network, so the query is debounced and the previous request is dropped.

Streamed tracks are cached on disk, 200 MB with least-recently-used eviction, in the app cache directory so the system can reclaim it. The cache wraps the ticket resolver rather than sitting behind it: a cached track asks the server for nothing at all, and the cache key is the item's — not the stream URL, which carries a single-use ticket and would never match itself. The key names the whole rendering — format and bitrate, not just the track — since the server serves the same track in several versions, exactly as it keys its own cache entries. Device files bypass the cache entirely; they are already on disk.

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.

Sync

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.

License

GPL-3.0 — same as WaveFlow desktop.

About

Native Android client for WaveFlow — local music player. Kotlin + Jetpack Compose + Media3.

Resources

Code of conduct

Contributing

Security policy

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages