This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
S2 Music Player — an Android app for local music playback and streaming via Jellyfin, Emby, and Plex. Features Android Auto, Chromecast, custom EQ, replay gain, sleep timer, batch tag editing, and Material 3 theming.
All commands run from the repository root.
# Build debug APK
./gradlew :android:app:assembleDebug
# Run all unit tests
./gradlew testDebugUnitTest
# Or via script:
./support/scripts/unit-test
# Run a single module's tests
./gradlew :android:playback:testDebugUnitTest
# Run instrumented tests (Gradle Managed Device — auto-provisions emulator)
./gradlew :android:app:pixel6Api34AtdDebugAndroidTest
# Or via the "smoke" device group:
./gradlew :android:app:smokeGroupDebugAndroidTest
# Lint (KTLint)
./support/scripts/lint
# Build release bundle
./gradlew :android:app:bundleRelease:android:app— Main application: UI screens, DI setup, presenters, navigation:android:playback— ExoPlayer wrapper, PlaybackManager, PlaybackService, queue management, audio focus:android:mediaprovider:core— MediaProvider interface, MediaImporter, repository interfaces (Song, Album, Playlist, Genre):android:mediaprovider:local— Local MediaStore/TagLib provider implementation:android:mediaprovider:jellyfin|emby|plex— Remote streaming provider implementations:android:data— Room database, Parcelable data models:android:core— Shared utilities, logging, Hilt setup:android:networking— Retrofit + OkHttp + Moshi network layer:android:imageloader— Glide image loading:android:trial— Trial/subscription management via Play Billing:android:remote-config— Firebase Remote Config wrapper
Legacy screens use MVP (Model-View-Presenter) with Fragments, custom ViewBinder pattern for RecyclerView items, BasePresenter<T : View>, and BaseContract. New and migrated screens use Compose + ViewModel with unidirectional data flow — see docs/architecture/compose-viewmodel-udf.md for the canonical patterns and principles. Non-trivial ViewModel action logic is extracted into use cases — classes with a single operator fun invoke, injected via Hilt (see principle #8a in the UDF doc). Navigation uses Android Navigation Component with Safe Args.
PlaybackManager orchestrates playback. It coordinates QueueManager, ExoPlayerPlayback, AudioFocusHelper, and PlaybackService (foreground service with MediaBrowserServiceCompat). State changes propagate via PlaybackWatcher callbacks.
Repository pattern backed by Room database. MediaProvider implementations (local, Jellyfin, Emby, Plex) return Flow<FlowEvent> for reactive updates. MediaImporter coordinates discovery across providers.
Hilt with @HiltAndroidApp, @AndroidEntryPoint. DI modules in app/di/: AppModule, RepositoryModule, MediaProviderModule, ImageLoaderModule.
- Kotlin 2.x, Java 17 (with core library desugaring for API 23+)
- Min SDK 23, Target/Compile SDK 36
- ExoPlayer: Custom build (
2.14.2-shuttle-16kb) with FLAC/Opus extensions as local AARs - Version catalog:
gradle/libs.versions.toml - Versioning: Date-based from git tags (
vYYMMDDNN→ version codeYYMMDDNN, version nameYYYY.MM.DD) - Debug builds use
.devapp ID suffix - R8 full mode is disabled (Retrofit compatibility)
- KTLint with
android_studiostyle (.editorconfig) - Composable functions exempt from naming rules
- Property naming, filename, package-name, wildcard-imports, and backing-property-naming rules disabled
- A Claude Code hook auto-formats Kotlin files on every edit (
support/scripts/lint -F)
# Check lint
support/scripts/lint
# Auto-fix lint
support/scripts/lint -F- Trunk-based: commit and push directly to
main - Tag
vYYMMDDNN(e.g.git tag v26032801 && git push origin v26032801) triggers build + deploy to Google Play (internal track) - External contributors use PRs to
main(CI runs lint, unit tests, snapshot tests, instrumented tests)
Robolectric-based Compose tests that verify observable UI behaviour. These allow safe rearchitecting of Compose screens and ViewModels — if the UI still looks right, the tests pass.
Run them:
./gradlew :android:app:testDebugUnitTest --tests "com.simplecityapps.shuttle.ui.screens.library.songs.SongListTest"
./gradlew :android:app:testDebugUnitTest --tests "com.simplecityapps.shuttle.ui.screens.library.genres.GenreListTest"Configuration: android/app/src/test/resources/robolectric.properties sets sdk=34, graphics=NATIVE, and application=android.app.Application (bypasses Hilt app init for fast, isolated tests).
Each Compose screen has a robot that encapsulates selectors and interaction mechanics. Tests express what they verify, not how to find Compose nodes. When the implementation changes (test tags, content descriptions, layout structure), update the robot — not every test.
Files per screen:
songs/
SongListTest.kt # test cases
SongListRobot.kt # selector/interaction encapsulation
SongListScenarios.kt # ViewState factories
Robot responsibilities:
setContent(viewState)— renders the composable with callback capturesassertTextDisplayed(text)/assertTextNotDisplayed(text)— hides node selectorsopenContextMenu()— hides content description selectorsclickText(text)/clickMenuItem(text)— interaction primitives- Callback capture fields (
lastAddedToQueue,lastDeleted, etc.) — avoid verbose lambda setup in tests
Robot boundaries — keep it thin:
- The robot hides selectors (content descriptions, test tags, node matchers)
- The robot does NOT hide behaviour — tests compose primitives to describe what they verify
- Assertions use user-visible text, not implementation details
- No screen-specific compound assertions like
assertContextMenuComplete()— tests list what they expect
Example test:
@Test
fun `context menu invokes onAddToQueue`() {
val song = createSong(name = "Queue Me")
robot.setContent(readySongList(songs = listOf(song)))
robot.openContextMenu()
robot.clickMenuItem("Add to Queue")
robot.lastAddedToQueue shouldBe song
}Top-level functions that construct ViewState with sensible defaults. Reduce boilerplate without hiding what matters.
// SongListScenarios.kt
readySongList(songs = listOf(createSong(name = "My Song")))
scanningSongList(Progress(50, 200))
emptySongList()
loadingSongList // val, not a function — Loading has no parameterscreateSong(), createGenre(), createPlaylist() in app/src/test/.../creationFunctions.kt. All parameters have defaults — override only what matters for the test.
- Create
*Robot.kt— constructor takesComposeContentTestRule, providessetContent(), selectors, callback captures - Create
*Scenarios.kt— top-level functions for each ViewState variant - Create
*Test.kt—@RunWith(RobolectricTestRunner::class), instantiate robot fromcomposeTestRule - Add model factories to
creationFunctions.ktif needed
- FastScroller + DropdownMenu: The
FastScrolleroverlay causesDropdownMenupopups to be immediately dismissed under Robolectric. Context menu tests that need dropdowns should render the list item composable directly (e.g.GenreListItem) rather than the full list. The robot encapsulates this — seeGenreListRobot.setItemContent().