diff --git a/.editorconfig b/.editorconfig index ede61d03f..fa14e6d65 100644 --- a/.editorconfig +++ b/.editorconfig @@ -8,7 +8,6 @@ ktlint_standard_property-naming=disabled ktlint_standard_filename=disabled ktlint_standard_package-name=disabled ktlint_code_style=android_studio -ktlint_function_naming_ignore_when_annotated_with = Composable +ktlint_function_naming_ignore_when_annotated_with=Composable ktlint_standard_trailing-comma-on-declaration-site=disabled ktlint_standard_trailing-comma-on-call-site=disabled -ktlint_function_naming_ignore_when_annotated_with=Composable diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..5ddcd2f24 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,170 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +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. + +## Build Commands + +All commands run from the repository root. + +```bash +# 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 (requires device/emulator) +./gradlew :android:app:connectedCheck + +# Lint (KTLint) +./support/scripts/lint + +# Build release bundle +./gradlew :android:app:bundleRelease +``` + +## Architecture + +### Module Structure + +- **`: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 + +### UI Patterns + +The app uses **MVP (Model-View-Presenter)** with Fragments. Most screens are Fragment-based with custom `ViewBinder` pattern for RecyclerView items. Compose is used in newer screens (onboarding, genre list, shared components). Navigation uses Android Navigation Component with Safe Args. + +Key MVP base classes: +- `BasePresenter` — coroutine-scoped presenter with SupervisorJob +- `BaseContract` — defines View/Presenter interfaces per screen + +### Playback Flow + +PlaybackManager orchestrates playback. It coordinates QueueManager, ExoPlayerPlayback, AudioFocusHelper, and PlaybackService (foreground service with MediaBrowserServiceCompat). State changes propagate via PlaybackWatcher callbacks. + +### Data Layer + +Repository pattern backed by Room database. MediaProvider implementations (local, Jellyfin, Emby, Plex) return `Flow` for reactive updates. MediaImporter coordinates discovery across providers. + +### DI + +Hilt with `@HiltAndroidApp`, `@AndroidEntryPoint`. DI modules in `app/di/`: AppModule, RepositoryModule, MediaProviderModule, ImageLoaderModule. + +## Build Configuration + +- **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**: Defined in `buildSrc/src/main/kotlin/AppVersion.kt` +- Debug builds use `.dev` app ID suffix +- R8 full mode is disabled (Retrofit compatibility) + +## Code Style + +- KTLint with `android_studio` style (`.editorconfig`) +- Composable functions exempt from naming rules +- Property naming, filename, and package-name rules disabled +- Git hooks available in `support/scripts/git/` for pre-commit lint and pre-push tests + +## Branch Conventions + +- Base branch: `dev` +- Branch prefixes: `feature/`, `fix/`, `tech/`, `doc/` +- All changes via PR to `main`; pushes to `main` trigger deployment to Google Play (internal track) + +## Testing + +### Compose UI Characterisation 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:** +```bash +./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). + +### Robot Pattern + +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 captures +- `assertTextDisplayed(text)` / `assertTextNotDisplayed(text)` — hides node selectors +- `openContextMenu()` — hides content description selectors +- `clickText(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:** +```kotlin +@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 +} +``` + +### Scenario Factories + +Top-level functions that construct `ViewState` with sensible defaults. Reduce boilerplate without hiding what matters. + +```kotlin +// SongListScenarios.kt +readySongList(songs = listOf(createSong(name = "My Song"))) +scanningSongList(Progress(50, 200)) +emptySongList() +loadingSongList // val, not a function — Loading has no parameters +``` + +### Model Factories + +`createSong()`, `createGenre()`, `createPlaylist()` in `app/src/test/.../creationFunctions.kt`. All parameters have defaults — override only what matters for the test. + +### Adding a New Screen's Tests + +1. Create `*Robot.kt` — constructor takes `ComposeContentTestRule`, provides `setContent()`, selectors, callback captures +2. Create `*Scenarios.kt` — top-level functions for each ViewState variant +3. Create `*Test.kt` — `@RunWith(RobolectricTestRunner::class)`, instantiate robot from `composeTestRule` +4. Add model factories to `creationFunctions.kt` if needed + +### Known Robolectric Limitations + +- **FastScroller + DropdownMenu:** The `FastScroller` overlay causes `DropdownMenu` popups 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 — see `GenreListRobot.setItemContent()`. diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/ComposeContextualToolbarHelper.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/ComposeContextualToolbarHelper.kt index 21ad4da56..c20a71428 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/ComposeContextualToolbarHelper.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/ComposeContextualToolbarHelper.kt @@ -2,40 +2,15 @@ package com.simplecityapps.shuttle.ui.common import androidx.appcompat.widget.Toolbar import androidx.core.view.isVisible -import com.simplecityapps.shuttle.model.MediaProviderType -import com.simplecityapps.shuttle.model.Song -import kotlin.collections.distinct -import kotlin.collections.map -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.map import timber.log.Timber -class ComposeContextualToolbarHelper { +class ComposeContextualToolbarHelper( + private val selectionState: SelectionState, +) { var toolbar: Toolbar? = null var contextualToolbar: Toolbar? = null - private val _selectedSongsState = MutableStateFlow(emptySet()) - val selectedSongsState = _selectedSongsState.asStateFlow() - val selectedSongCountState = _selectedSongsState.asStateFlow() - .map { selectedSongs -> selectedSongs.size } - - fun toggleSongSelection(song: Song) { - Timber.d("foo: toggleSongSelection: ${hashCode()}") - _selectedSongsState.value = if (_selectedSongsState.value.contains(song)) { - _selectedSongsState.value - song - } else { - _selectedSongsState.value + song - } - } - - fun clearSelection() { - _selectedSongsState.value = emptySet() - } - - fun isSelecting() = _selectedSongsState.value.isNotEmpty() - fun show() { contextualToolbar?.let { contextualToolbar -> toolbar?.isVisible = false @@ -50,11 +25,6 @@ class ComposeContextualToolbarHelper { toolbar?.isVisible = true contextualToolbar?.isVisible = false contextualToolbar?.setNavigationOnClickListener(null) - clearSelection() + selectionState.clear() } - - fun selectedSongsMediaProviders(): List = selectedSongsState - .value - .map { it.mediaProvider } - .distinct() } diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/SelectionState.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/SelectionState.kt new file mode 100644 index 000000000..b39d807dc --- /dev/null +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/common/SelectionState.kt @@ -0,0 +1,26 @@ +package com.simplecityapps.shuttle.ui.common + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map + +class SelectionState { + + private val _selectedItems = MutableStateFlow(emptySet()) + val selectedItems = _selectedItems.asStateFlow() + val selectedCount = _selectedItems.map { it.size } + + fun toggle(item: T) { + _selectedItems.value = if (_selectedItems.value.contains(item)) { + _selectedItems.value - item + } else { + _selectedItems.value + item + } + } + + fun clear() { + _selectedItems.value = emptySet() + } + + fun isActive() = _selectedItems.value.isNotEmpty() +} diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt index 12f5c39a5..a00dc1b86 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongList.kt @@ -151,7 +151,7 @@ private fun SongList( LazyColumn( modifier = Modifier .fillMaxWidth() - .testTag("genres-list-lazy-column"), + .testTag("songs-list-lazy-column"), verticalArrangement = Arrangement.spacedBy(16.dp), contentPadding = PaddingValues(vertical = 16.dp, horizontal = 8.dp), state = state, diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt index 986c0a82f..46979d254 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListFragment.kt @@ -20,11 +20,11 @@ import androidx.lifecycle.repeatOnLifecycle import com.simplecityapps.shuttle.R import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.sorting.SongSortOrder +import com.simplecityapps.shuttle.ui.common.ComposeContextualToolbarHelper import com.simplecityapps.shuttle.ui.common.TagEditorMenuSanitiser import com.simplecityapps.shuttle.ui.common.autoCleared import com.simplecityapps.shuttle.ui.common.dialog.TagEditorAlertDialog import com.simplecityapps.shuttle.ui.common.dialog.showDeleteDialog -import com.simplecityapps.shuttle.ui.common.error.UserFriendlyError import com.simplecityapps.shuttle.ui.common.error.userDescription import com.simplecityapps.shuttle.ui.common.view.findToolbarHost import com.simplecityapps.shuttle.ui.screens.playlistmenu.CreatePlaylistDialogFragment @@ -51,6 +51,8 @@ class SongListFragment : private val viewModel: SongListViewModel by viewModels() + private var contextualToolbarHelper: ComposeContextualToolbarHelper by autoCleared() + private lateinit var playlistMenuView: PlaylistMenuView // Lifecycle @@ -80,25 +82,28 @@ class SongListFragment : composeView = view.findViewById(R.id.composeView) + contextualToolbarHelper = ComposeContextualToolbarHelper(viewModel.selectionState) + updateContextualToolbar() viewLifecycleOwner.lifecycleScope.launch { - viewModel.contextualToolbarHelper.selectedSongCountState + viewModel.selectionState.selectedCount .collect { count -> if (count == 0) { - viewModel.contextualToolbarHelper.hide() + contextualToolbarHelper.hide() } else { - viewModel.contextualToolbarHelper.show() + contextualToolbarHelper.show() - viewModel.contextualToolbarHelper.contextualToolbar?.title = + contextualToolbarHelper.contextualToolbar?.title = Phrase.fromPlural(requireContext(), R.plurals.multi_select_items_selected, count) .put("count", count) .format() - viewModel.contextualToolbarHelper.contextualToolbar?.menu?.let { menu -> + contextualToolbarHelper.contextualToolbar?.menu?.let { menu -> TagEditorMenuSanitiser.sanitise( menu, - viewModel.contextualToolbarHelper - .selectedSongsMediaProviders() + viewModel.selectionState.selectedItems.value + .map { it.mediaProvider } + .distinct(), ) } } @@ -131,7 +136,7 @@ class SongListFragment : onSongClick = { song -> viewModel.onSongClick(song) { result -> result.onFailure { error -> - showLoadError(error as Error) + showLoadError(error) } } }, @@ -172,17 +177,15 @@ class SongListFragment : }, onDelete = { song -> showDeleteDialog(requireContext(), song.name) { - try { - viewModel.delete(song) - } catch (e: UserFriendlyError) { - showDeleteError(e) + viewModel.delete(song).onFailure { error -> + showDeleteError(error) } } }, onShuffle = { viewModel.shuffle { result -> result.onFailure { error -> - showLoadError(error as Error) + showLoadError(error) } } } @@ -255,18 +258,20 @@ class SongListFragment : private fun updateContextualToolbar() { findToolbarHost()?.apply { - contextualToolbar?.let { contextualToolbar -> - contextualToolbar.menu.clear() - contextualToolbar.inflateMenu(R.menu.menu_multi_select) + contextualToolbar?.let { ctxToolbar -> + ctxToolbar.menu.clear() + ctxToolbar.inflateMenu(R.menu.menu_multi_select) TagEditorMenuSanitiser.sanitise( - contextualToolbar.menu, - viewModel.contextualToolbarHelper.selectedSongsMediaProviders(), + ctxToolbar.menu, + viewModel.selectionState.selectedItems.value + .map { it.mediaProvider } + .distinct(), ) - contextualToolbar.setOnMenuItemClickListener { menuItem -> - playlistMenuView.createPlaylistMenu(contextualToolbar.menu) - val selectedSongs = viewModel.contextualToolbarHelper.selectedSongsState.value.toList() + ctxToolbar.setOnMenuItemClickListener { menuItem -> + playlistMenuView.createPlaylistMenu(ctxToolbar.menu) + val selectedSongs = viewModel.selectionState.selectedItems.value.toList() if (playlistMenuView.handleMenuItem(menuItem, PlaylistData.Songs(selectedSongs))) { - viewModel.contextualToolbarHelper.hide() + contextualToolbarHelper.hide() return@setOnMenuItemClickListener true } when (menuItem.itemId) { @@ -277,18 +282,18 @@ class SongListFragment : R.id.editTags -> { TagEditorAlertDialog.newInstance(selectedSongs) .show(childFragmentManager) - viewModel.contextualToolbarHelper.hide() + contextualToolbarHelper.hide() true } else -> false } } } - viewModel.contextualToolbarHelper.contextualToolbar = contextualToolbar - viewModel.contextualToolbarHelper.toolbar = toolbar + contextualToolbarHelper.contextualToolbar = contextualToolbar + contextualToolbarHelper.toolbar = toolbar - if (viewModel.contextualToolbarHelper.isSelecting()) { - viewModel.contextualToolbarHelper.show() + if (viewModel.selectionState.isActive()) { + contextualToolbarHelper.show() } } } @@ -309,8 +314,11 @@ class SongListFragment : } } - fun showLoadError(error: Error) { - Toast.makeText(context, error.userDescription(resources), Toast.LENGTH_LONG).show() + fun showLoadError(error: Throwable) { + val message = (error as? Error)?.userDescription(resources) + ?: error.message + ?: resources.getString(R.string.error_unknown) + Toast.makeText(context, message, Toast.LENGTH_LONG).show() } fun showTagEditor(song: Song) { @@ -327,8 +335,11 @@ class SongListFragment : ).show() } - fun showDeleteError(error: Error) { - Toast.makeText(requireContext(), error.userDescription(resources), Toast.LENGTH_LONG).show() + fun showDeleteError(error: Throwable) { + val message = (error as? Error)?.userDescription(resources) + ?: error.message + ?: resources.getString(R.string.error_unknown) + Toast.makeText(requireContext(), message, Toast.LENGTH_LONG).show() } // CreatePlaylistDialogFragment.Listener Implementation diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt index 4484b6462..3eb962fdb 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListItem.kt @@ -115,7 +115,7 @@ fun SongListItem( ) { Text( modifier = Modifier.fillMaxWidth(), - text = song.name ?: "no name", // FIXME + text = song.name ?: stringResource(R.string.unknown), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onBackground, ) diff --git a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt index 7058b4a93..de13538d9 100644 --- a/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt +++ b/android/app/src/main/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModel.kt @@ -1,7 +1,6 @@ package com.simplecityapps.shuttle.ui.screens.library.songs import android.app.Application -import androidx.annotation.OpenForTesting import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile import androidx.lifecycle.AndroidViewModel @@ -18,12 +17,13 @@ import com.simplecityapps.shuttle.model.Song import com.simplecityapps.shuttle.persistence.GeneralPreferenceManager import com.simplecityapps.shuttle.query.SongQuery import com.simplecityapps.shuttle.sorting.SongSortOrder -import com.simplecityapps.shuttle.ui.common.ComposeContextualToolbarHelper +import com.simplecityapps.shuttle.di.IoDispatcher +import com.simplecityapps.shuttle.ui.common.SelectionState import com.simplecityapps.shuttle.ui.common.error.UserFriendlyError import com.simplecityapps.shuttle.ui.screens.library.SortPreferenceManager import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject -import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine @@ -33,13 +33,13 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -@OpenForTesting @HiltViewModel class SongListViewModel @Inject constructor( private val songRepository: SongRepository, private val playbackManager: PlaybackManager, private val queueManager: QueueManager, private val sortPreferenceManager: SortPreferenceManager, + @IoDispatcher private val ioDispatcher: CoroutineDispatcher, preferenceManager: GeneralPreferenceManager, mediaImportObserver: MediaImportObserver, application: Application, @@ -50,7 +50,7 @@ class SongListViewModel @Inject constructor( private val _selectedSortOrder = MutableStateFlow(sortPreferenceManager.sortOrderSongList) val selectedSortOrder = _selectedSortOrder.asStateFlow() - val contextualToolbarHelper = ComposeContextualToolbarHelper() + val selectionState = SelectionState() val theme = preferenceManager.theme(viewModelScope) val accent = preferenceManager.accent(viewModelScope) @@ -61,7 +61,7 @@ class SongListViewModel @Inject constructor( .getSongs(SongQuery.All(sortOrder = sortPreferenceManager.sortOrderSongList)) .filterNotNull(), mediaImportObserver.songImportState, - contextualToolbarHelper.selectedSongsState, + selectionState.selectedItems, _selectedSortOrder, ) { songs, songImportState, selectedSongs, __selectedSortOrder -> if (songImportState is SongImportState.ImportProgress) { @@ -78,8 +78,8 @@ class SongListViewModel @Inject constructor( } fun onSongClick(song: Song, completion: (Result) -> Unit) { - if (contextualToolbarHelper.isSelecting()) { - contextualToolbarHelper.toggleSongSelection(song) + if (selectionState.isActive()) { + selectionState.toggle(song) completion(Result.success(true)) } else { play(song, completion) @@ -87,7 +87,7 @@ class SongListViewModel @Inject constructor( } fun onSongLongClick(song: Song) { - contextualToolbarHelper.toggleSongSelection(song) + selectionState.toggle(song) } private fun play(song: Song, completion: (Result) -> Unit) { @@ -114,8 +114,8 @@ class SongListViewModel @Inject constructor( fun addSelectedToQueue() { viewModelScope.launch { - playbackManager.addToQueue(contextualToolbarHelper.selectedSongsState.value.toList()) - contextualToolbarHelper.clearSelection() + playbackManager.addToQueue(selectionState.selectedItems.value.toList()) + selectionState.clear() } } @@ -133,18 +133,19 @@ class SongListViewModel @Inject constructor( } } - fun delete(song: Song) { + fun delete(song: Song): Result { val context = getApplication().applicationContext val documentFile = DocumentFile.fromSingleUri(context, song.path.toUri()) if (documentFile?.delete() == false) { - throw UserFriendlyError(context.getString(R.string.delete_song_failed)) + return Result.failure(UserFriendlyError(context.getString(R.string.delete_song_failed))) } viewModelScope.launch { songRepository.remove(song) queueManager.remove(song) } + return Result.success(Unit) } fun shuffle(completion: (Result) -> Unit) { @@ -173,7 +174,7 @@ class SongListViewModel @Inject constructor( } viewModelScope.launch { - withContext(Dispatchers.IO) { + withContext(ioDispatcher) { sortPreferenceManager.sortOrderSongList = sortOrder _selectedSortOrder.value = sortOrder } diff --git a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt index 6c799514e..a0be9de78 100644 --- a/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt +++ b/android/app/src/test/java/com/simplecityapps/shuttle/ui/screens/library/songs/SongListViewModelTest.kt @@ -24,7 +24,6 @@ import io.mockk.coVerify import io.mockk.every import io.mockk.just import io.mockk.mockk -import io.mockk.spyk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow @@ -36,7 +35,6 @@ import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.setMain import org.junit.After import org.junit.Before -import org.junit.Ignore import org.junit.Test @ExperimentalCoroutinesApi @@ -170,12 +168,12 @@ class SongListViewModelTest { mockSongs(listOf(SONG)) viewModel = createViewModel() advanceUntilIdle() - viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() + viewModel.selectionState.isActive().shouldBeFalse() viewModel.onSongLongClick(SONG) - viewModel.contextualToolbarHelper.isSelecting().shouldBeTrue() - viewModel.contextualToolbarHelper.selectedSongsState.value + viewModel.selectionState.isActive().shouldBeTrue() + viewModel.selectionState.selectedItems.value .shouldBe(listOf(SONG)) } @@ -189,8 +187,8 @@ class SongListViewModelTest { viewModel.onSongClick(SONG2) {} - viewModel.contextualToolbarHelper.isSelecting().shouldBeTrue() - viewModel.contextualToolbarHelper.selectedSongsState.value + viewModel.selectionState.isActive().shouldBeTrue() + viewModel.selectionState.selectedItems.value .shouldBe(songs) } @@ -205,8 +203,8 @@ class SongListViewModelTest { viewModel.onSongClick(SONG1) {} - viewModel.contextualToolbarHelper.isSelecting().shouldBeTrue() - viewModel.contextualToolbarHelper.selectedSongsState.value + viewModel.selectionState.isActive().shouldBeTrue() + viewModel.selectionState.selectedItems.value .shouldBe(listOf(SONG2)) } @@ -215,13 +213,13 @@ class SongListViewModelTest { mockSongs(listOf(SONG)) viewModel = createViewModel() advanceUntilIdle() - viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() + viewModel.selectionState.isActive().shouldBeFalse() viewModel.onSongLongClick(SONG) viewModel.onSongClick(SONG) {} - viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() - viewModel.contextualToolbarHelper.selectedSongsState.value + viewModel.selectionState.isActive().shouldBeFalse() + viewModel.selectionState.selectedItems.value .shouldBe(emptyList()) } @@ -238,7 +236,7 @@ class SongListViewModelTest { advanceUntilIdle() coVerify(exactly = 1) { mockPlaybackManager.addToQueue(listOf(SONG2)) } - viewModel.contextualToolbarHelper.isSelecting().shouldBeFalse() + viewModel.selectionState.isActive().shouldBeFalse() } @Test @@ -304,30 +302,16 @@ class SongListViewModelTest { } @Test - @Ignore( - """Fails due to running in IO dispatcher. Fix by injecting - StandardTestDispatcher when creating the view model.""" - ) fun `sets the sort order`() = runTest { - val spiedSortPreferenceManager = spyk( - SortPreferenceManager(mockk(relaxed = true)) - ) - viewModel = SongListViewModel( - songRepository = mockSongRepository, - playbackManager = mockPlaybackManager, - queueManager = mockQueueManager, - sortPreferenceManager = spiedSortPreferenceManager, - preferenceManager = mockPreferenceManager, - mediaImportObserver = mockMediaImportObserver, - application = mockApplication, - ) + every { mockSortPreferenceManager.sortOrderSongList = any() } just Runs + viewModel = createViewModel() advanceUntilIdle() viewModel.setSortOrder(SongSortOrder.ArtistGroupKey) advanceUntilIdle() viewModel.selectedSortOrder.value.shouldBe(SongSortOrder.ArtistGroupKey) - io.mockk.verify { spiedSortPreferenceManager.sortOrderSongList = SongSortOrder.ArtistGroupKey } + io.mockk.verify { mockSortPreferenceManager.sortOrderSongList = SongSortOrder.ArtistGroupKey } } @Test @@ -356,6 +340,7 @@ class SongListViewModelTest { playbackManager = mockPlaybackManager, queueManager = mockQueueManager, sortPreferenceManager = mockSortPreferenceManager, + ioDispatcher = testDispatcher, preferenceManager = mockPreferenceManager, mediaImportObserver = mockMediaImportObserver, application = mockApplication, diff --git a/android/app/src/test/java/com/simplecityapps/utils.kt b/android/app/src/test/java/com/simplecityapps/utils.kt index 2d69afec4..97a910e29 100644 --- a/android/app/src/test/java/com/simplecityapps/utils.kt +++ b/android/app/src/test/java/com/simplecityapps/utils.kt @@ -1,6 +1,6 @@ package com.simplecityapps import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableSharedFlow -fun neverEmittingFlow(): Flow = flowOf() +fun neverEmittingFlow(): Flow = MutableSharedFlow() diff --git a/android/core/src/main/java/com/simplecityapps/shuttle/di/CoroutineModule.kt b/android/core/src/main/java/com/simplecityapps/shuttle/di/CoroutineModule.kt index 1b55d2c62..f1a5a791d 100644 --- a/android/core/src/main/java/com/simplecityapps/shuttle/di/CoroutineModule.kt +++ b/android/core/src/main/java/com/simplecityapps/shuttle/di/CoroutineModule.kt @@ -6,6 +6,7 @@ import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Qualifier import javax.inject.Singleton +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -34,6 +35,10 @@ class CoroutineModule { @AppSupervisorJob job: Job, coroutineExceptionHandler: CoroutineExceptionHandler ): CoroutineScope = CoroutineScope(Dispatchers.Main + job + coroutineExceptionHandler) + + @Provides + @IoDispatcher + fun provideIoDispatcher(): CoroutineDispatcher = Dispatchers.IO } @Retention(AnnotationRetention.BINARY) @@ -43,3 +48,7 @@ annotation class AppCoroutineScope @Retention(AnnotationRetention.BINARY) @Qualifier annotation class AppSupervisorJob + +@Retention(AnnotationRetention.BINARY) +@Qualifier +annotation class IoDispatcher diff --git a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SongDataDao.kt b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SongDataDao.kt index 05d0cc07a..ec56d1adf 100644 --- a/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SongDataDao.kt +++ b/android/mediaprovider/local/src/main/java/com/simplecityapps/localmediaprovider/local/data/room/dao/SongDataDao.kt @@ -29,7 +29,6 @@ abstract class SongDataDao { abstract fun getAllSongData(): Flow> fun getAll(): Flow> = getAllSongData().map { list -> - Timber.i("getAll() ${list.size}") list.map { songData -> songData.toSong() }