From 151b69bedb1a24a7517795dc79548a65ec025841 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 3 Aug 2023 11:52:09 -0600 Subject: [PATCH 01/25] list: add selection menu Add a menu dialog for selections. This more or less completes the bottom sheet menu functionality. Resolves #454. --- .../java/org/oxycblt/auxio/MainFragment.kt | 6 ++- .../auxio/detail/AlbumDetailFragment.kt | 5 +- .../auxio/detail/ArtistDetailFragment.kt | 9 ++-- .../auxio/detail/GenreDetailFragment.kt | 7 +-- .../auxio/detail/PlaylistDetailFragment.kt | 7 +-- .../org/oxycblt/auxio/home/HomeFragment.kt | 1 + .../auxio/home/list/AlbumListFragment.kt | 2 +- .../auxio/home/list/ArtistListFragment.kt | 2 +- .../auxio/home/list/GenreListFragment.kt | 2 +- .../auxio/home/list/PlaylistListFragment.kt | 2 +- .../auxio/home/list/SongListFragment.kt | 2 +- .../org/oxycblt/auxio/list/ListViewModel.kt | 12 +++++ .../oxycblt/auxio/list/SelectionFragment.kt | 22 ++------- .../java/org/oxycblt/auxio/list/menu/Menu.kt | 7 +++ .../auxio/list/menu/MenuDialogFragmentImpl.kt | 48 +++++++++++++++++++ .../oxycblt/auxio/list/menu/MenuViewModel.kt | 7 +++ .../auxio/playback/PlaybackPanelFragment.kt | 2 +- .../oxycblt/auxio/search/SearchFragment.kt | 11 +++-- .../org/oxycblt/auxio/util/FrameworkUtil.kt | 3 +- .../res/menu/{item_album.xml => album.xml} | 0 .../{item_album_song.xml => album_song.xml} | 0 ...item_artist_album.xml => artist_album.xml} | 0 .../{item_artist_song.xml => artist_song.xml} | 0 ...item_detail_album.xml => detail_album.xml} | 0 ...em_detail_parent.xml => detail_parent.xml} | 0 ...etail_playlist.xml => detail_playlist.xml} | 0 .../res/menu/{item_parent.xml => parent.xml} | 0 ...em_playback_song.xml => playback_song.xml} | 0 .../menu/{item_playlist.xml => playlist.xml} | 0 ...em_playlist_song.xml => playlist_song.xml} | 0 app/src/main/res/menu/selection.xml | 30 ++++++++++++ .../main/res/menu/{item_song.xml => song.xml} | 0 app/src/main/res/menu/toolbar_selection.xml | 16 +------ app/src/main/res/navigation/inner.xml | 28 +++++++++++ app/src/main/res/values/strings.xml | 4 +- 35 files changed, 177 insertions(+), 58 deletions(-) rename app/src/main/res/menu/{item_album.xml => album.xml} (100%) rename app/src/main/res/menu/{item_album_song.xml => album_song.xml} (100%) rename app/src/main/res/menu/{item_artist_album.xml => artist_album.xml} (100%) rename app/src/main/res/menu/{item_artist_song.xml => artist_song.xml} (100%) rename app/src/main/res/menu/{item_detail_album.xml => detail_album.xml} (100%) rename app/src/main/res/menu/{item_detail_parent.xml => detail_parent.xml} (100%) rename app/src/main/res/menu/{item_detail_playlist.xml => detail_playlist.xml} (100%) rename app/src/main/res/menu/{item_parent.xml => parent.xml} (100%) rename app/src/main/res/menu/{item_playback_song.xml => playback_song.xml} (100%) rename app/src/main/res/menu/{item_playlist.xml => playlist.xml} (100%) rename app/src/main/res/menu/{item_playlist_song.xml => playlist_song.xml} (100%) create mode 100644 app/src/main/res/menu/selection.xml rename app/src/main/res/menu/{item_song.xml => song.xml} (100%) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 2d510ea36..549ac2aa4 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -298,7 +298,11 @@ class MainFragment : initialNavDestinationChange = true return } - listModel.dropSelection() + if (destination.id != R.id.selection_menu_dialog) { + // Drop any pending playlist edits when navigating away. This could actually happen + // if the user is quick enough. + listModel.dropSelection() + } } private fun handleShowOuter(outer: Outer?) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 7227d9aa6..3fd4a6963 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -101,7 +101,7 @@ class AlbumDetailFragment : setNavigationOnClickListener { findNavController().navigateUp() } overrideOnOverflowMenuClick { listModel.openMenu( - R.menu.item_detail_album, unlikelyToBeNull(detailModel.currentAlbum.value)) + R.menu.detail_album, unlikelyToBeNull(detailModel.currentAlbum.value)) } } @@ -145,7 +145,7 @@ class AlbumDetailFragment : } override fun onOpenMenu(item: Song) { - listModel.openMenu(R.menu.item_album_song, item, detailModel.playInAlbumWith) + listModel.openMenu(R.menu.album_song, item, detailModel.playInAlbumWith) } override fun onPlay() { @@ -243,6 +243,7 @@ class AlbumDetailFragment : when (menu) { is Menu.ForSong -> AlbumDetailFragmentDirections.openSongMenu(menu.parcel) is Menu.ForAlbum -> AlbumDetailFragmentDirections.openAlbumMenu(menu.parcel) + is Menu.ForSelection -> AlbumDetailFragmentDirections.openSelectionMenu(menu.parcel) is Menu.ForArtist, is Menu.ForGenre, is Menu.ForPlaylist -> error("Unexpected menu $menu") diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index c209a1a05..a611af6f4 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -100,7 +100,7 @@ class ArtistDetailFragment : setOnMenuItemClickListener(this@ArtistDetailFragment) overrideOnOverflowMenuClick { listModel.openMenu( - R.menu.item_detail_parent, unlikelyToBeNull(detailModel.currentArtist.value)) + R.menu.detail_parent, unlikelyToBeNull(detailModel.currentArtist.value)) } } @@ -152,9 +152,8 @@ class ArtistDetailFragment : override fun onOpenMenu(item: Music) { when (item) { - is Song -> - listModel.openMenu(R.menu.item_artist_song, item, detailModel.playInArtistWith) - is Album -> listModel.openMenu(R.menu.item_artist_album, item) + is Song -> listModel.openMenu(R.menu.artist_song, item, detailModel.playInArtistWith) + is Album -> listModel.openMenu(R.menu.artist_album, item) else -> error("Unexpected datatype: ${item::class.simpleName}") } } @@ -239,6 +238,8 @@ class ArtistDetailFragment : is Menu.ForSong -> ArtistDetailFragmentDirections.openSongMenu(menu.parcel) is Menu.ForAlbum -> ArtistDetailFragmentDirections.openAlbumMenu(menu.parcel) is Menu.ForArtist -> ArtistDetailFragmentDirections.openArtistMenu(menu.parcel) + is Menu.ForSelection -> + ArtistDetailFragmentDirections.openSelectionMenu(menu.parcel) is Menu.ForGenre, is Menu.ForPlaylist -> error("Unexpected menu $menu") } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 8b9cf5a68..522ebbfa6 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -98,7 +98,7 @@ class GenreDetailFragment : setOnMenuItemClickListener(this@GenreDetailFragment) overrideOnOverflowMenuClick { listModel.openMenu( - R.menu.item_detail_parent, unlikelyToBeNull(detailModel.currentGenre.value)) + R.menu.detail_parent, unlikelyToBeNull(detailModel.currentGenre.value)) } } @@ -150,8 +150,8 @@ class GenreDetailFragment : override fun onOpenMenu(item: Music) { when (item) { - is Artist -> listModel.openMenu(R.menu.item_parent, item) - is Song -> listModel.openMenu(R.menu.item_song, item, detailModel.playInGenreWith) + is Artist -> listModel.openMenu(R.menu.parent, item) + is Song -> listModel.openMenu(R.menu.song, item, detailModel.playInGenreWith) else -> error("Unexpected datatype: ${item::class.simpleName}") } } @@ -240,6 +240,7 @@ class GenreDetailFragment : is Menu.ForSong -> GenreDetailFragmentDirections.openSongMenu(menu.parcel) is Menu.ForArtist -> GenreDetailFragmentDirections.openArtistMenu(menu.parcel) is Menu.ForGenre -> GenreDetailFragmentDirections.openGenreMenu(menu.parcel) + is Menu.ForSelection -> GenreDetailFragmentDirections.openSelectionMenu(menu.parcel) is Menu.ForAlbum, is Menu.ForPlaylist -> error("Unexpected menu $menu") } diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 9a2c02777..103d1034f 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -104,8 +104,7 @@ class PlaylistDetailFragment : setOnMenuItemClickListener(this@PlaylistDetailFragment) overrideOnOverflowMenuClick { listModel.openMenu( - R.menu.item_detail_playlist, - unlikelyToBeNull(detailModel.currentPlaylist.value)) + R.menu.detail_playlist, unlikelyToBeNull(detailModel.currentPlaylist.value)) } } @@ -200,7 +199,7 @@ class PlaylistDetailFragment : } override fun onOpenMenu(item: Song) { - listModel.openMenu(R.menu.item_playlist_song, item, detailModel.playInPlaylistWith) + listModel.openMenu(R.menu.playlist_song, item, detailModel.playInPlaylistWith) } override fun onPlay() { @@ -302,6 +301,8 @@ class PlaylistDetailFragment : is Menu.ForSong -> PlaylistDetailFragmentDirections.openSongMenu(menu.parcel) is Menu.ForPlaylist -> PlaylistDetailFragmentDirections.openPlaylistMenu(menu.parcel) + is Menu.ForSelection -> + PlaylistDetailFragmentDirections.openSelectionMenu(menu.parcel) is Menu.ForArtist, is Menu.ForAlbum, is Menu.ForGenre -> error("Unexpected menu $menu") diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 66de36045..7228e10ec 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -501,6 +501,7 @@ class HomeFragment : is Menu.ForArtist -> HomeFragmentDirections.openArtistMenu(menu.parcel) is Menu.ForGenre -> HomeFragmentDirections.openGenreMenu(menu.parcel) is Menu.ForPlaylist -> HomeFragmentDirections.openPlaylistMenu(menu.parcel) + is Menu.ForSelection -> HomeFragmentDirections.openSelectionMenu(menu.parcel) } findNavController().navigateSafe(directions) } diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index a7c63a455..74c942dae 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -140,7 +140,7 @@ class AlbumListFragment : } override fun onOpenMenu(item: Album) { - listModel.openMenu(R.menu.item_album, item) + listModel.openMenu(R.menu.album, item) } private fun updateAlbums(albums: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index 84834cb74..7dc885308 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -116,7 +116,7 @@ class ArtistListFragment : } override fun onOpenMenu(item: Artist) { - listModel.openMenu(R.menu.item_parent, item) + listModel.openMenu(R.menu.parent, item) } private fun updateArtists(artists: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index a39c0ee2d..3307fa721 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -115,7 +115,7 @@ class GenreListFragment : } override fun onOpenMenu(item: Genre) { - listModel.openMenu(R.menu.item_parent, item) + listModel.openMenu(R.menu.parent, item) } private fun updateGenres(genres: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt index d8a7ac175..4228c872a 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/PlaylistListFragment.kt @@ -113,7 +113,7 @@ class PlaylistListFragment : } override fun onOpenMenu(item: Playlist) { - listModel.openMenu(R.menu.item_playlist, item) + listModel.openMenu(R.menu.playlist, item) } private fun updatePlaylists(playlists: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index fb214b76b..04f9847f1 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -139,7 +139,7 @@ class SongListFragment : } override fun onOpenMenu(item: Song) { - listModel.openMenu(R.menu.item_song, item, homeModel.playWith) + listModel.openMenu(R.menu.song, item, homeModel.playWith) } private fun updateSongs(songs: List) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt index ed6536fe2..e1f4380a5 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt @@ -201,6 +201,18 @@ constructor(private val listSettings: ListSettings, private val musicRepository: openImpl(Menu.ForPlaylist(menuRes, playlist)) } + /** + * Open a menu for a [Song] selection. This is not a popup menu, instead actually a dialog of + * menu options with additional information. + * + * @param menuRes The resource of the menu to use. + * @param songs The [Song] selection to show. + */ + fun openMenu(@MenuRes menuRes: Int, songs: List) { + logD("Opening menu for ${songs.size} songs") + openImpl(Menu.ForSelection(menuRes, songs)) + } + private fun openImpl(menu: Menu) { val existing = _menu.flow.value if (existing != null) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt index fd461d222..a0a73793f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt @@ -26,7 +26,7 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.share +import org.oxycblt.auxio.util.overrideOnOverflowMenuClick import org.oxycblt.auxio.util.showToast /** @@ -48,6 +48,9 @@ abstract class SelectionFragment : // Add cancel and menu item listeners to manage what occurs with the selection. setNavigationOnClickListener { listModel.dropSelection() } setOnMenuItemClickListener(this@SelectionFragment) + overrideOnOverflowMenuClick { + listModel.openMenu(R.menu.selection, listModel.takeSelection()) + } } } @@ -67,23 +70,6 @@ abstract class SelectionFragment : musicModel.addToPlaylist(listModel.takeSelection()) true } - R.id.action_selection_queue_add -> { - playbackModel.addToQueue(listModel.takeSelection()) - requireContext().showToast(R.string.lng_queue_added) - true - } - R.id.action_selection_play -> { - playbackModel.play(listModel.takeSelection()) - true - } - R.id.action_selection_shuffle -> { - playbackModel.shuffle(listModel.takeSelection()) - true - } - R.id.action_selection_share -> { - requireContext().share(listModel.takeSelection()) - true - } else -> false } diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt index fc388cd36..24581b5d2 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/Menu.kt @@ -99,4 +99,11 @@ sealed interface Menu { @Parcelize data class Parcel(val res: Int, val playlistUid: Music.UID) : Menu.Parcel } + + class ForSelection(@MenuRes override val res: Int, val songs: List) : Menu { + override val parcel: Parcel + get() = Parcel(res, songs.map { it.uid }) + + @Parcelize data class Parcel(val res: Int, val songUids: List) : Menu.Parcel + } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index 9abf34133..bb971728a 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -34,6 +34,7 @@ import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.util.getPlural import org.oxycblt.auxio.util.share import org.oxycblt.auxio.util.showToast @@ -321,3 +322,50 @@ class PlaylistMenuDialogFragment : MenuDialogFragment() { } } } + +/** + * [MenuDialogFragment] implementation for a [Song] selection. + * + * @author Alexander Capehart (OxygenCobalt) + */ +@AndroidEntryPoint +class SelectionMenuDialogFragment : MenuDialogFragment() { + override val menuModel: MenuViewModel by activityViewModels() + override val listModel: ListViewModel by activityViewModels() + private val musicModel: MusicViewModel by activityViewModels() + private val playbackModel: PlaybackViewModel by activityViewModels() + private val args: SelectionMenuDialogFragmentArgs by navArgs() + + override val parcel + get() = args.parcel + + // Nothing to disable in song menus. + override fun getDisabledItemIds(menu: Menu.ForSelection) = setOf() + + override fun updateMenu(binding: DialogMenuBinding, menu: Menu.ForSelection) { + binding.menuCover.bind( + menu.songs, getString(R.string.desc_selection_image), R.drawable.ic_song_24) + binding.menuType.text = getString(R.string.lbl_selection) + binding.menuName.text = + requireContext().getPlural(R.plurals.fmt_song_count, menu.songs.size) + binding.menuInfo.text = menu.songs.sumOf { it.durationMs }.formatDurationMs(true) + } + + override fun onClick(item: MenuItem, menu: Menu.ForSelection) { + when (item.itemId) { + R.id.action_play -> playbackModel.play(menu.songs) + R.id.action_shuffle -> playbackModel.shuffle(menu.songs) + R.id.action_play_next -> { + playbackModel.playNext(menu.songs) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_queue_add -> { + playbackModel.addToQueue(menu.songs) + requireContext().showToast(R.string.lng_queue_added) + } + R.id.action_share -> requireContext().share(menu.songs) + R.id.action_playlist_add -> musicModel.addToPlaylist(menu.songs) + else -> error("Unexpected menu item selected $item") + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt index 0d5388854..18ff75ccc 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuViewModel.kt @@ -66,6 +66,7 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi is Menu.ForArtist.Parcel -> unpackArtistParcel(parcel) is Menu.ForGenre.Parcel -> unpackGenreParcel(parcel) is Menu.ForPlaylist.Parcel -> unpackPlaylistParcel(parcel) + is Menu.ForSelection.Parcel -> unpackSelectionParcel(parcel) } private fun unpackSongParcel(parcel: Menu.ForSong.Parcel): Menu.ForSong? { @@ -94,4 +95,10 @@ class MenuViewModel @Inject constructor(private val musicRepository: MusicReposi val playlist = musicRepository.userLibrary?.findPlaylist(parcel.playlistUid) ?: return null return Menu.ForPlaylist(parcel.res, playlist) } + + private fun unpackSelectionParcel(parcel: Menu.ForSelection.Parcel): Menu.ForSelection? { + val deviceLibrary = musicRepository.deviceLibrary ?: return null + val songs = parcel.songUids.mapNotNull(deviceLibrary::findSong) + return Menu.ForSelection(parcel.res, songs) + } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index bc842e28a..b43b3330e 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -96,7 +96,7 @@ class PlaybackPanelFragment : playbackModel.song.value?.let { // No playback options are actually available in the menu, so use a junk // PlaySong option. - listModel.openMenu(R.menu.item_playback_song, it, PlaySong.ByItself) + listModel.openMenu(R.menu.playback_song, it, PlaySong.ByItself) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 128a3c394..48a436730 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -184,11 +184,11 @@ class SearchFragment : ListFragment() { override fun onOpenMenu(item: Music) { when (item) { - is Song -> listModel.openMenu(R.menu.item_song, item, searchModel.playWith) - is Album -> listModel.openMenu(R.menu.item_album, item) - is Artist -> listModel.openMenu(R.menu.item_parent, item) - is Genre -> listModel.openMenu(R.menu.item_parent, item) - is Playlist -> listModel.openMenu(R.menu.item_playlist, item) + is Song -> listModel.openMenu(R.menu.song, item, searchModel.playWith) + is Album -> listModel.openMenu(R.menu.album, item) + is Artist -> listModel.openMenu(R.menu.parent, item) + is Genre -> listModel.openMenu(R.menu.parent, item) + is Playlist -> listModel.openMenu(R.menu.playlist, item) } } @@ -261,6 +261,7 @@ class SearchFragment : ListFragment() { is Menu.ForArtist -> SearchFragmentDirections.openArtistMenu(menu.parcel) is Menu.ForGenre -> SearchFragmentDirections.openGenreMenu(menu.parcel) is Menu.ForPlaylist -> SearchFragmentDirections.openPlaylistMenu(menu.parcel) + is Menu.ForSelection -> SearchFragmentDirections.openSelectionMenu(menu.parcel) } findNavController().navigateSafe(directions) // Keyboard is no longer needed. diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index e3f50ccec..e58a34c93 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -28,6 +28,7 @@ import androidx.annotation.RequiresApi import androidx.appcompat.view.menu.ActionMenuItemView import androidx.appcompat.widget.ActionMenuView import androidx.appcompat.widget.AppCompatButton +import androidx.appcompat.widget.Toolbar import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ShareCompat import androidx.core.graphics.Insets @@ -111,7 +112,7 @@ val ViewBinding.context: Context * Override the behavior of a [MaterialToolbar]'s overflow menu to do something else. This is * extremely dumb, but required to hook overflow menus to bottom sheet menus. */ -fun MaterialToolbar.overrideOnOverflowMenuClick(block: (View) -> Unit) { +fun Toolbar.overrideOnOverflowMenuClick(block: (View) -> Unit) { for (toolbarChild in children) { if (toolbarChild is ActionMenuView) { for (menuChild in toolbarChild.children) { diff --git a/app/src/main/res/menu/item_album.xml b/app/src/main/res/menu/album.xml similarity index 100% rename from app/src/main/res/menu/item_album.xml rename to app/src/main/res/menu/album.xml diff --git a/app/src/main/res/menu/item_album_song.xml b/app/src/main/res/menu/album_song.xml similarity index 100% rename from app/src/main/res/menu/item_album_song.xml rename to app/src/main/res/menu/album_song.xml diff --git a/app/src/main/res/menu/item_artist_album.xml b/app/src/main/res/menu/artist_album.xml similarity index 100% rename from app/src/main/res/menu/item_artist_album.xml rename to app/src/main/res/menu/artist_album.xml diff --git a/app/src/main/res/menu/item_artist_song.xml b/app/src/main/res/menu/artist_song.xml similarity index 100% rename from app/src/main/res/menu/item_artist_song.xml rename to app/src/main/res/menu/artist_song.xml diff --git a/app/src/main/res/menu/item_detail_album.xml b/app/src/main/res/menu/detail_album.xml similarity index 100% rename from app/src/main/res/menu/item_detail_album.xml rename to app/src/main/res/menu/detail_album.xml diff --git a/app/src/main/res/menu/item_detail_parent.xml b/app/src/main/res/menu/detail_parent.xml similarity index 100% rename from app/src/main/res/menu/item_detail_parent.xml rename to app/src/main/res/menu/detail_parent.xml diff --git a/app/src/main/res/menu/item_detail_playlist.xml b/app/src/main/res/menu/detail_playlist.xml similarity index 100% rename from app/src/main/res/menu/item_detail_playlist.xml rename to app/src/main/res/menu/detail_playlist.xml diff --git a/app/src/main/res/menu/item_parent.xml b/app/src/main/res/menu/parent.xml similarity index 100% rename from app/src/main/res/menu/item_parent.xml rename to app/src/main/res/menu/parent.xml diff --git a/app/src/main/res/menu/item_playback_song.xml b/app/src/main/res/menu/playback_song.xml similarity index 100% rename from app/src/main/res/menu/item_playback_song.xml rename to app/src/main/res/menu/playback_song.xml diff --git a/app/src/main/res/menu/item_playlist.xml b/app/src/main/res/menu/playlist.xml similarity index 100% rename from app/src/main/res/menu/item_playlist.xml rename to app/src/main/res/menu/playlist.xml diff --git a/app/src/main/res/menu/item_playlist_song.xml b/app/src/main/res/menu/playlist_song.xml similarity index 100% rename from app/src/main/res/menu/item_playlist_song.xml rename to app/src/main/res/menu/playlist_song.xml diff --git a/app/src/main/res/menu/selection.xml b/app/src/main/res/menu/selection.xml new file mode 100644 index 000000000..1d4f3d94d --- /dev/null +++ b/app/src/main/res/menu/selection.xml @@ -0,0 +1,30 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/menu/item_song.xml b/app/src/main/res/menu/song.xml similarity index 100% rename from app/src/main/res/menu/item_song.xml rename to app/src/main/res/menu/song.xml diff --git a/app/src/main/res/menu/toolbar_selection.xml b/app/src/main/res/menu/toolbar_selection.xml index 9dfda8a30..e1cf43ef0 100644 --- a/app/src/main/res/menu/toolbar_selection.xml +++ b/app/src/main/res/menu/toolbar_selection.xml @@ -12,19 +12,7 @@ android:icon="@drawable/ic_playlist_add_24" app:showAsAction="ifRoom"/> - - - \ No newline at end of file diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index 979b09b2d..ef19e5474 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -57,6 +57,9 @@ + @@ -152,6 +155,9 @@ + @@ -204,6 +210,9 @@ + @@ -250,6 +259,9 @@ + @@ -296,6 +308,9 @@ + @@ -339,6 +354,9 @@ + @@ -481,4 +499,14 @@ android:name="parcel" app:argType="org.oxycblt.auxio.list.menu.Menu$ForPlaylist$Parcel" /> + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f2bedcee8..fd9f2c2e7 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -122,7 +122,6 @@ Add to playlist - Go to artist Go to album View properties @@ -167,6 +166,8 @@ Licenses Library statistics + Selection + @@ -335,6 +336,7 @@ Artist image for %s Genre image for %s Playlist image for %s + Selection image From c42a3ca97c29f1700ebdd363dd9e3c8b62384aa4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 3 Aug 2023 13:14:12 -0600 Subject: [PATCH 02/25] ui: refine navigation listeners Make sure that we don't drop selections or playlist edits when we navigate to dialogs, this time achieved through a more general navigation listener implementation than prior. --- .../java/org/oxycblt/auxio/MainFragment.kt | 38 +++------ .../auxio/detail/PlaylistDetailFragment.kt | 52 ++++++------ .../org/oxycblt/auxio/list/ListViewModel.kt | 28 ++++--- .../oxycblt/auxio/list/SelectionFragment.kt | 2 +- .../auxio/list/menu/MenuDialogFragmentImpl.kt | 5 +- .../auxio/ui/DialogAwareNavigationListener.kt | 79 +++++++++++++++++++ app/src/main/res/navigation/inner.xml | 28 +++---- app/src/main/res/navigation/outer.xml | 12 +-- 8 files changed, 154 insertions(+), 90 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 549ac2aa4..c86037eb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -27,8 +27,6 @@ import androidx.core.view.ViewCompat import androidx.core.view.isInvisible import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels -import androidx.navigation.NavController -import androidx.navigation.NavDestination import androidx.navigation.findNavController import androidx.navigation.fragment.findNavController import com.google.android.material.R as MR @@ -49,6 +47,7 @@ import org.oxycblt.auxio.playback.OpenPanel import org.oxycblt.auxio.playback.PlaybackBottomSheetBehavior import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior +import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.context @@ -67,9 +66,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull */ @AndroidEntryPoint class MainFragment : - ViewBindingFragment(), - ViewTreeObserver.OnPreDrawListener, - NavController.OnDestinationChangedListener { + ViewBindingFragment(), ViewTreeObserver.OnPreDrawListener { private val detailModel: DetailViewModel by activityViewModels() private val homeModel: HomeViewModel by activityViewModels() private val listModel: ListViewModel by activityViewModels() @@ -77,9 +74,9 @@ class MainFragment : private var sheetBackCallback: SheetBackPressedCallback? = null private var detailBackCallback: DetailBackPressedCallback? = null private var selectionBackCallback: SelectionBackPressedCallback? = null + private var selectionNavigationListener: DialogAwareNavigationListener? = null private var lastInsets: WindowInsets? = null private var elevationNormal = 0f - private var initialNavDestinationChange = true override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -111,6 +108,8 @@ class MainFragment : val selectionBackCallback = SelectionBackPressedCallback(listModel).also { selectionBackCallback = it } + selectionNavigationListener = DialogAwareNavigationListener(listModel::dropSelection) + // --- UI SETUP --- val context = requireActivity() @@ -162,8 +161,8 @@ class MainFragment : val binding = requireBinding() // Once we add the destination change callback, we will receive another initialization call, // so handle that by resetting the flag. - initialNavDestinationChange = false - binding.exploreNavHost.findNavController().addOnDestinationChangedListener(this) + requireNotNull(selectionNavigationListener) { "NavigationListener was not available" } + .attach(binding.exploreNavHost.findNavController()) // Listener could still reasonably fire even if we clear the binding, attach/detach // our pre-draw listener our listener in onStart/onStop respectively. binding.playbackSheet.viewTreeObserver.addOnPreDrawListener(this@MainFragment) @@ -184,7 +183,8 @@ class MainFragment : override fun onStop() { super.onStop() val binding = requireBinding() - binding.exploreNavHost.findNavController().removeOnDestinationChangedListener(this) + requireNotNull(selectionNavigationListener) { "NavigationListener was not available" } + .release(binding.exploreNavHost.findNavController()) binding.playbackSheet.viewTreeObserver.removeOnPreDrawListener(this) } @@ -193,6 +193,7 @@ class MainFragment : sheetBackCallback = null detailBackCallback = null selectionBackCallback = null + selectionNavigationListener = null } override fun onPreDraw(): Boolean { @@ -286,25 +287,6 @@ class MainFragment : return true } - override fun onDestinationChanged( - controller: NavController, - destination: NavDestination, - arguments: Bundle? - ) { - // Drop the initial call by NavController that simply provides us with the current - // destination. This would cause the selection state to be lost every time the device - // rotates. - if (!initialNavDestinationChange) { - initialNavDestinationChange = true - return - } - if (destination.id != R.id.selection_menu_dialog) { - // Drop any pending playlist edits when navigating away. This could actually happen - // if the user is quick enough. - listModel.dropSelection() - } - } - private fun handleShowOuter(outer: Outer?) { val directions = when (outer) { diff --git a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt index 103d1034f..ed460bc33 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/PlaylistDetailFragment.kt @@ -20,9 +20,8 @@ package org.oxycblt.auxio.detail import android.os.Bundle import android.view.LayoutInflater +import android.view.MenuItem import androidx.fragment.app.activityViewModels -import androidx.navigation.NavController -import androidx.navigation.NavDestination import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs import androidx.recyclerview.widget.ConcatAdapter @@ -51,6 +50,7 @@ import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.playback.PlaybackDecision import org.oxycblt.auxio.playback.PlaybackViewModel +import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD @@ -68,8 +68,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull class PlaylistDetailFragment : ListFragment(), DetailHeaderAdapter.Listener, - PlaylistDetailListAdapter.Listener, - NavController.OnDestinationChangedListener { + PlaylistDetailListAdapter.Listener { private val detailModel: DetailViewModel by activityViewModels() override val listModel: ListViewModel by activityViewModels() override val musicModel: MusicViewModel by activityViewModels() @@ -80,7 +79,7 @@ class PlaylistDetailFragment : private val playlistHeaderAdapter = PlaylistDetailHeaderAdapter(this) private val playlistListAdapter = PlaylistDetailListAdapter(this) private var touchHelper: ItemTouchHelper? = null - private var initialNavDestinationChange = false + private var editNavigationListener: DialogAwareNavigationListener? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -98,6 +97,8 @@ class PlaylistDetailFragment : override fun onBindingCreated(binding: FragmentDetailBinding, savedInstanceState: Bundle?) { super.onBindingCreated(binding, savedInstanceState) + editNavigationListener = DialogAwareNavigationListener(detailModel::dropPlaylistEdit) + // --- UI SETUP --- binding.detailNormalToolbar.apply { setNavigationOnClickListener { findNavController().navigateUp() } @@ -147,17 +148,31 @@ class PlaylistDetailFragment : collect(playbackModel.playbackDecision.flow, ::handlePlaybackDecision) } + override fun onMenuItemClick(item: MenuItem): Boolean { + if (super.onMenuItemClick(item)) { + return true + } + + if (item.itemId == R.id.action_save) { + detailModel.savePlaylistEdit() + return true + } + + return false + } + override fun onStart() { super.onStart() // Once we add the destination change callback, we will receive another initialization call, // so handle that by resetting the flag. - initialNavDestinationChange = false - findNavController().addOnDestinationChangedListener(this) + requireNotNull(editNavigationListener) { "NavigationListener was not available" } + .attach(findNavController()) } override fun onStop() { super.onStop() - findNavController().removeOnDestinationChangedListener(this) + requireNotNull(editNavigationListener) { "NavigationListener was not available" } + .release(findNavController()) } override fun onDestroyBinding(binding: FragmentDetailBinding) { @@ -168,26 +183,7 @@ class PlaylistDetailFragment : // Avoid possible race conditions that could cause a bad replace instruction to be consumed // during list initialization and crash the app. Could happen if the user is fast enough. detailModel.playlistSongInstructions.consume() - } - - override fun onDestinationChanged( - controller: NavController, - destination: NavDestination, - arguments: Bundle? - ) { - // Drop the initial call by NavController that simply provides us with the current - // destination. This would cause the selection state to be lost every time the device - // rotates. - if (!initialNavDestinationChange) { - initialNavDestinationChange = true - return - } - if (destination.id != R.id.playlist_detail_fragment && - destination.id != R.id.playlist_song_sort_dialog) { - // Drop any pending playlist edits when navigating away. This could actually happen - // if the user is quick enough. - detailModel.dropPlaylistEdit() - } + editNavigationListener = null } override fun onRealClick(item: Song) { diff --git a/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt index e1f4380a5..e223f439b 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/ListViewModel.kt @@ -109,6 +109,22 @@ constructor(private val listSettings: ListSettings, private val musicRepository: _selected.value = selected } + /** + * Clear the current selection and return it. + * + * @return A list of [Song]s collated from each item selected. + */ + fun peekSelection() = + _selected.value.flatMap { + when (it) { + is Song -> listOf(it) + is Album -> listSettings.albumSongSort.songs(it.songs) + is Artist -> listSettings.artistSongSort.songs(it.songs) + is Genre -> listSettings.genreSongSort.songs(it.songs) + is Playlist -> it.songs + } + } + /** * Clear the current selection and return it. * @@ -116,17 +132,7 @@ constructor(private val listSettings: ListSettings, private val musicRepository: */ fun takeSelection(): List { logD("Taking selection") - return _selected.value - .flatMap { - when (it) { - is Song -> listOf(it) - is Album -> listSettings.albumSongSort.songs(it.songs) - is Artist -> listSettings.artistSongSort.songs(it.songs) - is Genre -> listSettings.genreSongSort.songs(it.songs) - is Playlist -> it.songs - } - } - .also { _selected.value = listOf() } + return peekSelection().also { _selected.value = listOf() } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt b/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt index a0a73793f..69b58ac5f 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/SelectionFragment.kt @@ -49,7 +49,7 @@ abstract class SelectionFragment : setNavigationOnClickListener { listModel.dropSelection() } setOnMenuItemClickListener(this@SelectionFragment) overrideOnOverflowMenuClick { - listModel.openMenu(R.menu.selection, listModel.takeSelection()) + listModel.openMenu(R.menu.selection, listModel.peekSelection()) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt index bb971728a..a7eef2392 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/menu/MenuDialogFragmentImpl.kt @@ -79,10 +79,10 @@ class SongMenuDialogFragment : MenuDialogFragment() { playbackModel.addToQueue(menu.song) requireContext().showToast(R.string.lng_queue_added) } + R.id.action_playlist_add -> musicModel.addToPlaylist(menu.song) R.id.action_artist_details -> detailModel.showArtist(menu.song) R.id.action_album_details -> detailModel.showAlbum(menu.song.album) R.id.action_share -> requireContext().share(menu.song) - R.id.action_playlist_add -> musicModel.addToPlaylist(menu.song) R.id.action_detail -> detailModel.showSong(menu.song) else -> error("Unexpected menu item selected $item") } @@ -352,6 +352,7 @@ class SelectionMenuDialogFragment : MenuDialogFragment() { } override fun onClick(item: MenuItem, menu: Menu.ForSelection) { + listModel.dropSelection() when (item.itemId) { R.id.action_play -> playbackModel.play(menu.songs) R.id.action_shuffle -> playbackModel.shuffle(menu.songs) @@ -363,8 +364,8 @@ class SelectionMenuDialogFragment : MenuDialogFragment() { playbackModel.addToQueue(menu.songs) requireContext().showToast(R.string.lng_queue_added) } - R.id.action_share -> requireContext().share(menu.songs) R.id.action_playlist_add -> musicModel.addToPlaylist(menu.songs) + R.id.action_share -> requireContext().share(menu.songs) else -> error("Unexpected menu item selected $item") } } diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt new file mode 100644 index 000000000..1c3e9f340 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2023 Auxio Project + * DialogAwareNavigationListener.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.ui + +import android.os.Bundle +import androidx.navigation.NavController +import androidx.navigation.NavDestination + +/** + * A [NavController.OnDestinationChangedListener] that will call [callback] when moving between + * fragments only (not between dialogs or anything similar) + * + * @author Alexander Capehart (OxygenCobalt) + */ +class DialogAwareNavigationListener(private val callback: () -> Unit) : + NavController.OnDestinationChangedListener { + private var currentDestination: NavDestination? = null + + /** + * Attach this instance to a [NavController]. This should be done in the onStart method of a + * Fragment. + * + * @param navController The [NavController] to add to. + */ + fun attach(navController: NavController) { + currentDestination = null + navController.addOnDestinationChangedListener(this) + } + + /** + * Remove this listener from it's [NavController]. This should be done in the onStop method of a + * Fragment. + * + * @param navController The [NavController] to remove from. Should be the same on used in + * [attach]. + */ + fun release(navController: NavController) { + currentDestination = null + navController.removeOnDestinationChangedListener(this) + } + + override fun onDestinationChanged( + controller: NavController, + destination: NavDestination, + arguments: Bundle? + ) { + // Drop the initial call by NavController that simply provides us with the current + // destination. This would cause the selection state to be lost every time the device + // rotates. + val lastDestination = currentDestination + currentDestination = destination + if (lastDestination == null) { + return + } + + if (!lastDestination.isDialog() && !destination.isDialog()) { + callback() + } + } + + /** This relies on special label naming used in-app. */ + private fun NavDestination.isDialog() = label?.endsWith("dialog") == true +} diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index ef19e5474..d665a90d3 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -7,7 +7,7 @@ + android:label="settings_fragment"> @@ -41,7 +41,7 @@ + android:label="ui_preferences_fragment"> @@ -50,7 +50,7 @@ + android:label="personalize_preferences_fragment"> @@ -59,7 +59,7 @@ + android:label="personalize_preferences_fragment"> @@ -68,7 +68,7 @@ + android:label="personalize_preferences_fragment"> From ed3ed6a7131fa2a7c6ef5b42a0f82a2f7eedf6be Mon Sep 17 00:00:00 2001 From: "Weblate (bot)" Date: Mon, 7 Aug 2023 21:43:20 +0200 Subject: [PATCH 03/25] Translations update from Hosted Weblate (#529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Translated using Weblate (Czech) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Spanish) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Korean) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Russian) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Lithuanian) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/lt/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Italian) Currently translated at 99.6% (284 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/it/ * Translated using Weblate (Croatian) Currently translated at 100.0% (36 of 36 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/hr/ * Translated using Weblate (Croatian) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ * Translated using Weblate (Hebrew) Currently translated at 97.2% (35 of 36 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/he/ * Translated using Weblate (Hebrew) Currently translated at 90.5% (258 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/he/ * Translated using Weblate (Polish) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pl/ * Translated using Weblate (German) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/de/ * Translated using Weblate (Hindi) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hi/ * Translated using Weblate (German) Currently translated at 100.0% (36 of 36 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/de/ * Translated using Weblate (Punjabi) Currently translated at 100.0% (285 of 285 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/pa/ * Translated using Weblate (Ukrainian) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/uk/ * Translated using Weblate (Croatian) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hr/ * Translated using Weblate (Spanish) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/es/ * Translated using Weblate (Korean) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ko/ * Translated using Weblate (Russian) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/ru/ * Translated using Weblate (Chinese (Simplified)) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/zh_Hans/ * Translated using Weblate (Korean) Currently translated at 100.0% (36 of 36 strings) Translation: Auxio/Metadata Translate-URL: https://hosted.weblate.org/projects/auxio/metadata/ko/ * Translated using Weblate (Belarusian) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/be/ * Translated using Weblate (Czech) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/cs/ * Translated using Weblate (Hungarian) Currently translated at 100.0% (287 of 287 strings) Translation: Auxio/Strings Translate-URL: https://hosted.weblate.org/projects/auxio/strings/hu/ --------- Co-authored-by: Fjuro Co-authored-by: gallegonovato Co-authored-by: Макар Разин Co-authored-by: Eric Co-authored-by: Vaclovas Intas Co-authored-by: atilluF <110931720+atilluF@users.noreply.github.com> Co-authored-by: Milo Ivir Co-authored-by: FAYE Co-authored-by: Eryk Michalak Co-authored-by: qwerty287 Co-authored-by: ShareASmile Co-authored-by: BMT[UA] Co-authored-by: Hoseok Seo Co-authored-by: Tibor Botfai (gidano) --- app/src/main/res/values-be/strings.xml | 4 + app/src/main/res/values-cs/strings.xml | 4 + app/src/main/res/values-de/strings.xml | 3 + app/src/main/res/values-es/strings.xml | 4 + app/src/main/res/values-hi/strings.xml | 2 + app/src/main/res/values-hr/strings.xml | 15 +++- app/src/main/res/values-hu/strings.xml | 4 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-iw/strings.xml | 77 +++++++++++-------- app/src/main/res/values-ko/strings.xml | 9 +++ app/src/main/res/values-lt/strings.xml | 2 + app/src/main/res/values-pa/strings.xml | 2 + app/src/main/res/values-pl/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 12 ++- app/src/main/res/values-uk/strings.xml | 4 + app/src/main/res/values-zh-rCN/strings.xml | 4 + .../metadata/android/de/full_description.txt | 4 +- .../metadata/android/he/full_description.txt | 25 ++++++ .../metadata/android/hr/full_description.txt | 4 +- .../metadata/android/ko/full_description.txt | 4 +- 20 files changed, 141 insertions(+), 45 deletions(-) create mode 100644 fastlane/metadata/android/he/full_description.txt diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 1b783cd27..196cd5357 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -298,4 +298,8 @@ Песня Прайграць песню самастойна Выгляд + Сартаваць па + Напрамак + Абярыце малюнак + Абярыце \ No newline at end of file diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index d53bff062..35c984fed 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -309,4 +309,8 @@ Skladba Zobrazit Přehrát skladbu samostatně + Směr + Seřadit podle + Výběr obrázku + Výběr \ No newline at end of file diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 09d153413..eec9aa65e 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -299,4 +299,7 @@ Alle Album-Cover auf ein Seitenverhältnis von 1:1 zuschneiden Lied Ansehen + Lied selbst spielen + Richtung + Sortieren nach \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index c2e376431..70b8b49fc 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -304,4 +304,8 @@ Canción Vista Reproducir la canción por tí mismo + Ordenar por + Dirección + Selección de imágenes + Selección \ No newline at end of file diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index b4014ff97..f4e65894c 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -299,4 +299,6 @@ बुद्धिमान छंटाई संख्याओं या \"the\" जैसे शब्दों से शुरू होने वाले नामों को सही ढंग से क्रमबद्ध करें (अंग्रेजी भाषा के संगीत के साथ सबसे अच्छा काम करता है) इसी गीत को चलाएं + दिशा + के अनुसार क्रमबद्ध करें \ No newline at end of file diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 1ce8b10cd..2cd6aac4f 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -25,7 +25,7 @@ Izvođač Izvođači Žanrovi - Sortiraj + Razvrstaj Naziv Godina Trajanje @@ -178,7 +178,7 @@ Sve Dodaj u popis pjesama Dodano u popis pjesama - Prikaži svojstva + Pogledaj svojstva Idi na izvođača Idi na album Ostavi miješanje omogućeno kada se druga pjesma reproducira @@ -212,7 +212,7 @@ Otvori popis pjesama Žanr Zarez (,) - Ampersand (&) + Znak i (&) Kompilacija uživo Kompilacija remiksa DJ kompilacije @@ -255,7 +255,7 @@ Resetiraj ReplayGain izjednačavanje glasnoće Mape - Silazni + Silazno Promijenite temu i boje aplikacije Prilagodite kontrole i ponašanje korisničkog sučelja Upravljajte učitavanjem glazbe i slika @@ -292,4 +292,11 @@ Nema diska Prisili kvadratične omote albuma Odreži sve omote albuma na omjer 1:1 + Pjesma + Pogledaj + Razvrstaj po + Reproduciraj pjesmu zasebno + Smjer + Slika odabira + Odabir \ No newline at end of file diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 6c0b11c88..179bb1a44 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -299,4 +299,8 @@ Dal Megnéz Dal lejátszása önmagában + Irány + Rendezés + Kiválasztás + Kép kiválasztás \ No newline at end of file diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 483d5b02f..9e565a356 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -304,4 +304,5 @@ Brano Visualizza Riproduci brano da solo + Ordina per \ No newline at end of file diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index 31b5677fd..ab1393f98 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -1,9 +1,9 @@ - מוזיקה בטעינה - מוזיקה בטעינה + מוזיקה נטענת + מוזיקה נטענת לנסות שוב - מתבצעת סריקה בספריית המוזיקה שלך + ספריית המוזיקה שלך נסרקת כל השירים אלבומים אלבום חי @@ -17,17 +17,17 @@ סינגל חי אוסף אוסף חי - אוספי רמיקסים + אוסף רמיקסים פסקולים פסקול מיקסטייפים - מיקס + מיקס DJ חי רמיקסים אומן אומנים - סוגה - סוגות + ז\'אנר + ז\'אנרים סינון הכל תאריך @@ -40,15 +40,15 @@ מושמע כעת איקוולייזר ניגון - ניגון הנבחרים + ניגון נבחרים ערבוב - ערבוב הנבחרים + ערבוב נבחרים ניגון הבא הוספה לתור מעבר לאלבום הצגת מאפיינים מאפייני שיר - תבנית + פורמט גודל קצב סיביות קצב דגימה @@ -62,11 +62,11 @@ גרסה קוד מקור ויקי - רישיונות + רשיונות סטטיסטיקות ספרייה צפייה ושליטה בהשמעת המוזיקה - טוען את ספריית המוזיקה שלך… - סורק את ספריית המוזיקה שלך כדי לאתר שינויים… + ספריית המוזיקה שלך נטענת… + ספריית המוזיקה שלך נסרקת לאיתור שינויים… התווסף לתור מפותח על ידי אלכסנדר קייפהארט חיפוש בספרייה שלך… @@ -80,7 +80,7 @@ שימוש בערכת נושא שחורה לגמרי מצב מעוגל התאמה אישית - התאמת רכיבים והתנהגות ממשק המשתמש + התאמת רכיבי והתנהגות הממשק תצוגה לשוניות ספרייה פעולת התראות מותאמת אישית @@ -93,19 +93,19 @@ ניגון מכל השירים ניגון מאלבום ניגון מהאומן - ניגון מסוגה - לזכור ערבוב + ניגון מז\'אנר + זכירת ערבוב המשך ערבוב בעת הפעלת שיר חדש תוכן טעינה מחדש אוטומטית - לטעון מחדש את הספרייה בכל פעם שהיא משתנה (דורש התראה קבועה) - התעלמות מקובצי שמע שאינם מוזיקה, כמו הסכתים + טעינת הספרייה מחדש בכל פעם שהיא משתנה (דורש התראה קבועה) + התעלמות מקבצי אודיו שאינם מוזיקה, כמו הסכתים מפרידים רבי-ערכים פסיק (,) נקודה-פסיק (;) פלוס (+) גם (&) - הסתרת שיתופי פעולה + הסתרת משתפי~ות פעולה הצגת אומנים שמצויינים ישירות בקרדיטים של אלבום בלבד (עובד באופן מיטבי על ספריות מתויגות היטב) עטיפות אלבום כבוי @@ -118,7 +118,7 @@ עצירה בעת חזרה ReplayGain העדפת אלבום - מגבר עוצמת נגינה מחדש + מגבר ReplayGain התאמה עם תגיות מיקסטייפ נגן מוזיקה פשוט והגיוני לאנדרואיד. @@ -136,24 +136,24 @@ שם קובץ ערבוב המצב שוחזר - על אודות + אודות הגדרות אוטומטי הפעלת פינות מעוגלות ברכיבי ממשק נוספים (עטיפות אלבומים נדרשות להיות מעוגלות) שינוי מראה וסדר לשוניות הספרייה פעולת סרגל השמעה מותאמת אישית - הגדרת טעינת המוזיקה והתמונות + הגדרת אופן טעינת מוזיקה ותמונות מוזיקה אי-הכללת תוכן שאינו מוזיקה התאמת תווים המציינים ערכי תגית מרובים קו נטוי (/) אזהרה: השימוש בהגדרה זו עלול לגרום לחלק מהתגיות להיות מפורשות באופן שגוי כבעלות מספר ערכים. ניתן לפתור זאת על ידי הכנסת קו נטוי אחורי (\\) לפני תווים מפרידים לא רצויים. איכות גבוהה - התעלמות ממילים כמו \"The\" (\"ה׳ היידוע\") בעת סידור על פי שם (עובד באופן מיטבי עם מוזיקה בשפה האנגלית) + התעלמות ממספרים או מילים כמו \"The\" (\"ה׳ היידוע\") בעת סידור על פי שם (עובד באופן מיטבי עם מוזיקה בשפה האנגלית) תמונות הגדרת הצליל והניגון תמיד להתחיל לנגן ברגע שמחוברות אזניות (עלול לא לעבוד בכל המערכות) - השהיה עם חזרה על שיר + השהייה עם חזרה על שיר העדפת רצועה אסטרטגיית ReplayGain העדפת אלבום אם אחד מופעל @@ -162,7 +162,7 @@ רשימת השמעה חדשה הוספה לרשימת השמעה לתת - רשימת השמעה + רשימת השמעה (פלייליסט) רשימות השמעה מחיקה שינוי שם @@ -172,8 +172,8 @@ לא ניתן לנקות את המצב כתום תיקיות מוזיקה - טעינה מחדש של ספריית המוזיקה, במידה וניתן יעשה שימוש במטמון תגיות - סריקה מחדש אחר מוזיקה + טעינה מחדש של ספריית המוזיקה, במידה וניתן ייעשה שימוש בתגיות מהמטמון + סריקת מוסיקה מחדש שמירת מצב הנגינה לא ניתן לשמור את המצב ‏ Auxio צריך הרשאות על מנת לקרוא את ספריית המוזיקה שלך @@ -185,7 +185,7 @@ אלבומים טעונים: %d סוגות טעונות: %d המצב נוקה - ספרייה + ספריה שמירת מצב הנגינה הנוכחי כעת לא נמצא יישום שיכול לטפל במשימה זו אין תיקיות @@ -209,7 +209,7 @@ דינמי המוזיקה שלך בטעינה (‎%1$d/%2$d)… דיסק %d - ניהול תיקיות המוזיקה לטעינה + ניהול המקומות שמהם תיטען מוזיקה אין שירים ורוד נוצרה רשימת השמעה @@ -235,7 +235,7 @@ שני אלבומים %d אלבומים - שונה שם לרשימת השמעה + שונה שם רשימת ההשמעה רשימת השמעה נמחקה נוסף לרשימת השמעה ערבוב כל השירים @@ -244,7 +244,7 @@ תמונת רשימת השמעה עבור %s אדום ירוק - ניתוב הורה + נתיב הורה לא ניתן לשחזר את המצב רצועה %d יצירת רשימת השמעה חדשה @@ -260,4 +260,19 @@ ירוק עמוק צהוב מחיקת %s\? פעולה זו לא ניתן לביטול. + שיר + מיון חכם + הצגה + הכרחת עטיפות אלבום מרובעות + ריקון מטמון התגיות וטעינת ספריית המוזיקה מחדש במלואה (איטי יותר, אך יותר שלם) + ניקוי מצב הנגינה הקודם שנשמר (אם קיים) + מיון על פי + כיוון + חיתוך כל עטיפות האלבומים ליחס של 1:1 + מוזיקה לא תיטען מהתיקיות שנוספו. + מוזיקה תיטען רק מהתיקיות שנוספו. + מופיע~ה ב- + ניגון השיר בלבד + אזהרה: שינוי המגבר לערך חיובי גבוה עלול לגרום לשיאים בחלק מרצועות האודיו + שחזור מצב נגינה \ No newline at end of file diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 6716d4d77..7d54c121c 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -295,4 +295,13 @@ 디스크 없음 재생목록이 삭제되었습니다 %s 수정 중 + 노래 + 보다 + 포스 스퀘어 앨범 커버 + 모든 앨범 표지를 1:1 가로세로 비율로 자르기 + 노래 따로 재생 + 방향 + 정렬 기준 + 선택 이미지 + 선택 \ No newline at end of file diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index caa995e31..e225f4250 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -297,4 +297,6 @@ Apkarpyti visus albumų viršelius iki 1:1 kraštinių koeficiento Priversti kvadratinių albumų viršelius Groti dainą pačią + Rūšiuoti pagal + Kryptis \ No newline at end of file diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 0c83a6bfa..8e43996db 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -292,4 +292,6 @@ ਗੀਤ ਵੇਖੋ ਇਸੇ ਗੀਤ ਨੂੰ ਚਲਾਓ + ਸੌਰਟ ਕਰੋ + ਦਿਸ਼ਾ \ No newline at end of file diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index a472dc577..e813bd244 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -305,4 +305,6 @@ Piosenka Odtwarzanie utworu samodzielnie Widok + Sortuj według + Kierunek \ No newline at end of file diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 6f311d10a..03703707b 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -148,15 +148,15 @@ ОК При воспроизведении из сведений Воспроизведение с показанного элемента - Номер песни + Номер трека Битрейт Диск Трек Позиция восстановлена Отмена Внимание: Изменение предусиления на большое положительное значение может привести к появлению искажений на некоторых звуковых дорожках. - Свойства - Свойства песни + Сведения + Свойства трека Путь Формат Размер @@ -304,7 +304,11 @@ Поделиться Использовать квадратные обложки альбомов Обрезать все обложки альбомов до соотношения сторон 1:1 - Песня + Трек Вид Воспроизвести трек отдельно + Сортировать по + Направление + Выберите + Выберите изображение \ No newline at end of file diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index a8c4959d6..fa3310d1c 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -304,4 +304,8 @@ Пісня Переглянути Відтворити пісню окремо + Сортувати за + Напрямок + Вибрати + Вибрати зображення \ No newline at end of file diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 1b91a7870..5edb4fc5e 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -298,4 +298,8 @@ 歌曲 查看 自行播放歌曲 + 排序依据 + 说明 + 选择 + 选择图片 \ No newline at end of file diff --git a/fastlane/metadata/android/de/full_description.txt b/fastlane/metadata/android/de/full_description.txt index a9b2d6711..00b49f827 100644 --- a/fastlane/metadata/android/de/full_description.txt +++ b/fastlane/metadata/android/de/full_description.txt @@ -1,8 +1,8 @@ -Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf Exoplayer und besitzt daher eine erstklassige Musikbibliothek-Unterstützung sowie Wiedergabequalität verglichen mit anderen Playern, die veraltete Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik. +Auxio ist ein lokaler Musik-Player mit einer schnellen, verlässlichen UI/UX, aber ohne die vielen unnötigen Funktionen, die andere Player haben. Auxio basiert auf modernen Wiedergabebibliotheken und besitzt daher eine erstklassige Musikbibliothek-Unterstützung sowie Wiedergabequalität verglichen mit anderen Playern, die veraltete Android-Funktionen nutzen. Kurz gesagt, Auxio spielt Musik. Funktionen -- auf ExoPlayer basierende Wiedergabe +- auf Media3 ExoPlayer basierende Wiedergabe - elegante, am Material Design orientierte UI - Überzeugende UX, die eine einfache Bedienung über Grenzfälle stellt - Anpassbares Verhalten diff --git a/fastlane/metadata/android/he/full_description.txt b/fastlane/metadata/android/he/full_description.txt new file mode 100644 index 000000000..588fec696 --- /dev/null +++ b/fastlane/metadata/android/he/full_description.txt @@ -0,0 +1,25 @@ +אוקסיו הוא נגן מוזיקה מקומי עם ממשק וחוויית משתמש מהיר ואמין, ובלי כל הפיצ'רים חסרי התועלת הרבים שנמצאים בנגני מוזיקה אחרים. בנוי על ספריות ניגון מדיה מודרניות, לאוקסיו יש תמיכה מיטבית בספריות ואיכות שמע נעלית בהשוואה ליישומים אחרים שמשתמשים בפונקציונליות אנדרואיד מיושנת. בקיצור, הוא פשוט מנגן מוזיקה. + +פיצ'רים + +- ניגון מבוסס על Media3 ExoPlayer +- ממשק משתמש מהיר שנגזר מהנחיות Material Design האחרונות ביותר +- חוויית משתמש שמתעדפת נוחות שימוש על פני מקרי קיצון +- התנהגות מותאמת אישית +- תמיכה במספרי דיסק, אומנים מרובים, סוגי שחרור, +תאריכים מדוייקים/מקוריים, תגיות מיון, ועוד +- מערכת אומנים מתקדמת שמאחדת אומנים ואומני אלבום + +- ניהול תיקיות מודע לכרטיסי SD +- פונקציונליות פלייליסטים אמינה +- התמדה במצב ההשמעה +- תמיכה מלאה ב-ReplayGain (בקבצי MP3, FLAC, OGG, OPUS, ו-MP4) +- תמיכה באיקוולייזר חיצוני (למשל, Wavelet) +- קצה לקצה +- תמיכה בעטיפות מוטבעות +- פונקציונליות חיפוש +- ניגון אוטומטי באוזניות +- ווידג'טים אלגנטיים שמתאימים את עצמם לגודלם אוטומטית +- פרטי לגמרי ולא מקוון + +- ללא עטיפות אלבום מעוגלות (אלא אם את.ה מעוניינ.ת בהם. אחרת אפשר.) diff --git a/fastlane/metadata/android/hr/full_description.txt b/fastlane/metadata/android/hr/full_description.txt index 3973c6e3a..f5f8d2d17 100644 --- a/fastlane/metadata/android/hr/full_description.txt +++ b/fastlane/metadata/android/hr/full_description.txt @@ -1,8 +1,8 @@ -Auxio je lokalni glazbeni player s brzim, pouzdanim UI/UX bez mnogih beskorisnih značajki prisutnih u drugim glazbenim playerima. Izgrađen na temelju ExoPlayera, Auxio ima vrhunsku podršku za biblioteku i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, Reproducira glazbu. +Auxio je lokalni glazbeni player s brzim, pouzdanim UI/UX bez mnogih beskorisnih značajki prisutnih u drugim glazbenim playerima. Izgrađen na osnovi modernih biblioteka za reprodukciju, Auxio ima vrhunsku podršku za biblioteku i kvalitetu slušanja u usporedbi s drugim aplikacijama koje koriste zastarjelu funkcionalnost Androida. Ukratko, Reproducira glazbu. Značajke -- Reprodukcija temeljena na ExoPlayeru +- Reprodukcija temeljena na Media3 ExoPlayeru - Snappy UI izvedeno iz najnovijih smjernica za materijalni dizajn - Iskustveni korisnički doživljaj koji daje prednost jednostavnosti upotrebe u odnosu na rubne slučajeve - Prilagodljivo ponašanje diff --git a/fastlane/metadata/android/ko/full_description.txt b/fastlane/metadata/android/ko/full_description.txt index 0a1497368..2d5d002c0 100644 --- a/fastlane/metadata/android/ko/full_description.txt +++ b/fastlane/metadata/android/ko/full_description.txt @@ -1,8 +1,8 @@ -Auxio는 다른 음악 플레이어에 존재하는 많은 쓸모없는 기능 없이 빠르고 안정적인 UI/UX를 갖춘 로컬 음악 플레이어입니다. Exoplayer를 기반으로 구축된 Auxio는 기본 미디어플레이어 API를 사용하는 다른 앱에 비해 훨씬 더 나은 청취 경험을 제공합니다. 즉, 제대로 된 음악을 재생합니다. +Auxio는 다른 음악 플레이어에 있는 쓸모없는 많은 기능 없이 빠르고 안정적인 UI/UX를 갖춘 로컬 음악 플레이어입니다. 최신 미디어 재생 라이브러리를 기반으로 구축된 Auxio는 오래된 안드로이드 기능을 사용하는 다른 앱에 비해 뛰어난 라이브러리 지원과 청취 품질을 제공합니다. 즉, 제대로 된 음악을 재생합니다. 기능 -- ExoPlayer 기반 재생 +- Media3 ExoPlayer 기반 재생 - 최신주목할 만한 디자인 가이드라인에서 파생된 Snappy UI - 엣지 케이스보다 사용 편의성을 우선시하는 의견이 많은 UX - 사용자 정의 가능한 동작 From 4ade27e66de4deef5258f550f3ca5342b304c2aa Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 12 Aug 2023 11:41:26 -0600 Subject: [PATCH 04/25] ui: avoid lopsided bottom sheet handles By default, bottom sheet handles have 16dp bottom padding for some reason. This looked really bad on the dialogs, so remove it there. Only keep it on the queue handle, since it has a companion label that will overlap if not lopsided. --- app/src/main/res/layout/fragment_main.xml | 1 + app/src/main/res/values/styles_core.xml | 3 +-- app/src/main/res/values/styles_ui.xml | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/layout/fragment_main.xml b/app/src/main/res/layout/fragment_main.xml index 58259f50b..98328654d 100644 --- a/app/src/main/res/layout/fragment_main.xml +++ b/app/src/main/res/layout/fragment_main.xml @@ -56,6 +56,7 @@ android:id="@+id/queue_handle" android:layout_width="match_parent" android:layout_height="wrap_content" + android:paddingBottom="@dimen/spacing_medium" app:layout_constraintTop_toTopOf="parent" /> @style/Widget.Auxio.LinearProgressIndicator @style/Widget.Auxio.BottomSheet @style/Widget.Auxio.BottomSheet.Dialog + @style/Widget.Auxio.BottomSheet.Handle @style/TextAppearance.Auxio.DisplayLarge @style/TextAppearance.Auxio.DisplayMedium @@ -51,8 +52,6 @@ none false true - - @color/sel_compat_ripple ?attr/colorOnSurfaceVariant ?attr/colorPrimary diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 98228e1aa..5106e12b9 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -59,6 +59,10 @@ @anim/bottom_sheet_slide_out + + From f5c7f25cdf5b235e1899d959f28a950fddd1ce02 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Aug 2023 17:42:05 -0600 Subject: [PATCH 06/25] home: add music loading error dialog Add a dialog that shows the stack trace of a music loading error. This is an MVP that is only available to music loading to resolve some immediate issues. Resolves #527. --- .../oxycblt/auxio/home/ErrorDetailsDialog.kt | 89 +++++++++++++++++++ .../org/oxycblt/auxio/home/HomeFragment.kt | 11 ++- .../java/org/oxycblt/auxio/music/Indexing.kt | 2 +- .../oxycblt/auxio/music/MusicRepository.kt | 1 + .../oxycblt/auxio/settings/AboutFragment.kt | 84 ++--------------- .../org/oxycblt/auxio/util/FrameworkUtil.kt | 67 ++++++++++++++ app/src/main/res/drawable/ic_copy_24.xml | 11 +++ .../fragment_playback_panel.xml | 4 +- .../main/res/layout/dialog_error_details.xml | 67 ++++++++++++++ app/src/main/res/layout/fragment_home.xml | 21 ++++- app/src/main/res/navigation/inner.xml | 13 +++ app/src/main/res/values/dimens.xml | 1 + app/src/main/res/values/strings.xml | 8 ++ 13 files changed, 294 insertions(+), 85 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt create mode 100644 app/src/main/res/drawable/ic_copy_24.xml create mode 100644 app/src/main/res/layout/dialog_error_details.xml diff --git a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt new file mode 100644 index 000000000..19ac97a34 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2023 Auxio Project + * ErrorDetailsDialog.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.home + +import android.content.ClipData +import android.content.ClipboardManager +import android.os.Build +import android.os.Bundle +import android.view.LayoutInflater +import androidx.appcompat.app.AlertDialog +import androidx.navigation.fragment.navArgs +import org.oxycblt.auxio.R +import org.oxycblt.auxio.databinding.DialogErrorDetailsBinding +import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment +import org.oxycblt.auxio.util.getSystemServiceCompat +import org.oxycblt.auxio.util.openInBrowser +import org.oxycblt.auxio.util.showToast + +/** + * A dialog that shows a stack trace for a music loading error. + * + * TODO: Extend to other errors + * + * @author Alexander Capehart (OxygenCobalt) + */ +class ErrorDetailsDialog : ViewBindingMaterialDialogFragment() { + private val args: ErrorDetailsDialogArgs by navArgs() + private var clipboardManager: ClipboardManager? = null + + override fun onConfigDialog(builder: AlertDialog.Builder) { + builder + .setTitle(R.string.lbl_error_info) + .setPositiveButton(R.string.lbl_report) { _, _ -> + requireContext().openInBrowser(LINK_ISSUES) + } + .setNegativeButton(R.string.lbl_cancel, null) + } + + override fun onCreateBinding(inflater: LayoutInflater) = + DialogErrorDetailsBinding.inflate(inflater) + + override fun onBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) { + super.onBindingCreated(binding, savedInstanceState) + + clipboardManager = requireContext().getSystemServiceCompat(ClipboardManager::class) + + // --- UI SETUP --- + binding.errorStackTrace.text = args.error.stackTraceToString().trimEnd('\n') + binding.errorCopy.setOnClickListener { copyStackTrace() } + } + + override fun onDestroyBinding(binding: DialogErrorDetailsBinding) { + super.onDestroyBinding(binding) + clipboardManager = null + } + + private fun copyStackTrace() { + requireNotNull(clipboardManager) { "Clipboard was unavailable" } + .setPrimaryClip( + ClipData.newPlainText("Exception Stack Trace", args.error.stackTraceToString())) + // A copy notice is shown by the system from Android 13 onwards + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + requireContext().showToast(R.string.lbl_copied) + } + } + + private companion object { + /** The URL to the bug report issue form */ + const val LINK_ISSUES = + "https://github.com/OxygenCobalt/Auxio/issues/new" + + "?assignees=OxygenCobalt&labels=bug&projects=&template=bug-crash-report.yml" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index 7228e10ec..c016c6602 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -330,11 +330,12 @@ class HomeFragment : } } - private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) { + private fun setupCompleteState(binding: FragmentHomeBinding, error: Exception?) { if (error == null) { logD("Received ok response") binding.homeFab.show() binding.homeIndexingContainer.visibility = View.INVISIBLE + binding.homeIndexingError.visibility = View.INVISIBLE return } @@ -357,6 +358,7 @@ class HomeFragment : .launch(PERMISSION_READ_AUDIO) } } + binding.homeIndexingError.visibility = View.INVISIBLE } is NoMusicException -> { logD("Showing no music error") @@ -367,6 +369,7 @@ class HomeFragment : text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.refresh() } } + binding.homeIndexingError.visibility = View.INVISIBLE } else -> { logD("Showing generic error") @@ -377,6 +380,12 @@ class HomeFragment : text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.rescan() } } + binding.homeIndexingError.apply { + visibility = View.VISIBLE + setOnClickListener { + findNavController().navigateSafe(HomeFragmentDirections.reportError(error)) + } + } } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt index a185d5b2f..d4e582660 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt @@ -47,7 +47,7 @@ sealed interface IndexingState { * @param error If music loading has failed, the error that occurred will be here. Otherwise, it * will be null. */ - data class Completed(val error: Throwable?) : IndexingState + data class Completed(val error: Exception?) : IndexingState } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index 6e45c5ae9..d5263da7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -371,6 +371,7 @@ constructor( // parallel. logD("Starting MediaStore query") emitIndexingProgress(IndexingProgress.Indeterminate) + val mediaStoreQueryJob = worker.scope.async { val query = diff --git a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt index cd6217a9f..3c3258ab9 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/AboutFragment.kt @@ -18,13 +18,8 @@ package org.oxycblt.auxio.settings -import android.content.ActivityNotFoundException -import android.content.Intent -import android.content.pm.PackageManager -import android.os.Build import android.os.Bundle import android.view.LayoutInflater -import androidx.core.net.toUri import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import androidx.navigation.fragment.findNavController @@ -37,8 +32,7 @@ import org.oxycblt.auxio.music.MusicViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.ui.ViewBindingFragment import org.oxycblt.auxio.util.collectImmediately -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.showToast +import org.oxycblt.auxio.util.openInBrowser import org.oxycblt.auxio.util.systemBarInsetsCompat /** @@ -69,10 +63,10 @@ class AboutFragment : ViewBindingFragment() { } binding.aboutVersion.text = BuildConfig.VERSION_NAME - binding.aboutCode.setOnClickListener { openLinkInBrowser(LINK_SOURCE) } - binding.aboutWiki.setOnClickListener { openLinkInBrowser(LINK_WIKI) } - binding.aboutLicenses.setOnClickListener { openLinkInBrowser(LINK_LICENSES) } - binding.aboutAuthor.setOnClickListener { openLinkInBrowser(LINK_AUTHOR) } + binding.aboutCode.setOnClickListener { requireContext().openInBrowser(LINK_SOURCE) } + binding.aboutWiki.setOnClickListener { requireContext().openInBrowser(LINK_WIKI) } + binding.aboutLicenses.setOnClickListener { requireContext().openInBrowser(LINK_LICENSES) } + binding.aboutAuthor.setOnClickListener { requireContext().openInBrowser(LINK_AUTHOR) } // VIEWMODEL SETUP collectImmediately(musicModel.statistics, ::updateStatistics) @@ -93,74 +87,6 @@ class AboutFragment : ViewBindingFragment() { (statistics?.durationMs ?: 0).formatDurationMs(false)) } - /** - * Open the given URI in a web browser. - * - * @param uri The URL to open. - */ - private fun openLinkInBrowser(uri: String) { - logD("Opening $uri") - val context = requireContext() - val browserIntent = - Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - // Android 11 seems to now handle the app chooser situations on its own now - // [along with adding a new permission that breaks the old manual code], so - // we just do a typical activity launch. - logD("Using API 30+ chooser") - try { - context.startActivity(browserIntent) - } catch (e: ActivityNotFoundException) { - // No app installed to open the link - context.showToast(R.string.err_no_app) - } - } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { - // On older versions of android, opening links from an ACTION_VIEW intent might - // not work in all cases, especially when no default app was set. If that is the - // case, we will try to manually handle these cases before we try to launch the - // browser. - logD("Resolving browser activity for chooser") - val pkgName = - context.packageManager - .resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY) - ?.run { activityInfo.packageName } - - if (pkgName != null) { - if (pkgName == "android") { - // No default browser [Must open app chooser, may not be supported] - logD("No default browser found") - openAppChooser(browserIntent) - } else logD("Opening browser intent") - try { - browserIntent.setPackage(pkgName) - startActivity(browserIntent) - } catch (e: ActivityNotFoundException) { - // Not a browser but an app chooser - browserIntent.setPackage(null) - openAppChooser(browserIntent) - } - } else { - // No app installed to open the link - context.showToast(R.string.err_no_app) - } - } - } - - /** - * Open an app chooser for a given [Intent]. - * - * @param intent The [Intent] to show an app chooser for. - */ - private fun openAppChooser(intent: Intent) { - logD("Opening app chooser for ${intent.action}") - val chooserIntent = - Intent(Intent.ACTION_CHOOSER) - .putExtra(Intent.EXTRA_INTENT, intent) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - startActivity(chooserIntent) - } - private companion object { /** The URL to the source code. */ const val LINK_SOURCE = "https://github.com/OxygenCobalt/Auxio" diff --git a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt index e58a34c93..1662c47c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/FrameworkUtil.kt @@ -18,7 +18,10 @@ package org.oxycblt.auxio.util +import android.content.ActivityNotFoundException import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.graphics.PointF import android.graphics.drawable.Drawable import android.os.Build @@ -33,6 +36,7 @@ import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.app.ShareCompat import androidx.core.graphics.Insets import androidx.core.graphics.drawable.DrawableCompat +import androidx.core.net.toUri import androidx.core.view.children import androidx.navigation.NavController import androidx.navigation.NavDirections @@ -41,6 +45,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.viewbinding.ViewBinding import com.google.android.material.appbar.MaterialToolbar import java.lang.IllegalArgumentException +import org.oxycblt.auxio.R import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -322,3 +327,65 @@ fun Context.share(songs: Collection) { builder.setType(mimeTypes.singleOrNull() ?: "audio/*").startChooser() } + +/** + * Open the given URI in a web browser. + * + * @param uri The URL to open. + */ +fun Context.openInBrowser(uri: String) { + fun openAppChooser(intent: Intent) { + logD("Opening app chooser for ${intent.action}") + val chooserIntent = + Intent(Intent.ACTION_CHOOSER) + .putExtra(Intent.EXTRA_INTENT, intent) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(chooserIntent) + } + + logD("Opening $uri") + val browserIntent = + Intent(Intent.ACTION_VIEW, uri.toUri()).setFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + // Android 11 seems to now handle the app chooser situations on its own now + // [along with adding a new permission that breaks the old manual code], so + // we just do a typical activity launch. + logD("Using API 30+ chooser") + try { + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + // No app installed to open the link + showToast(R.string.err_no_app) + } + } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) { + // On older versions of android, opening links from an ACTION_VIEW intent might + // not work in all cases, especially when no default app was set. If that is the + // case, we will try to manually handle these cases before we try to launch the + // browser. + logD("Resolving browser activity for chooser") + val pkgName = + packageManager.resolveActivity(browserIntent, PackageManager.MATCH_DEFAULT_ONLY)?.run { + activityInfo.packageName + } + + if (pkgName != null) { + if (pkgName == "android") { + // No default browser [Must open app chooser, may not be supported] + logD("No default browser found") + openAppChooser(browserIntent) + } else logD("Opening browser intent") + try { + browserIntent.setPackage(pkgName) + startActivity(browserIntent) + } catch (e: ActivityNotFoundException) { + // Not a browser but an app chooser + browserIntent.setPackage(null) + openAppChooser(browserIntent) + } + } else { + // No app installed to open the link + showToast(R.string.err_no_app) + } + } +} diff --git a/app/src/main/res/drawable/ic_copy_24.xml b/app/src/main/res/drawable/ic_copy_24.xml new file mode 100644 index 000000000..65bb96df5 --- /dev/null +++ b/app/src/main/res/drawable/ic_copy_24.xml @@ -0,0 +1,11 @@ + + + + diff --git a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml index c673dd8ca..b46562fa2 100644 --- a/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml +++ b/app/src/main/res/layout-sw600dp/fragment_playback_panel.xml @@ -69,8 +69,8 @@ android:id="@+id/playback_seek_bar" android:layout_width="match_parent" android:layout_height="wrap_content" - android:layout_marginStart="@dimen/spacing_small" - android:layout_marginEnd="@dimen/spacing_small" + android:layout_marginStart="@dimen/spacing_tiny" + android:layout_marginEnd="@dimen/spacing_tiny" app:layout_constraintBottom_toTopOf="@+id/playback_controls_container" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintHorizontal_bias="0.0" diff --git a/app/src/main/res/layout/dialog_error_details.xml b/app/src/main/res/layout/dialog_error_details.xml new file mode 100644 index 000000000..729c17d0b --- /dev/null +++ b/app/src/main/res/layout/dialog_error_details.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 049256481..8eeb42c13 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -108,16 +108,33 @@ + + diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index d665a90d3..b1f834f95 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -81,8 +81,21 @@ + + + + + 48dp 56dp 64dp + 64dp 72dp 24dp diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fd9f2c2e7..852e9eeae 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -16,6 +16,8 @@ Monitoring music library Retry + + More Grant @@ -168,6 +170,12 @@ Selection + Error information + + Copied + + Report + From e912120f9fecb29f3018141719b4c23be465d8e7 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Mon, 14 Aug 2023 19:54:31 -0600 Subject: [PATCH 07/25] all: general cleanup --- CHANGELOG.md | 1 + .../main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt | 4 ++-- .../org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt | 6 ++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e9e66ee13..81d1a81e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ - Item and sort menus have been refreshed with a cleaner look - Added ability to sort playlists - Added option to play song by itself in library/item details +- Added error details when music loading fails #### What's Improved - Made "Add to Playlist" action more prominent in selection toolbar diff --git a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt index 19ac97a34..e88ed1175 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/ErrorDetailsDialog.kt @@ -35,9 +35,9 @@ import org.oxycblt.auxio.util.showToast /** * A dialog that shows a stack trace for a music loading error. * - * TODO: Extend to other errors - * * @author Alexander Capehart (OxygenCobalt) + * + * TODO: Extend to other errors */ class ErrorDetailsDialog : ViewBindingMaterialDialogFragment() { private val args: ErrorDetailsDialogArgs by navArgs() diff --git a/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt index 1c3e9f340..58fe95c14 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/DialogAwareNavigationListener.kt @@ -24,7 +24,10 @@ import androidx.navigation.NavDestination /** * A [NavController.OnDestinationChangedListener] that will call [callback] when moving between - * fragments only (not between dialogs or anything similar) + * fragments only (not between dialogs or anything similar). + * + * Note: This only works because of special naming used in Auxio's navigation graphs. Keep this in + * mind when porting to other projects. * * @author Alexander Capehart (OxygenCobalt) */ @@ -74,6 +77,5 @@ class DialogAwareNavigationListener(private val callback: () -> Unit) : } } - /** This relies on special label naming used in-app. */ private fun NavDestination.isDialog() = label?.endsWith("dialog") == true } From e32fc6b60985b3c40d7d443f6b0d28daf708251b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Tue, 15 Aug 2023 20:48:17 -0600 Subject: [PATCH 08/25] home: fix misaligned grant/retry button Caused by naive visibility logic when I added the "More" button prior. Resolves #544. --- .../org/oxycblt/auxio/home/HomeFragment.kt | 17 +++--- .../oxycblt/auxio/music/MusicRepository.kt | 1 + app/src/main/res/layout/fragment_home.xml | 57 ++++++++++--------- 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt index c016c6602..01558611d 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -335,7 +335,6 @@ class HomeFragment : logD("Received ok response") binding.homeFab.show() binding.homeIndexingContainer.visibility = View.INVISIBLE - binding.homeIndexingError.visibility = View.INVISIBLE return } @@ -343,13 +342,13 @@ class HomeFragment : val context = requireContext() binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.INVISIBLE + binding.homeIndexingActions.visibility = View.VISIBLE when (error) { is NoAudioPermissionException -> { logD("Showing permission prompt") binding.homeIndexingStatus.text = context.getString(R.string.err_no_perms) // Configure the action to act as a permission launcher. - binding.homeIndexingAction.apply { - visibility = View.VISIBLE + binding.homeIndexingTry.apply { text = context.getString(R.string.lbl_grant) setOnClickListener { requireNotNull(storagePermissionLauncher) { @@ -358,29 +357,29 @@ class HomeFragment : .launch(PERMISSION_READ_AUDIO) } } - binding.homeIndexingError.visibility = View.INVISIBLE + binding.homeIndexingMore.visibility = View.GONE } is NoMusicException -> { logD("Showing no music error") binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) // Configure the action to act as a reload trigger. - binding.homeIndexingAction.apply { + binding.homeIndexingTry.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.refresh() } } - binding.homeIndexingError.visibility = View.INVISIBLE + binding.homeIndexingMore.visibility = View.GONE } else -> { logD("Showing generic error") binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) // Configure the action to act as a reload trigger. - binding.homeIndexingAction.apply { + binding.homeIndexingTry.apply { visibility = View.VISIBLE text = context.getString(R.string.lbl_retry) setOnClickListener { musicModel.rescan() } } - binding.homeIndexingError.apply { + binding.homeIndexingMore.apply { visibility = View.VISIBLE setOnClickListener { findNavController().navigateSafe(HomeFragmentDirections.reportError(error)) @@ -394,7 +393,7 @@ class HomeFragment : // Remove all content except for the progress indicator. binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.VISIBLE - binding.homeIndexingAction.visibility = View.INVISIBLE + binding.homeIndexingActions.visibility = View.INVISIBLE when (progress) { is IndexingProgress.Indeterminate -> { diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index d5263da7b..e3f2a0abb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,6 +29,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield diff --git a/app/src/main/res/layout/fragment_home.xml b/app/src/main/res/layout/fragment_home.xml index 8eeb42c13..f1b5c8c80 100644 --- a/app/src/main/res/layout/fragment_home.xml +++ b/app/src/main/res/layout/fragment_home.xml @@ -70,8 +70,8 @@ android:layout_height="wrap_content" android:layout_gravity="center" android:layout_margin="@dimen/spacing_medium" - android:fitsSystemWindows="true" - android:visibility="invisible"> + android:visibility="invisible" + android:fitsSystemWindows="true"> @@ -103,37 +103,40 @@ android:layout_marginEnd="@dimen/spacing_medium" android:indeterminate="true" app:indeterminateAnimationType="disjoint" - app:layout_constraintBottom_toBottomOf="@+id/home_indexing_action" - app:layout_constraintTop_toTopOf="@+id/home_indexing_action" /> + app:layout_constraintBottom_toBottomOf="@+id/home_indexing_actions" + app:layout_constraintTop_toTopOf="@+id/home_indexing_actions" /> - + tools:layout_editor_absoluteX="16dp"> - + + + + + From b43e4695c080be09e71c9a70c36a21b057e9df5c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 15:09:45 -0600 Subject: [PATCH 09/25] build: update deps fragment: 1.6.0 -> 1.6.1 preferences: 1.2.0 -> 1.2.1 room: 2.6.0-alpha02 -> 2.6.0-alpha03 material: 1.10.0-alpha05 -> 1.10.0-alpha06 media: 1.1.0 -> 1.1.1 --- app/build.gradle | 12 +++++------- build.gradle | 2 +- media | 2 +- 3 files changed, 7 insertions(+), 9 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index f088245c7..efac4f42e 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,7 +21,7 @@ android { defaultConfig { applicationId namespace - versionName "3.1.4" + versionName "3.1.0" versionCode 34 minSdk 24 @@ -88,7 +88,7 @@ dependencies { implementation "androidx.core:core-ktx:1.10.1" implementation "androidx.appcompat:appcompat:1.6.1" implementation "androidx.activity:activity-ktx:1.7.2" - implementation "androidx.fragment:fragment-ktx:1.6.0" + implementation "androidx.fragment:fragment-ktx:1.6.1" // Components // Deliberately kept on 1.2.1 to prevent a bug where the queue sheet will not collapse on @@ -114,13 +114,11 @@ dependencies { implementation "androidx.media:media:1.6.0" // Preferences - implementation "androidx.preference:preference-ktx:1.2.0" + implementation "androidx.preference:preference-ktx:1.2.1" // Database - def room_version = '2.6.0-alpha02' + def room_version = '2.6.0-alpha03' implementation "androidx.room:room-runtime:$room_version" - // I have no clue why, but using KSP breaks the playlist database definition. - //noinspection KaptUsageInsteadOfKsp ksp "androidx.room:room-compiler:$room_version" implementation "androidx.room:room-ktx:$room_version" @@ -136,7 +134,7 @@ dependencies { // Material // TODO: Exactly figure out the conditions that the 1.7.0 ripple bug occurred so you can just // PR a fix. - implementation "com.google.android.material:material:1.10.0-alpha05" + implementation "com.google.android.material:material:1.10.0-alpha06" // Dependency Injection implementation "com.google.dagger:dagger:$hilt_version" diff --git a/build.gradle b/build.gradle index 75fa2ca64..e5bc1dc66 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { kotlin_version = '1.9.0' - navigation_version = "2.6.0" + navigation_version = "2.7.0" hilt_version = '2.47' } diff --git a/media b/media index 316763308..40c3e5c68 160000 --- a/media +++ b/media @@ -1 +1 @@ -Subproject commit 316763308d3143c75270103c85cf2d984bfa34a0 +Subproject commit 40c3e5c68cbdf8758037aa40b4071cca8a53ee89 From 70a5bab9214c9592979504fe006d225982872ce2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 17:12:56 -0600 Subject: [PATCH 10/25] ui: vendor bottom sheet dialog w/fixes Vendor BottomSheetDialog(Fragment) with the inset fix that prior used reflection. Apparently said reflection breaks down and crashes the release build somehow. So now I just have to hastily patch BackportBottomSheetBehavior and vendor another 1000 lines of MDC code. Really considering making a PHP sadness-like blog solely for android at this point. --- .../BackportBottomSheetBehavior.java | 32 +- .../BackportBottomSheetDialog.java | 551 ++++++++++++++++++ .../BackportBottomSheetDialogFragment.java | 120 ++++ .../ViewBindingBottomSheetDialogFragment.kt | 66 +-- .../res/layout/design_bottom_sheet_dialog.xml | 51 ++ app/src/main/res/values/styles_ui.xml | 8 + 6 files changed, 753 insertions(+), 75 deletions(-) create mode 100644 app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java create mode 100644 app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java create mode 100644 app/src/main/res/layout/design_bottom_sheet_dialog.xml diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index 214f6ac62..f9e8edb42 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -1737,16 +1737,10 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo final boolean shouldHandleGestureInsets = VERSION.SDK_INT >= VERSION_CODES.Q && !isGestureInsetBottomIgnored() && !peekHeightAuto; - // If were not handling insets at all, don't apply the listener. - if (!paddingBottomSystemWindowInsets - && !paddingLeftSystemWindowInsets - && !paddingRightSystemWindowInsets - && !marginLeftSystemWindowInsets - && !marginRightSystemWindowInsets - && !marginTopSystemWindowInsets - && !shouldHandleGestureInsets) { - return; - } + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + ViewUtils.doOnApplyWindowInsets( child, new ViewUtils.OnApplyWindowInsetsListener() { @@ -1759,6 +1753,12 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); insetTop = systemBarInsets.top; + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + // Intentionally uses getSystemWindowInsetBottom to apply padding properly when + // adjustResize is used as the windowSoftInputMode. + insetBottom = insets.getSystemWindowInsetBottom(); boolean isRtl = ViewUtils.isLayoutRtl(view); @@ -1767,9 +1767,6 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo int rightPadding = view.getPaddingRight(); if (paddingBottomSystemWindowInsets) { - // Intentionally uses getSystemWindowInsetBottom to apply padding properly when - // adjustResize is used as the windowSoftInputMode. - insetBottom = insets.getSystemWindowInsetBottom(); bottomPadding = initialPadding.bottom + insetBottom; } @@ -1810,11 +1807,10 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo gestureInsetBottom = mandatoryGestureInsets.bottom; } - // Don't update the peek height to be above the navigation bar or gestures if these - // flags are off. It means the client is already handling it. - if (paddingBottomSystemWindowInsets || shouldHandleGestureInsets) { - updatePeekHeight(/* animate= */ false); - } + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves + // don't need peek height adjustments (Despite the fact that they still likely padding + // the view, just without clipping anything) + updatePeekHeight(/* animate= */ false); return insets; } }); diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java new file mode 100644 index 000000000..af5cc64bf --- /dev/null +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java @@ -0,0 +1,551 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.material.bottomsheet; + +import com.google.android.material.R; + +import static com.google.android.material.color.MaterialColors.isColorLight; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.content.res.TypedArray; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.os.Build; +import android.os.Build.VERSION; +import android.os.Build.VERSION_CODES; +import android.os.Bundle; +import androidx.appcompat.app.AppCompatDialog; +import android.util.TypedValue; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager.LayoutParams; +import android.widget.FrameLayout; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.StyleRes; +import androidx.coordinatorlayout.widget.CoordinatorLayout; +import androidx.core.view.AccessibilityDelegateCompat; +import androidx.core.view.OnApplyWindowInsetsListener; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowCompat; +import androidx.core.view.WindowInsetsCompat; +import androidx.core.view.WindowInsetsControllerCompat; +import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; +import com.google.android.material.internal.EdgeToEdgeUtils; +import com.google.android.material.motion.MaterialBackOrchestrator; +import com.google.android.material.shape.MaterialShapeDrawable; + +import org.checkerframework.common.subtyping.qual.Bottom; + +/** + * Base class for {@link android.app.Dialog}s styled as a bottom sheet. + * + *

Edge to edge window flags are automatically applied if the {@link + * android.R.attr#navigationBarColor} is transparent or translucent and {@code enableEdgeToEdge} is + * true. These can be set in the theme that is passed to the constructor, or will be taken from the + * theme of the context (ie. your application or activity theme). + * + *

In edge to edge mode, padding will be added automatically to the top when sliding under the + * status bar. Padding can be applied automatically to the left, right, or bottom if any of + * `paddingBottomSystemWindowInsets`, `paddingLeftSystemWindowInsets`, or + * `paddingRightSystemWindowInsets` are set to true in the style. + * + * MODIFICATION: Replace all usages of BottomSheetBehavior with BackportBottomSheetBehavior + */ +public class BackportBottomSheetDialog extends AppCompatDialog { + + private BackportBottomSheetBehavior behavior; + + private FrameLayout container; + private CoordinatorLayout coordinator; + private FrameLayout bottomSheet; + + boolean dismissWithAnimation; + + boolean cancelable = true; + private boolean canceledOnTouchOutside = true; + private boolean canceledOnTouchOutsideSet; + private EdgeToEdgeCallback edgeToEdgeCallback; + private boolean edgeToEdgeEnabled; + @Nullable private MaterialBackOrchestrator backOrchestrator; + + public BackportBottomSheetDialog(@NonNull Context context) { + this(context, 0); + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + public BackportBottomSheetDialog(@NonNull Context context, @StyleRes int theme) { + super(context, getThemeResId(context, theme)); + // We hide the title bar for any style configuration. Otherwise, there will be a gap + // above the bottom sheet when it is expanded. + supportRequestWindowFeature(Window.FEATURE_NO_TITLE); + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + protected BackportBottomSheetDialog( + @NonNull Context context, boolean cancelable, OnCancelListener cancelListener) { + super(context, cancelable, cancelListener); + supportRequestWindowFeature(Window.FEATURE_NO_TITLE); + this.cancelable = cancelable; + + edgeToEdgeEnabled = + getContext() + .getTheme() + .obtainStyledAttributes(new int[] {R.attr.enableEdgeToEdge}) + .getBoolean(0, false); + } + + @Override + public void setContentView(@LayoutRes int layoutResId) { + super.setContentView(wrapInBottomSheet(layoutResId, null, null)); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Window window = getWindow(); + if (window != null) { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + // The status bar should always be transparent because of the window animation. + window.setStatusBarColor(0); + + window.addFlags(LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS); + if (VERSION.SDK_INT < VERSION_CODES.M) { + // It can be transparent for API 23 and above because we will handle switching the status + // bar icons to light or dark as appropriate. For API 21 and API 22 we just set the + // translucent status bar. + window.addFlags(LayoutParams.FLAG_TRANSLUCENT_STATUS); + } + } + window.setLayout(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT); + } + } + + @Override + public void setContentView(View view) { + super.setContentView(wrapInBottomSheet(0, view, null)); + } + + @Override + public void setContentView(View view, ViewGroup.LayoutParams params) { + super.setContentView(wrapInBottomSheet(0, view, params)); + } + + @Override + public void setCancelable(boolean cancelable) { + super.setCancelable(cancelable); + if (this.cancelable != cancelable) { + this.cancelable = cancelable; + if (behavior != null) { + behavior.setHideable(cancelable); + } + if (getWindow() != null) { + updateListeningForBackCallbacks(); + } + } + } + + @Override + protected void onStart() { + super.onStart(); + if (behavior != null && behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + behavior.setState(BackportBottomSheetBehavior.STATE_COLLAPSED); + } + } + + @Override + public void onAttachedToWindow() { + super.onAttachedToWindow(); + Window window = getWindow(); + if (window != null) { + if (VERSION.SDK_INT >= VERSION_CODES.LOLLIPOP) { + // If the navigation bar is transparent at all the BottomSheet should be edge to edge. + boolean drawEdgeToEdge = + edgeToEdgeEnabled && Color.alpha(window.getNavigationBarColor()) < 255; + if (container != null) { + container.setFitsSystemWindows(!drawEdgeToEdge); + } + if (coordinator != null) { + coordinator.setFitsSystemWindows(!drawEdgeToEdge); + } + WindowCompat.setDecorFitsSystemWindows(window, !drawEdgeToEdge); + } + if (edgeToEdgeCallback != null) { + edgeToEdgeCallback.setWindow(window); + } + } + + updateListeningForBackCallbacks(); + } + + @Override + public void onDetachedFromWindow() { + if (edgeToEdgeCallback != null) { + edgeToEdgeCallback.setWindow(null); + } + + if (backOrchestrator != null) { + backOrchestrator.stopListeningForBackCallbacks(); + } + } + + /** + * This function can be called from a few different use cases, including Swiping the dialog down + * or calling `dismiss()` from a `BackportBottomSheetDialogFragment`, tapping outside a dialog, etc... + * + *

The default animation to dismiss this dialog is a fade-out transition through a + * windowAnimation. Call {@link #setDismissWithAnimation(true)} if you want to utilize the + * BottomSheet animation instead. + * + *

If this function is called from a swipe down interaction, or dismissWithAnimation is false, + * then keep the default behavior. + * + *

Else, since this is a terminal event which will finish this dialog, we override the attached + * {@link BackportBottomSheetBehavior.BottomSheetCallback} to call this function, after {@link + * BackportBottomSheetBehavior#STATE_HIDDEN} is set. This will enforce the swipe down animation before + * canceling this dialog. + */ + @Override + public void cancel() { + BackportBottomSheetBehavior behavior = getBehavior(); + + if (!dismissWithAnimation || behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + super.cancel(); + } else { + behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN); + } + } + + @Override + public void setCanceledOnTouchOutside(boolean cancel) { + super.setCanceledOnTouchOutside(cancel); + if (cancel && !cancelable) { + cancelable = true; + } + canceledOnTouchOutside = cancel; + canceledOnTouchOutsideSet = true; + } + + @NonNull + public BackportBottomSheetBehavior getBehavior() { + if (behavior == null) { + // The content hasn't been set, so the behavior doesn't exist yet. Let's create it. + ensureContainerAndBehavior(); + } + return behavior; + } + + /** + * Set to perform the swipe down animation when dismissing instead of the window animation for the + * dialog. + * + * @param dismissWithAnimation True if swipe down animation should be used when dismissing. + */ + public void setDismissWithAnimation(boolean dismissWithAnimation) { + this.dismissWithAnimation = dismissWithAnimation; + } + + /** + * Returns if dismissing will perform the swipe down animation on the bottom sheet, rather than + * the window animation for the dialog. + */ + public boolean getDismissWithAnimation() { + return dismissWithAnimation; + } + + /** Returns if edge to edge behavior is enabled for this dialog. */ + public boolean getEdgeToEdgeEnabled() { + return edgeToEdgeEnabled; + } + + /** Creates the container layout which must exist to find the behavior */ + private FrameLayout ensureContainerAndBehavior() { + if (container == null) { + container = + (FrameLayout) View.inflate(getContext(), R.layout.design_bottom_sheet_dialog, null); + + coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator); + bottomSheet = (FrameLayout) container.findViewById(R.id.design_bottom_sheet); + + // MODIFICATION: Override layout-specified BottomSheetBehavior w/BackportBottomSheetBehavior + behavior = BackportBottomSheetBehavior.from(bottomSheet); + behavior.addBottomSheetCallback(bottomSheetCallback); + behavior.setHideable(cancelable); + + backOrchestrator = new MaterialBackOrchestrator(behavior, bottomSheet); + } + return container; + } + + private View wrapInBottomSheet( + int layoutResId, @Nullable View view, @Nullable ViewGroup.LayoutParams params) { + ensureContainerAndBehavior(); + CoordinatorLayout coordinator = (CoordinatorLayout) container.findViewById(R.id.coordinator); + if (layoutResId != 0 && view == null) { + view = getLayoutInflater().inflate(layoutResId, coordinator, false); + } + + if (edgeToEdgeEnabled) { + ViewCompat.setOnApplyWindowInsetsListener( + bottomSheet, + new OnApplyWindowInsetsListener() { + @Override + public WindowInsetsCompat onApplyWindowInsets(View view, WindowInsetsCompat insets) { + if (edgeToEdgeCallback != null) { + behavior.removeBottomSheetCallback(edgeToEdgeCallback); + } + + if (insets != null) { + edgeToEdgeCallback = new EdgeToEdgeCallback(bottomSheet, insets); + edgeToEdgeCallback.setWindow(getWindow()); + behavior.addBottomSheetCallback(edgeToEdgeCallback); + } + + return insets; + } + }); + } + + bottomSheet.removeAllViews(); + if (params == null) { + bottomSheet.addView(view); + } else { + bottomSheet.addView(view, params); + } + // We treat the CoordinatorLayout as outside the dialog though it is technically inside + coordinator + .findViewById(R.id.touch_outside) + .setOnClickListener( + new View.OnClickListener() { + @Override + public void onClick(View view) { + if (cancelable && isShowing() && shouldWindowCloseOnTouchOutside()) { + cancel(); + } + } + }); + // Handle accessibility events + ViewCompat.setAccessibilityDelegate( + bottomSheet, + new AccessibilityDelegateCompat() { + @Override + public void onInitializeAccessibilityNodeInfo( + View host, @NonNull AccessibilityNodeInfoCompat info) { + super.onInitializeAccessibilityNodeInfo(host, info); + if (cancelable) { + info.addAction(AccessibilityNodeInfoCompat.ACTION_DISMISS); + info.setDismissable(true); + } else { + info.setDismissable(false); + } + } + + @Override + public boolean performAccessibilityAction(View host, int action, Bundle args) { + if (action == AccessibilityNodeInfoCompat.ACTION_DISMISS && cancelable) { + cancel(); + return true; + } + return super.performAccessibilityAction(host, action, args); + } + }); + bottomSheet.setOnTouchListener( + new View.OnTouchListener() { + @Override + public boolean onTouch(View view, MotionEvent event) { + // Consume the event and prevent it from falling through + return true; + } + }); + return container; + } + + private void updateListeningForBackCallbacks() { + if (backOrchestrator == null) { + return; + } + if (cancelable) { + backOrchestrator.startListeningForBackCallbacks(); + } else { + backOrchestrator.stopListeningForBackCallbacks(); + } + } + + boolean shouldWindowCloseOnTouchOutside() { + if (!canceledOnTouchOutsideSet) { + TypedArray a = + getContext().obtainStyledAttributes(new int[] {android.R.attr.windowCloseOnTouchOutside}); + canceledOnTouchOutside = a.getBoolean(0, true); + a.recycle(); + canceledOnTouchOutsideSet = true; + } + return canceledOnTouchOutside; + } + + private static int getThemeResId(@NonNull Context context, int themeId) { + if (themeId == 0) { + // If the provided theme is 0, then retrieve the dialogTheme from our theme + TypedValue outValue = new TypedValue(); + if (context.getTheme().resolveAttribute(R.attr.bottomSheetDialogTheme, outValue, true)) { + themeId = outValue.resourceId; + } else { + // bottomSheetDialogTheme is not provided; we default to our light theme + themeId = R.style.Theme_Design_Light_BottomSheetDialog; + } + } + return themeId; + } + + void removeDefaultCallback() { + behavior.removeBottomSheetCallback(bottomSheetCallback); + } + + @NonNull + private BackportBottomSheetBehavior.BottomSheetCallback bottomSheetCallback = + new BackportBottomSheetBehavior.BottomSheetCallback() { + @Override + public void onStateChanged( + @NonNull View bottomSheet, @BackportBottomSheetBehavior.State int newState) { + if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) { + cancel(); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) {} + }; + + private static class EdgeToEdgeCallback extends BackportBottomSheetBehavior.BottomSheetCallback { + + @Nullable private final Boolean lightBottomSheet; + @NonNull private final WindowInsetsCompat insetsCompat; + + @Nullable private Window window; + private boolean lightStatusBar; + + private EdgeToEdgeCallback( + @NonNull final View bottomSheet, @NonNull WindowInsetsCompat insetsCompat) { + this.insetsCompat = insetsCompat; + + // Try to find the background color to automatically change the status bar icons so they will + // still be visible when the bottomsheet slides underneath the status bar. + ColorStateList backgroundTint; + MaterialShapeDrawable msd = BackportBottomSheetBehavior.from(bottomSheet).getMaterialShapeDrawable(); + if (msd != null) { + backgroundTint = msd.getFillColor(); + } else { + backgroundTint = ViewCompat.getBackgroundTintList(bottomSheet); + } + + if (backgroundTint != null) { + // First check for a tint + lightBottomSheet = isColorLight(backgroundTint.getDefaultColor()); + } else if (bottomSheet.getBackground() instanceof ColorDrawable) { + // Then check for the background color + lightBottomSheet = isColorLight(((ColorDrawable) bottomSheet.getBackground()).getColor()); + } else { + // Otherwise don't change the status bar color + lightBottomSheet = null; + } + } + + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + setPaddingForPosition(bottomSheet); + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) { + setPaddingForPosition(bottomSheet); + } + + @Override + void onLayout(@NonNull View bottomSheet) { + setPaddingForPosition(bottomSheet); + } + + void setWindow(@Nullable Window window) { + if (this.window == window) { + return; + } + this.window = window; + if (window != null) { + WindowInsetsControllerCompat insetsController = + WindowCompat.getInsetsController(window, window.getDecorView()); + lightStatusBar = insetsController.isAppearanceLightStatusBars(); + } + } + + private void setPaddingForPosition(View bottomSheet) { + if (bottomSheet.getTop() < insetsCompat.getSystemWindowInsetTop()) { + // If the bottomsheet is light, we should set light status bar so the icons are visible + // since the bottomsheet is now under the status bar. + if (window != null) { + EdgeToEdgeUtils.setLightStatusBar( + window, lightBottomSheet == null ? lightStatusBar : lightBottomSheet); + } + // Smooth transition into status bar when drawing edge to edge. + bottomSheet.setPadding( + bottomSheet.getPaddingLeft(), + (insetsCompat.getSystemWindowInsetTop() - bottomSheet.getTop()), + bottomSheet.getPaddingRight(), + bottomSheet.getPaddingBottom()); + } else if (bottomSheet.getTop() != 0) { + // Reset the status bar icons to the original color because the bottomsheet is not under the + // status bar. + if (window != null) { + EdgeToEdgeUtils.setLightStatusBar(window, lightStatusBar); + } + bottomSheet.setPadding( + bottomSheet.getPaddingLeft(), + 0, + bottomSheet.getPaddingRight(), + bottomSheet.getPaddingBottom()); + } + } + } + + /** + * @deprecated use {@link EdgeToEdgeUtils#setLightStatusBar(Window, boolean)} instead + */ + @Deprecated + public static void setLightStatusBar(@NonNull View view, boolean isLight) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + int flags = view.getSystemUiVisibility(); + if (isLight) { + flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } else { + flags &= ~View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + view.setSystemUiVisibility(flags); + } + } +} diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java new file mode 100644 index 000000000..eead66daa --- /dev/null +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialogFragment.java @@ -0,0 +1,120 @@ +/* + * Copyright (C) 2015 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.android.material.bottomsheet; + +import android.annotation.SuppressLint; +import android.app.Dialog; +import android.os.Bundle; +import androidx.appcompat.app.AppCompatDialogFragment; +import android.view.View; +import androidx.annotation.LayoutRes; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +/** + * Modal bottom sheet. This is a version of {@link androidx.fragment.app.DialogFragment} that shows + * a bottom sheet using {@link BackportBottomSheetDialog} instead of a floating dialog. + */ +public class BackportBottomSheetDialogFragment extends AppCompatDialogFragment { + + /** + * Tracks if we are waiting for a dismissAllowingStateLoss or a regular dismiss once the + * BottomSheet is hidden and onStateChanged() is called. + */ + private boolean waitingForDismissAllowingStateLoss; + + public BackportBottomSheetDialogFragment() {} + + @SuppressLint("ValidFragment") + public BackportBottomSheetDialogFragment(@LayoutRes int contentLayoutId) { + super(contentLayoutId); + } + + @NonNull + @Override + public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) { + return new BackportBottomSheetDialog(getContext(), getTheme()); + } + + @Override + public void dismiss() { + if (!tryDismissWithAnimation(false)) { + super.dismiss(); + } + } + + @Override + public void dismissAllowingStateLoss() { + if (!tryDismissWithAnimation(true)) { + super.dismissAllowingStateLoss(); + } + } + + /** + * Tries to dismiss the dialog fragment with the bottom sheet animation. Returns true if possible, + * false otherwise. + */ + private boolean tryDismissWithAnimation(boolean allowingStateLoss) { + Dialog baseDialog = getDialog(); + if (baseDialog instanceof BackportBottomSheetDialog) { + BackportBottomSheetDialog dialog = (BackportBottomSheetDialog) baseDialog; + BackportBottomSheetBehavior behavior = dialog.getBehavior(); + if (behavior.isHideable() && dialog.getDismissWithAnimation()) { + dismissWithAnimation(behavior, allowingStateLoss); + return true; + } + } + + return false; + } + + private void dismissWithAnimation( + @NonNull BackportBottomSheetBehavior behavior, boolean allowingStateLoss) { + waitingForDismissAllowingStateLoss = allowingStateLoss; + + if (behavior.getState() == BackportBottomSheetBehavior.STATE_HIDDEN) { + dismissAfterAnimation(); + } else { + if (getDialog() instanceof BackportBottomSheetDialog) { + ((BackportBottomSheetDialog) getDialog()).removeDefaultCallback(); + } + behavior.addBottomSheetCallback(new BottomSheetDismissCallback()); + behavior.setState(BackportBottomSheetBehavior.STATE_HIDDEN); + } + } + + private void dismissAfterAnimation() { + if (waitingForDismissAllowingStateLoss) { + super.dismissAllowingStateLoss(); + } else { + super.dismiss(); + } + } + + private class BottomSheetDismissCallback extends BackportBottomSheetBehavior.BottomSheetCallback { + + @Override + public void onStateChanged(@NonNull View bottomSheet, int newState) { + if (newState == BackportBottomSheetBehavior.STATE_HIDDEN) { + dismissAfterAnimation(); + } + } + + @Override + public void onSlide(@NonNull View bottomSheet, float slideOffset) {} + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt index e3087d42f..8abffb38f 100644 --- a/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/ui/ViewBindingBottomSheetDialogFragment.kt @@ -20,25 +20,18 @@ package org.oxycblt.auxio.ui import android.content.Context import android.os.Bundle -import android.util.AttributeSet import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import android.widget.FrameLayout import androidx.annotation.StyleRes import androidx.fragment.app.DialogFragment import androidx.viewbinding.ViewBinding -import com.google.android.material.bottomsheet.BottomSheetBehavior -import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.bottomsheet.BackportBottomSheetBehavior +import com.google.android.material.bottomsheet.BackportBottomSheetDialog +import com.google.android.material.bottomsheet.BackportBottomSheetDialogFragment import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import java.lang.reflect.Field -import java.lang.reflect.Method -import org.oxycblt.auxio.util.coordinatorLayoutBehavior import org.oxycblt.auxio.util.getDimenPixels -import org.oxycblt.auxio.util.lazyReflectedField -import org.oxycblt.auxio.util.lazyReflectedMethod import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.systemBarInsetsCompat import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -48,10 +41,10 @@ import org.oxycblt.auxio.util.unlikelyToBeNull * @author Alexander Capehart (OxygenCobalt) */ abstract class ViewBindingBottomSheetDialogFragment : - BottomSheetDialogFragment() { + BackportBottomSheetDialogFragment() { private var _binding: VB? = null - override fun onCreateDialog(savedInstanceState: Bundle?): BottomSheetDialog = + override fun onCreateDialog(savedInstanceState: Bundle?): BackportBottomSheetDialog = TweakedBottomSheetDialog(requireContext(), theme) /** @@ -100,10 +93,7 @@ abstract class ViewBindingBottomSheetDialogFragment : inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle? - ): View { - val root = onCreateBinding(inflater).also { _binding = it }.root - return EdgeToEdgeFixWrapperLayout(root.context).apply { addView(root) } - } + ) = onCreateBinding(inflater).also { _binding = it }.root final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) @@ -121,7 +111,8 @@ abstract class ViewBindingBottomSheetDialogFragment : private inner class TweakedBottomSheetDialog @JvmOverloads - constructor(context: Context, @StyleRes theme: Int = 0) : BottomSheetDialog(context, theme) { + constructor(context: Context, @StyleRes theme: Int = 0) : + BackportBottomSheetDialog(context, theme) { private var avoidUnusableCollapsedState = false override fun onCreate(savedInstanceState: Bundle?) { @@ -141,47 +132,8 @@ abstract class ViewBindingBottomSheetDialogFragment : super.onStart() if (avoidUnusableCollapsedState) { // skipCollapsed isn't enough, also need to immediately snap to expanded state. - behavior.state = BottomSheetBehavior.STATE_EXPANDED + behavior.state = BackportBottomSheetBehavior.STATE_EXPANDED } } } - - private class EdgeToEdgeFixWrapperLayout - @JvmOverloads - constructor(context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0) : - FrameLayout(context, attrs, defStyleAttr) { - override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { - super.onLayout(changed, left, top, right, bottom) - // BottomSheetBehavior's normal window inset behavior is awful. It doesn't - // follow true edge-to-edge and instead just blindly pads the bottom part of - // the view, causing visual clipping. We can turn it off, but that throws - // of the peek height calculation and results in a collapsed state that only - // expands a few pixels (specifically the size of the bottom inset) into an - // expanded state. So, ideally we would just vendor and eliminate the padding - // changes entirely, but due to layout dependencies that requires vendoring - // both BottomSheetDialog and BottomSheetDialogFragment, which I generally - // don't want to do. Instead, we deliberately clobber the window insets listener - // of our bottom sheet and only re-implement the update of the cached inset - // variables and the peek height update. This way, the peek height calculation - // remains consistent and the top inset animation continues to work correctly - // without the other absurd edge-to-edge behaviors. - // TODO: Do a fix for this upstream - (parent as View).setOnApplyWindowInsetsListener { v, insets -> - val bsb = v.coordinatorLayoutBehavior as BottomSheetBehavior - BSB_INSET_TOP_FIELD.set(bsb, insets.systemBarInsetsCompat.top) - BSB_INSET_BOTTOM_FIELD.set(bsb, insets.systemBarInsetsCompat.bottom) - BSB_UPDATE_PEEK_HEIGHT_METHOD.invoke(bsb, false) - insets - } - } - - private companion object { - val BSB_INSET_TOP_FIELD: Field by - lazyReflectedField(BottomSheetBehavior::class, "insetTop") - val BSB_INSET_BOTTOM_FIELD: Field by - lazyReflectedField(BottomSheetBehavior::class, "insetBottom") - val BSB_UPDATE_PEEK_HEIGHT_METHOD: Method by - lazyReflectedMethod(BottomSheetBehavior::class, "updatePeekHeight", Boolean::class) - } - } } diff --git a/app/src/main/res/layout/design_bottom_sheet_dialog.xml b/app/src/main/res/layout/design_bottom_sheet_dialog.xml new file mode 100644 index 000000000..bb70eccbb --- /dev/null +++ b/app/src/main/res/layout/design_bottom_sheet_dialog.xml @@ -0,0 +1,51 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/styles_ui.xml b/app/src/main/res/values/styles_ui.xml index 18e87da49..5106e12b9 100644 --- a/app/src/main/res/values/styles_ui.xml +++ b/app/src/main/res/values/styles_ui.xml @@ -32,11 +32,19 @@ From 449ec7cecd813b5b4de5835c8c8c14b2f6fd0987 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 17:32:26 -0600 Subject: [PATCH 11/25] ui: fix gap in landscape bottom sheet dialog Apparently a second-order effect of the prior fix since the insetTop value would now shift the dialog downwards unneccessarily. --- .../material/bottomsheet/BackportBottomSheetBehavior.java | 5 ++++- .../material/bottomsheet/BackportBottomSheetDialog.java | 2 -- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java index f9e8edb42..577036ec4 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetBehavior.java @@ -1752,7 +1752,10 @@ public class BackportBottomSheetBehavior extends CoordinatorLayo Insets mandatoryGestureInsets = insets.getInsets(WindowInsetsCompat.Type.mandatorySystemGestures()); - insetTop = systemBarInsets.top; + // MODIFICATION: Fix second order change of edge-to-edge fix where dialogs will not + // use the nice-looking inset animation and instead blindly shift themselves downwards. + // insetTop = systemBarInsets.top; + // MODIFICATION: Fix awful assumption that clients handling edge-to-edge by themselves // don't need peek height adjustments (Despite the fact that they still likely padding // the view, just without clipping anything) diff --git a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java index af5cc64bf..060fe04d2 100644 --- a/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java +++ b/app/src/main/java/com/google/android/material/bottomsheet/BackportBottomSheetDialog.java @@ -53,8 +53,6 @@ import com.google.android.material.internal.EdgeToEdgeUtils; import com.google.android.material.motion.MaterialBackOrchestrator; import com.google.android.material.shape.MaterialShapeDrawable; -import org.checkerframework.common.subtyping.qual.Bottom; - /** * Base class for {@link android.app.Dialog}s styled as a bottom sheet. * From f400aa513c42999c240c015bcdd7bce4b84536a1 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:20:21 -0600 Subject: [PATCH 12/25] ui: mitigate navigation desync bug This thing reared it's ugly head again during 3.2.0 testing. I think I've found a terrible but probably functional workaround for it. Start using it. --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c86037eb1..6747acb65 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -148,6 +148,14 @@ class MainFragment : } } + // Workaround for a bug where fast navigation ends up desynchronizing the current + // destination in the main navigation graph. + findNavController().apply { + findDestination(R.id.main_fragment)?.let { + currentBackStackEntry?.destination = it + } + } + // --- VIEWMODEL SETUP --- collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) From 80268498560aded7cc8a3d053d00e32ee2f58029 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:41:19 -0600 Subject: [PATCH 13/25] all: cleanup --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 4 +--- app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt | 1 - app/src/main/res/values-ar-rIQ/strings.xml | 2 -- app/src/main/res/values-ar/strings.xml | 2 -- app/src/main/res/values-be/strings.xml | 2 -- app/src/main/res/values-cs/strings.xml | 2 -- app/src/main/res/values-de/strings.xml | 2 -- app/src/main/res/values-el/strings.xml | 2 -- app/src/main/res/values-es/strings.xml | 2 -- app/src/main/res/values-fi/strings.xml | 2 -- app/src/main/res/values-fr/strings.xml | 2 -- app/src/main/res/values-gl/strings.xml | 2 -- app/src/main/res/values-hi/strings.xml | 2 -- app/src/main/res/values-hr/strings.xml | 2 -- app/src/main/res/values-hu/strings.xml | 2 -- app/src/main/res/values-in/strings.xml | 2 -- app/src/main/res/values-it/strings.xml | 2 -- app/src/main/res/values-iw/strings.xml | 2 -- app/src/main/res/values-ja/strings.xml | 2 -- app/src/main/res/values-ko/strings.xml | 2 -- app/src/main/res/values-lt/strings.xml | 2 -- app/src/main/res/values-ml/strings.xml | 1 - app/src/main/res/values-nb-rNO/strings.xml | 2 -- app/src/main/res/values-nl/strings.xml | 2 -- app/src/main/res/values-pa/strings.xml | 2 -- app/src/main/res/values-pl/strings.xml | 2 -- app/src/main/res/values-pt-rBR/strings.xml | 2 -- app/src/main/res/values-pt-rPT/strings.xml | 2 -- app/src/main/res/values-ro/strings.xml | 2 -- app/src/main/res/values-ru/strings.xml | 2 -- app/src/main/res/values-sv/strings.xml | 2 -- app/src/main/res/values-tr/strings.xml | 2 -- app/src/main/res/values-uk/strings.xml | 2 -- app/src/main/res/values-zh-rCN/strings.xml | 2 -- app/src/main/res/values/strings.xml | 2 -- 35 files changed, 1 insertion(+), 69 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 6747acb65..2163721de 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -151,9 +151,7 @@ class MainFragment : // Workaround for a bug where fast navigation ends up desynchronizing the current // destination in the main navigation graph. findNavController().apply { - findDestination(R.id.main_fragment)?.let { - currentBackStackEntry?.destination = it - } + findDestination(R.id.main_fragment)?.let { currentBackStackEntry?.destination = it } } // --- VIEWMODEL SETUP --- diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index e3f2a0abb..d5263da7b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -29,7 +29,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import kotlinx.coroutines.yield diff --git a/app/src/main/res/values-ar-rIQ/strings.xml b/app/src/main/res/values-ar-rIQ/strings.xml index 9dc9abb35..97794174f 100644 --- a/app/src/main/res/values-ar-rIQ/strings.xml +++ b/app/src/main/res/values-ar-rIQ/strings.xml @@ -145,8 +145,6 @@ الحجم المسار إحصائيات المكتبة - تشغي الاغاني المحددة بترتيب عشوائي - تشغيل الموسيقى المحددة معدل البت اسم الملف تجميع مباشر diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index fcbf1ee96..ca6f6fafd 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -14,7 +14,6 @@ حذف قائمة التشغيل؟ بحث تصفية - تشغيل المختارة تشغيل التالي إضافة للطابور إضافة لقائمة التشغيل @@ -28,7 +27,6 @@ قائمة تشغيل جديدة إعادة تسمية قائمة التشغيل تعديل - خلط المختارة طابور خلط اذهب للفنان diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 196cd5357..106d70d59 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -72,7 +72,6 @@ Зараз іграе Гуляць Ператасаваць - Выбрана перамешванне Памер Ператасаваць Адмяніць @@ -81,7 +80,6 @@ Гуляць далей Дадаць у чаргу Эквалайзер - Гуляць выбрана Чарга Перайсці да альбома Перайсці да выканаўцы diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 35c984fed..9e55b50e3 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -260,9 +260,7 @@ Nepodařilo se vymazat stav Znovu najít hudbu Vymazat mezipaměť značek a znovu úplně znovu načíst hudební knihovnu (pomalejší, ale úplnější) - Přehrát vybrané Vybráno %d - Náhodně přehrát vybrané Přehrát z žánru Wiki %1$s, %2$s diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index eec9aa65e..d30ef1144 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -251,8 +251,6 @@ Zustand konnte nicht gespeichert werden Music neu scannen Tag-Cache leeren und die Musik-Bibliothek vollständig neu laden (langsamer, aber vollständiger) - Ausgewählte abspielen - Ausgewählte zufällig abspielen %d ausgewählt Vom Genre abspielen Wiki diff --git a/app/src/main/res/values-el/strings.xml b/app/src/main/res/values-el/strings.xml index 0e7da1415..b5730db8e 100644 --- a/app/src/main/res/values-el/strings.xml +++ b/app/src/main/res/values-el/strings.xml @@ -135,8 +135,6 @@ Σύνθεση ζωντανών κομματιών Σύνθεση ρεμίξ Ισοσταθμιστής - Αναπαραγωγή επιλεγμένου - Τυχαία αναπαραγωγή επιλεγμένων Ενιαία κυκλοφορία Σινγκλ \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 70b8b49fc..0712780dd 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -255,9 +255,7 @@ No se puede borrar el estado Borrar la caché de las etiquetas y recargar completamente la biblioteca musical (más lento, pero más completo) Volver a escanear la música - Nodo aleatorio seleccionado %d seleccionado - Reproducir los seleccionados Reproducir desde el género Wiki %1$s, %2$s diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 1f222e0a7..55677cbf1 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -37,7 +37,6 @@ Nyt toistetaan Taajuuskorjain Toista - Toisto valittu Sekoita Jono Lisää jonoon @@ -219,7 +218,6 @@ ReplayGain Suosi albumia ReplayGain-strategia - Sekoitus valittu Automaattinen uudelleenlataus Automaattitoisto kuulokkeilla Aloita aina toisto, kun kuulokkeet yhdistetään (ei välttämättä toimi kaikilla laitteilla) diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 822484268..b063a1730 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -134,8 +134,6 @@ Genre inconnu Dynamique Cyan - Lecture aléatoire sélectionnée - Réinitialiser Aucun dossier Supprimer le dossier Artiste inconnu diff --git a/app/src/main/res/values-gl/strings.xml b/app/src/main/res/values-gl/strings.xml index 5c3288e6a..faf684102 100644 --- a/app/src/main/res/values-gl/strings.xml +++ b/app/src/main/res/values-gl/strings.xml @@ -49,7 +49,6 @@ Reproducir Mezcla Reproducir seguinte - Reproducir a selección Cola Engadir á cola Excluir o que non é música @@ -126,7 +125,6 @@ Ascendente Descendente Ecualizador - Aleatorio seleccionado Frecuencia de mostraxe Acerca de Monitorizando cambios na túa biblioteca… diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml index f4e65894c..97ffbd5ca 100644 --- a/app/src/main/res/values-hi/strings.xml +++ b/app/src/main/res/values-hi/strings.xml @@ -100,8 +100,6 @@ %s हटाएँ\? इसे पूर्ववत नहीं किया जा सकता। लोड किए गए गाने: %d अवरोही - चयनित चलाएँ - फेरबदल का चयन किया गया स्थिति साफ की गई स्थिति सहेजी गई लायब्रेरी टैब की दृश्यता और क्रम बदलें diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 2cd6aac4f..fecd2b6f2 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -247,8 +247,6 @@ Ponovo pretraži glazbu Izbriši predmemoriju oznaka i ponovo potpuno učitaj glazbenu biblioteku (sporije, ali potpunije) Odabrano: %d - Promiješaj odabrane - Reproduciraj odabrane Reproduciraj iz žanra Wiki %1$s, %2$s diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 179bb1a44..cec9b922a 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -75,7 +75,6 @@ Név Dátum Csökkenő - Kiválasztott lejátszása Új lejátszólista Ismeretlen műfaj Ugrás a következő dalra @@ -142,7 +141,6 @@ Helyezze át ezt a dalt %s előadó fotója Teljes időtartam: %s - Kiválasztottak keverése UI vezérlők és viselkedés testreszabása A könyvtárfülek láthatóságának és sorrendjének módosítása A tétel részleteiből történő lejátszáskor diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 525ed9115..21f166a7e 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -183,8 +183,6 @@ Muat ulang otomatis Selalu muat ulang pustaka musik saat terjadi perubahan (membutuhkan notifikasi tetap) Perilaku - Putar yang dipilih - Acak yang dipilih Mode bundar Aktifkan sudut yang bundar pada elemen UI tambahan (mewajibkan sampul album bersudut bundar) Koma (,) diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 9e565a356..1a4bf8938 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -255,8 +255,6 @@ Impossibile salvare Svuota la cache dei tag e ricarica completamente la libreria musicale (più lento, ma più completo) Impossibile svuotare - Mescola selezionati - Riproduci selezionati %d selezionati Riproduci dal genere Wiki diff --git a/app/src/main/res/values-iw/strings.xml b/app/src/main/res/values-iw/strings.xml index ab1393f98..e85f5a07e 100644 --- a/app/src/main/res/values-iw/strings.xml +++ b/app/src/main/res/values-iw/strings.xml @@ -40,9 +40,7 @@ מושמע כעת איקוולייזר ניגון - ניגון נבחרים ערבוב - ערבוב נבחרים ניגון הבא הוספה לתור מעבר לאלבום diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 21849864b..7c011f04e 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -63,7 +63,6 @@ 降順 再生 シャフル - 選択曲をシャフル 次に再生 再生待ちに追加 オーディオ形式 @@ -178,7 +177,6 @@ リミックスEP リミックス ジャンル - 選択曲を再生 プロパティを見る 再生待ち ライブラリ統計 diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 7d54c121c..70aa17ba9 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -251,8 +251,6 @@ %d 아티스트 태그 정보를 지우고 음악 라이브러리를 재생성함(느림, 더 완전함) - 선택한 재생 - 선택한 셔플 %d 선택됨 재설정 위키 diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index e225f4250..cfcbccdcf 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -249,8 +249,6 @@ Perskenuoti muziką Išvalyti žymių talpyklą ir pilnai perkrauti muzikos biblioteką (lėčiau, bet labiau išbaigta) %d pasirinkta - Pasirinktas grojimas - Pasirinktas maišymas Groti iš žanro Viki %1$s, %2$s diff --git a/app/src/main/res/values-ml/strings.xml b/app/src/main/res/values-ml/strings.xml index 82f0ffe92..f1ae5be53 100644 --- a/app/src/main/res/values-ml/strings.xml +++ b/app/src/main/res/values-ml/strings.xml @@ -1,6 +1,5 @@ - തിരഞ്ഞെടുത്തു കളിക്കുക രക്ഷിക്കുക പെരുമാറ്റം ഉള്ളടക്കം diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 5913008c0..c276bd0bd 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -43,7 +43,6 @@ Sporantall Spill neste - Omstokking valgt Bibliotek Kunne ikke lagre tilstand @@ -275,7 +274,6 @@ Tonekontroll Endre gjentagelsesmodus Spill - Spill valgte Lagre Laster inn musikkbiblioteket ditt … Ved avspilling fra bibliotek diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4d29de5df..c0ecc5905 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -209,7 +209,6 @@ Toon alleen artiesten die rechtstreeks op een album worden genoemd (werkt het beste op goed getagde bibliotheken) Sorteer namen die beginnen met cijfers of woorden zoals \"de\" correct (werkt het beste met Engelstalige muziek) Stop met afspelen - Geselecteerd afspelen Uw muziekbibliotheek wordt geladen… Gedrag Remix compilatie @@ -279,7 +278,6 @@ %d artiest %d artiesten - Shuffle geselecteerd Intelligent sorteren Verschijnt op Afspeellijsten diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 8e43996db..175c77cdb 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -49,7 +49,6 @@ ਇਕੋਲਾਈਜ਼ਰ ਚਲਾਓ ਸ਼ਫਲ - ਸ਼ਫਲ ਚੁਣਿਆ ਗਿਆ ਕਤਾਰ ਅਗਲਾ ਚਲਾਓ ਕਤਾਰ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ @@ -76,7 +75,6 @@ ਖੋਜੋ ਗੀਤ ਦੀ ਗਿਣਤੀ ਘਟਦੇ ਹੋਏ - ਚੁਣਿਆ ਹੋਇਆ ਚਲਾਓ ਕਲਾਕਾਰ \'ਤੇ ਜਾਓ ਫਾਈਲ ਦਾ ਨਾਮ ਬਿੱਟ ਰੇਟ diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index e813bd244..737838fe6 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -255,8 +255,6 @@ Stan odtwarzania Obrazy Zarządzaj dźwiękiem i odtwarzaniem muzyki - Odtwórz wybrane - Wybrane losowo Wybrano %d Wyrównanie głośności (ReplayGain) Resetuj diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index ec43dcdc8..ef6191ce3 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -253,8 +253,6 @@ Não foi possível salvar a lista Ocultar artistas colaboradores Mostrar apenas artistas que foram creditados diretamente no álbum (funciona melhor em músicas com metadados completos) - Tocar selecionada(s) - Aleatorizar selecionadas %d Selecionadas Wiki Redefinir diff --git a/app/src/main/res/values-pt-rPT/strings.xml b/app/src/main/res/values-pt-rPT/strings.xml index 8140c3ad1..3a297776d 100644 --- a/app/src/main/res/values-pt-rPT/strings.xml +++ b/app/src/main/res/values-pt-rPT/strings.xml @@ -217,8 +217,6 @@ A carregar a sua biblioteca de músicas… (%1$d/%2$d) Retroceder antes de voltar Parar reprodução - Reproduzir selecionada(s) - Aleatorizar selecionadas Caminho principal Ativar cantos arredondados em elementos adicionais da interface do utilizador (requer que as capas dos álbuns sejam arredondadas) %d Selecionadas diff --git a/app/src/main/res/values-ro/strings.xml b/app/src/main/res/values-ro/strings.xml index 793b4ec7d..4351fd565 100644 --- a/app/src/main/res/values-ro/strings.xml +++ b/app/src/main/res/values-ro/strings.xml @@ -134,11 +134,9 @@ Afişa Utilizați o temă întunecată pur-negru Coperți rotunjite ale albumelor - Redare selecție Listă de redare Liste de redare Descrescător - Selecție aleatorie aleasă Treceți la următoarea Redă de la artist Redă din genul diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index 03703707b..6e93fb54f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -258,8 +258,6 @@ Не удалось очистить состояние Не удалось сохранить состояние Предупреждение: Использование этой настройки может привести к тому, что некоторые теги будут неправильно интерпретироваться как имеющие несколько значений. Вы можете решить эту проблему, добавив к нежелательным символам-разделителям обратную косую черту (\\). - Воспроизвести выбранное - Перемешать выбранное %d выбрано Вики Сбросить diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index e9547d169..60f9e5c97 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -43,7 +43,6 @@ Nu spelar Utjämnare Spela - Spela utvalda Blanda Spela nästa @@ -99,7 +98,6 @@ Alla Disk Sortera - Blanda utvalda Lägg till kö Filnamn Lägg till diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 86915531e..559ef2336 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -196,8 +196,6 @@ Tekliler Tekli Karışık kaset - Seçileni çal - Karışık seçildi Canlı derleme Remiks derlemeler Ekolayzır diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index fa3310d1c..ddeb997f0 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -54,7 +54,6 @@ %d альбомів %d альбомів - Перемішати вибране Ім\'я файлу Формат Добре @@ -83,7 +82,6 @@ Шлях до каталогу Екран Рік - Відтворити вибране Обкладинки альбомів Приховати співавторів Вимкнено diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5edb4fc5e..c5e17d0c4 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -249,8 +249,6 @@ 无法清除状态 重新扫描音乐 清除标签缓存并完全重新加载音乐库(更慢,但更完整) - 随机播放所选 - 播放所选 选中了 %d 首 按流派播放 Wiki diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 852e9eeae..31700900b 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -114,9 +114,7 @@ Now playing Equalizer Play - Play selected Shuffle - Shuffle selected Queue Play next From 12bc46e210f7456cbc72bb666d50beb488590ece Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:42:48 -0600 Subject: [PATCH 14/25] build: bump to 3.2.0 Bump Auxio to version 3.2.0 (35). --- CHANGELOG.md | 5 +---- README.md | 4 ++-- app/build.gradle | 4 ++-- .../metadata/android/en-US/changelogs/35.txt | 2 ++ .../en-US/images/phoneScreenshots/shot5.png | Bin 83876 -> 95043 bytes 5 files changed, 7 insertions(+), 8 deletions(-) create mode 100644 fastlane/metadata/android/en-US/changelogs/35.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index 81d1a81e7..8fd76d375 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## dev +## 3.2.0 #### What's New - Item and sort menus have been refreshed with a cleaner look @@ -16,9 +16,6 @@ aspect ratio setting #### What's Fixed - Playlist detail view now respects playback settings -#### Dev/Meta -- Unified navigation graph - ## 3.1.4 #### What's Fixed diff --git a/README.md b/README.md index a324ffd3c..8b16f36ed 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,8 @@

Auxio

A simple, rational music player for android.

- - Latest Version + + Latest Version Releases diff --git a/app/build.gradle b/app/build.gradle index efac4f42e..6ab20ef92 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -21,8 +21,8 @@ android { defaultConfig { applicationId namespace - versionName "3.1.0" - versionCode 34 + versionName "3.2.0" + versionCode 35 minSdk 24 targetSdk 34 diff --git a/fastlane/metadata/android/en-US/changelogs/35.txt b/fastlane/metadata/android/en-US/changelogs/35.txt new file mode 100644 index 000000000..e3514f6dc --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/35.txt @@ -0,0 +1,2 @@ +Auxio 3.2.0 refreshes the item management experience, with a new menu UI and playback options. +For more information, see https://github.com/OxygenCobalt/Auxio/releases/tag/v3.2.0. \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png b/fastlane/metadata/android/en-US/images/phoneScreenshots/shot5.png index 8fcac190a493a94397413a59818e23838fe26f39..1ed0f49ec4322ba3748ae74728c17821e380add1 100644 GIT binary patch literal 95043 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 of6pS=TT?yc|I0=dg`r8lS-m2vGp13EL>j{pDw literal 83876 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 DEDcez From 58989f8568bb4ce03202b7adb534d219bb209f15 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Wed, 16 Aug 2023 19:58:38 -0600 Subject: [PATCH 15/25] build: revert to navigation 2.5.3 I would have to duplicate the workaround for every fragment in the project. Easier to just roll back until it's fixed. --- app/src/main/java/org/oxycblt/auxio/MainFragment.kt | 6 ------ build.gradle | 2 +- 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index 2163721de..c86037eb1 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -148,12 +148,6 @@ class MainFragment : } } - // Workaround for a bug where fast navigation ends up desynchronizing the current - // destination in the main navigation graph. - findNavController().apply { - findDestination(R.id.main_fragment)?.let { currentBackStackEntry?.destination = it } - } - // --- VIEWMODEL SETUP --- collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) diff --git a/build.gradle b/build.gradle index e5bc1dc66..8713d9839 100644 --- a/build.gradle +++ b/build.gradle @@ -1,7 +1,7 @@ buildscript { ext { kotlin_version = '1.9.0' - navigation_version = "2.7.0" + navigation_version = "2.5.3" hilt_version = '2.47' } From d297c10b0ad8f4e69742b0fe4eb48ea8e0f1174e Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:24:17 -0600 Subject: [PATCH 16/25] detail: fix crash on multi-artist navigation Caused by an unimplemented navigation branch in ArtistDetailFragment. --- .../org/oxycblt/auxio/detail/ArtistDetailFragment.kt | 12 ++++++++++-- .../auxio/music/decision/AddToPlaylistDialog.kt | 2 +- app/src/main/res/navigation/inner.xml | 3 +++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index a611af6f4..b0bc09386 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -221,8 +221,16 @@ class ArtistDetailFragment : .navigateSafe(ArtistDetailFragmentDirections.showArtist(show.artist.uid)) } } - is Show.SongArtistDecision, - is Show.AlbumArtistDecision, + is Show.SongArtistDecision -> { + logD("Navigating to artist choices for ${show.song}") + findNavController() + .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.song.uid)) + } + is Show.AlbumArtistDecision -> { + logD("Navigating to artist choices for ${show.album}") + findNavController() + .navigateSafe(ArtistDetailFragmentDirections.showArtistChoices(show.album.uid)) + } is Show.GenreDetails, is Show.PlaylistDetails -> { error("Unexpected show command $show") diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index 57e2bdf7c..7e7aec2ac 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -76,7 +76,7 @@ class AddToPlaylistDialog : // --- VIEWMODEL SETUP --- pickerModel.setSongsToAdd(args.songUids) - collect(musicModel.playlistDecision.flow, ::handleDecision) + musicModel.playlistDecision.consume() collectImmediately(pickerModel.currentSongsToAdd, ::updatePendingSongs) collectImmediately(pickerModel.playlistAddChoices, ::updatePlaylistChoices) } diff --git a/app/src/main/res/navigation/inner.xml b/app/src/main/res/navigation/inner.xml index b1f834f95..a974b3360 100644 --- a/app/src/main/res/navigation/inner.xml +++ b/app/src/main/res/navigation/inner.xml @@ -263,6 +263,9 @@ + From 20c34fd888934562d49374327fcd5c49faef554c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:39:05 -0600 Subject: [PATCH 17/25] music: fix crash on adding to new playlist Apparently dialog fragments do not change the state of the fragment it is overlaid on, resulting in it still having active StateFlow collectors that will intercept new playlist requests before AddToPlaylistDialog. Once again sharing StateFlows across views has bit me. In the future I may try to preserve the navigation idioms by not stacking NewPlaylistDialog on AddToPlaylistDialog and instead simply swap them out. I think this would also be better design too (It's not like I'm allowing other decision dialogs to be exitable back to their prior dialog). --- .../music/decision/AddToPlaylistDialog.kt | 32 ++++++------------- 1 file changed, 10 insertions(+), 22 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt index 7e7aec2ac..51dcfcf6c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/decision/AddToPlaylistDialog.kt @@ -32,10 +32,8 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicChoicesBinding import org.oxycblt.auxio.list.ClickableListListener import org.oxycblt.auxio.music.MusicViewModel -import org.oxycblt.auxio.music.PlaylistDecision import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.ui.ViewBindingMaterialDialogFragment -import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.navigateSafe @@ -93,26 +91,16 @@ class AddToPlaylistDialog : } override fun onNewPlaylist() { - musicModel.createPlaylist(songs = pickerModel.currentSongsToAdd.value ?: return) - } - - private fun handleDecision(decision: PlaylistDecision?) { - when (decision) { - is PlaylistDecision.Add -> { - logD("Navigated to playlist add dialog") - musicModel.playlistDecision.consume() - } - is PlaylistDecision.New -> { - logD("Navigating to new playlist dialog") - findNavController() - .navigateSafe( - AddToPlaylistDialogDirections.newPlaylist( - decision.songs.map { it.uid }.toTypedArray())) - } - is PlaylistDecision.Rename, - is PlaylistDecision.Delete -> error("Unexpected decision $decision") - null -> {} - } + // TODO: This is a temporary fix. Eventually I want to make this navigate away and + // instead have primary fragments launch navigation to the new playlist dialog. + // This should be better design (dialog layering is uh... probably not good) and + // preserves the existing navigation system. + // I could also roll some kind of new playlist textbox into the dialog, but that's + // a lot harder. + val songs = pickerModel.currentSongsToAdd.value ?: return + findNavController() + .navigateSafe( + AddToPlaylistDialogDirections.newPlaylist(songs.map { it.uid }.toTypedArray())) } private fun updatePendingSongs(songs: List?) { From d0b34a14e4f7a4c6c460c9e7d6e942c293f6c2c4 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Thu, 17 Aug 2023 20:42:12 -0600 Subject: [PATCH 18/25] playback: fix broken item navigation Caused yet again by sharing StateFlows leading to a strange out-of-order collector notification, which then allows detail fragments to consume item navigation requests before the playback panel can even get them. SharedFlow doesn't help here, so we are just forced to move this to MainFragment which does not have this issue for some reason. --- .../java/org/oxycblt/auxio/MainFragment.kt | 21 ++++++++++++++++ .../auxio/playback/PlaybackPanelFragment.kt | 24 +------------------ 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt index c86037eb1..297bad95c 100644 --- a/app/src/main/java/org/oxycblt/auxio/MainFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/MainFragment.kt @@ -38,6 +38,7 @@ import kotlin.math.max import kotlin.math.min import org.oxycblt.auxio.databinding.FragmentMainBinding import org.oxycblt.auxio.detail.DetailViewModel +import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.Outer import org.oxycblt.auxio.list.ListViewModel @@ -49,6 +50,7 @@ import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.queue.QueueBottomSheetBehavior import org.oxycblt.auxio.ui.DialogAwareNavigationListener import org.oxycblt.auxio.ui.ViewBindingFragment +import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.context import org.oxycblt.auxio.util.coordinatorLayoutBehavior @@ -149,6 +151,11 @@ class MainFragment : } // --- VIEWMODEL SETUP --- + // This has to be done here instead of the playback panel to make sure that it's prioritized + // by StateFlow over any detail fragment. + // FIXME: This is a consequence of sharing events across several consumers. There has to be + // a better way of doing this. + collect(detailModel.toShow.flow, ::handleShow) collectImmediately(detailModel.editedPlaylist, detailBackCallback::invalidateEnabled) collectImmediately(homeModel.showOuter.flow, ::handleShowOuter) collectImmediately(listModel.selected, selectionBackCallback::invalidateEnabled) @@ -287,6 +294,20 @@ class MainFragment : return true } + private fun handleShow(show: Show?) { + when (show) { + is Show.SongAlbumDetails, + is Show.ArtistDetails, + is Show.AlbumDetails -> playbackModel.openMain() + is Show.SongDetails, + is Show.SongArtistDecision, + is Show.AlbumArtistDecision, + is Show.GenreDetails, + is Show.PlaylistDetails, + null -> {} + } + } + private fun handleShowOuter(outer: Outer?) { val directions = when (outer) { diff --git a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt index b43b3330e..b7228d508 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/PlaybackPanelFragment.kt @@ -33,7 +33,6 @@ import dagger.hilt.android.AndroidEntryPoint import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.FragmentPlaybackPanelBinding import org.oxycblt.auxio.detail.DetailViewModel -import org.oxycblt.auxio.detail.Show import org.oxycblt.auxio.list.ListViewModel import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song @@ -41,7 +40,6 @@ import org.oxycblt.auxio.music.resolveNames import org.oxycblt.auxio.playback.state.RepeatMode import org.oxycblt.auxio.playback.ui.StyledSeekBar import org.oxycblt.auxio.ui.ViewBindingFragment -import org.oxycblt.auxio.util.collect import org.oxycblt.auxio.util.collectImmediately import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.overrideOnOverflowMenuClick @@ -105,12 +103,7 @@ class PlaybackPanelFragment : // respective item. binding.playbackSong.apply { isSelected = true - setOnClickListener { - playbackModel.song.value?.let { - detailModel.showAlbum(it) - playbackModel.openMain() - } - } + setOnClickListener { playbackModel.song.value?.let(detailModel::showAlbum) } } binding.playbackArtist.apply { isSelected = true @@ -138,7 +131,6 @@ class PlaybackPanelFragment : collectImmediately(playbackModel.repeatMode, ::updateRepeat) collectImmediately(playbackModel.isPlaying, ::updatePlaying) collectImmediately(playbackModel.isShuffled, ::updateShuffled) - collect(detailModel.toShow.flow, ::handleShow) } override fun onDestroyBinding(binding: FragmentPlaybackPanelBinding) { @@ -220,20 +212,6 @@ class PlaybackPanelFragment : requireBinding().playbackShuffle.isActivated = isShuffled } - private fun handleShow(show: Show?) { - when (show) { - is Show.SongAlbumDetails, - is Show.ArtistDetails, - is Show.AlbumDetails -> playbackModel.openMain() - is Show.SongDetails, - is Show.SongArtistDecision, - is Show.AlbumArtistDecision, - is Show.GenreDetails, - is Show.PlaylistDetails, - null -> {} - } - } - private fun navigateToCurrentArtist() { playbackModel.song.value?.let(detailModel::showArtist) } From 2c2bd79ae2238dd599f9706cc0905556f8a4a159 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 11:47:50 -0600 Subject: [PATCH 19/25] music: trim simple names after punct removal Trim simple names once punctuation has been removed. This prevents situations where album names like "& Yet & Yet" (a real album by post-rock outfit Do Make Say Think) will have blank thumbs. This probably isn't the best approach in general, but nothing about the intelligent name system is a good approach. --- .../java/org/oxycblt/auxio/music/info/Name.kt | 7 +- .../org/oxycblt/auxio/music/info/DateTest.kt | 119 +----------------- .../org/oxycblt/auxio/music/info/NameTest.kt | 4 + 3 files changed, 11 insertions(+), 119 deletions(-) create mode 100644 app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index bbde5aca3..fb753f641 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -148,6 +148,8 @@ sealed interface Name : Comparable { private val collator: Collator = Collator.getInstance().apply { strength = Collator.PRIMARY } private val punctRegex by lazy { Regex("[\\p{Punct}+]") } +// TODO: Consider how you want to handle whitespace and "gaps" in names. + /** * Plain [Name.Known] implementation that is internationalization-safe. * @@ -159,7 +161,7 @@ private data class SimpleKnownName(override val raw: String, override val sort: private fun parseToken(name: String): SortToken { // Remove excess punctuation from the string, as those usually aren't considered in sorting. - val stripped = name.replace(punctRegex, "").ifEmpty { name } + val stripped = name.replace(punctRegex, "").trim().ifEmpty { name } val collationKey = collator.getCollationKey(stripped) // Always use lexicographic mode since we aren't parsing any numeric components return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) @@ -180,7 +182,8 @@ private data class IntelligentKnownName(override val raw: String, override val s // optimize it val stripped = name - // Remove excess punctuation from the string, as those u + // Remove excess punctuation from the string, as those usually aren't + // considered in sorting. .replace(punctRegex, "") .ifEmpty { name } .run { diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt index 075df1b1c..40c95bc56 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt @@ -1,119 +1,4 @@ -/* - * Copyright (c) 2023 Auxio Project - * DateTest.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - package org.oxycblt.auxio.music.info -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test - -class DateTest { - @Test - fun date_equals_varyingPrecision() { - assertTrue( - requireNotNull(Date.from("2016-08-16T00:01:02")) != - requireNotNull(Date.from("2016-08-16"))) - } - - @Test - fun date_compareTo_dates() { - val a = requireNotNull(Date.from("2016-08-16T00:01:02")) - val b = requireNotNull(Date.from("2016-09-16T00:01:02")) - assertEquals(-1, a.compareTo(b)) - } - - @Test - fun date_compareTo_times() { - val a = requireNotNull(Date.from("2016-08-16T00:02:02")) - val b = requireNotNull(Date.from("2016-08-16T00:01:02")) - assertEquals(1, a.compareTo(b)) - } - - @Test - fun date_compareTo_varyingPrecision() { - val a = requireNotNull(Date.from("2016-08-16T00:01:02")) - val b = requireNotNull(Date.from("2016-08-16")) - assertEquals( - 1, - a.compareTo(b), - ) - } - - @Test - fun date_from_values() { - assertEquals("2016", Date.from(2016).toString()) - assertEquals("2016-08-16", Date.from(2016, 8, 16).toString()) - assertEquals("2016-08-16T00:01Z", Date.from(2016, 8, 16, 0, 1).toString()) - } - - @Test - fun date_from_yearDate() { - assertEquals("2016-08-16", Date.from(20160816).toString()) - assertEquals("2016-08-16", Date.from("20160816").toString()) - } - - @Test - fun date_from_timestamp() { - assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16T00:01:02").toString()) - assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16 00:01:02").toString()) - } - - @Test - fun date_from_lesserPrecision() { - assertEquals("2016", Date.from("2016").toString()) - assertEquals("2016-08", Date.from("2016-08").toString()) - assertEquals("2016-08-16", Date.from("2016-08-16").toString()) - assertEquals("2016-08-16T00:01Z", Date.from("2016-08-16T00:01").toString()) - } - - @Test - fun date_from_wack() { - assertEquals(null, Date.from(0)) - assertEquals(null, Date.from("")) - assertEquals(null, Date.from("2016-08-16:00:01:02")) - assertEquals("2016-11", Date.from("2016-11-32 25:43:01").toString()) - } - - @Test - fun dateRange_from_correct() { - val range = - requireNotNull( - Date.Range.from( - listOf( - requireNotNull(Date.from("2016-08-16T00:01:02")), - requireNotNull(Date.from("2016-07-16")), - requireNotNull(Date.from("2014-03-12T00")), - requireNotNull(Date.from("2022-12-22T22:22:22"))))) - assertEquals("2014-03-12T00Z", range.min.toString()) - assertEquals("2022-12-22T22:22:22Z", range.max.toString()) - } - - @Test - fun dateRange_from_one() { - val range = - requireNotNull( - Date.Range.from(listOf(requireNotNull(Date.from("2016-08-16T00:01:02"))))) - assertEquals("2016-08-16T00:01:02Z", range.min.toString()) - assertEquals("2016-08-16T00:01:02Z", range.max.toString()) - } - - @Test - fun dateRange_from_none() { - assertEquals(null, Date.Range.from(listOf())) - } -} +class NameTest { +} \ No newline at end of file diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt new file mode 100644 index 000000000..40c95bc56 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -0,0 +1,4 @@ +package org.oxycblt.auxio.music.info + +class NameTest { +} \ No newline at end of file From 59e42acad901237054797a0ec2d81e846b2bb6cb Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 11:51:09 -0600 Subject: [PATCH 20/25] test: re-implement tests Reimplement the tests of music modeling, and re-enable the automatic test workflow in GH actions. I'm actually going to work on reimplementing these. --- .github/workflows/android.yml | 4 +- app/build.gradle | 2 + .../java/org/oxycblt/auxio/music/info/Name.kt | 6 +- .../java/org/oxycblt/auxio/music/FakeMusic.kt | 143 ------ .../auxio/music/FakeMusicRepository.kt | 90 ---- .../oxycblt/auxio/music/FakeMusicSettings.kt | 77 ---- .../org/oxycblt/auxio/music/MusicModeTest.kt | 32 -- .../oxycblt/auxio/music/MusicViewModelTest.kt | 133 ------ .../auxio/music/device/DeviceMusicImplTest.kt | 186 -------- .../auxio/music/device/FakeDeviceLibrary.kt | 61 --- .../org/oxycblt/auxio/music/info/DateTest.kt | 91 +++- .../org/oxycblt/auxio/music/info/DiscTest.kt | 40 +- .../org/oxycblt/auxio/music/info/NameTest.kt | 428 +++++++++++++++++- .../auxio/music/metadata/TagUtilTest.kt | 31 +- .../auxio/music/metadata/TextTagsTest.kt | 5 + .../org/oxycblt/auxio/util/TestingUtil.kt | 28 -- 16 files changed, 567 insertions(+), 790 deletions(-) delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt delete mode 100644 app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index f0aff366e..f106a3aac 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -23,8 +23,8 @@ jobs: cache: gradle - name: Grant execute permission for gradlew run: chmod +x gradlew - # - name: Test app with Gradle - # run: ./gradlew app:testDebug + - name: Test app with Gradle + run: ./gradlew app:testDebug - name: Build debug APK with Gradle run: ./gradlew app:packageDebug - name: Upload debug APK artifact diff --git a/app/build.gradle b/app/build.gradle index 6ab20ef92..45fdff5ea 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -145,8 +145,10 @@ dependencies { // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" + testImplementation "io.mockk:mockk:1.13.7" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + } spotless { diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index fb753f641..c47e561cf 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.info import android.content.Context import androidx.annotation.StringRes +import androidx.annotation.VisibleForTesting import java.text.CollationKey import java.text.Collator import org.oxycblt.auxio.music.MusicSettings @@ -54,10 +55,11 @@ sealed interface Name : Comparable { abstract val sort: String? /** A tokenized version of the name that will be compared. */ - protected abstract val sortTokens: List + @VisibleForTesting(VisibleForTesting.PROTECTED) abstract val sortTokens: List /** An individual part of a name string that can be compared intelligently. */ - protected data class SortToken(val collationKey: CollationKey, val type: Type) : + @VisibleForTesting(VisibleForTesting.PROTECTED) + data class SortToken(val collationKey: CollationKey, val type: Type) : Comparable { override fun compareTo(other: SortToken): Int { // Numeric tokens should always be lower than lexicographic tokens. diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt deleted file mode 100644 index 32d8c0df2..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusic.kt +++ /dev/null @@ -1,143 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeMusic.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music - -import android.net.Uri -import org.oxycblt.auxio.music.fs.MimeType -import org.oxycblt.auxio.music.fs.Path -import org.oxycblt.auxio.music.info.Date -import org.oxycblt.auxio.music.info.Disc -import org.oxycblt.auxio.music.info.Name -import org.oxycblt.auxio.music.info.ReleaseType - -open class FakeSong : Song { - override val name: Name - get() = throw NotImplementedError() - - override val date: Date? - get() = throw NotImplementedError() - - override val dateAdded: Long - get() = throw NotImplementedError() - - override val disc: Disc? - get() = throw NotImplementedError() - - override val genres: List - get() = throw NotImplementedError() - - override val mimeType: MimeType - get() = throw NotImplementedError() - - override val track: Int? - get() = throw NotImplementedError() - - override val path: Path - get() = throw NotImplementedError() - - override val size: Long - get() = throw NotImplementedError() - - override val uri: Uri - get() = throw NotImplementedError() - - override val album: Album - get() = throw NotImplementedError() - - override val artists: List - get() = throw NotImplementedError() - - override val durationMs: Long - get() = throw NotImplementedError() - - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeAlbum : Album { - override val name: Name - get() = throw NotImplementedError() - - override val coverUri: Uri - get() = throw NotImplementedError() - - override val dateAdded: Long - get() = throw NotImplementedError() - - override val dates: Date.Range? - get() = throw NotImplementedError() - - override val releaseType: ReleaseType - get() = throw NotImplementedError() - - override val artists: List - get() = throw NotImplementedError() - - override val durationMs: Long - get() = throw NotImplementedError() - - override val songs: List - get() = throw NotImplementedError() - - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeArtist : Artist { - override val name: Name - get() = throw NotImplementedError() - - override val albums: List - get() = throw NotImplementedError() - - override val explicitAlbums: List - get() = throw NotImplementedError() - - override val implicitAlbums: List - get() = throw NotImplementedError() - - override val genres: List - get() = throw NotImplementedError() - - override val durationMs: Long - get() = throw NotImplementedError() - - override val songs: List - get() = throw NotImplementedError() - - override val uid: Music.UID - get() = throw NotImplementedError() -} - -open class FakeGenre : Genre { - override val name: Name - get() = throw NotImplementedError() - - override val artists: List - get() = throw NotImplementedError() - - override val durationMs: Long - get() = throw NotImplementedError() - - override val songs: List - get() = throw NotImplementedError() - - override val uid: Music.UID - get() = throw NotImplementedError() -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt deleted file mode 100644 index 8c79f0e9a..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt +++ /dev/null @@ -1,90 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeMusicRepository.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music - -import kotlinx.coroutines.Job -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.user.UserLibrary - -open class FakeMusicRepository : MusicRepository { - override val indexingState: IndexingState? - get() = throw NotImplementedError() - - override val deviceLibrary: DeviceLibrary? - get() = throw NotImplementedError() - - override val userLibrary: UserLibrary? - get() = throw NotImplementedError() - - override fun addUpdateListener(listener: MusicRepository.UpdateListener) { - throw NotImplementedError() - } - - override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { - throw NotImplementedError() - } - - override fun addIndexingListener(listener: MusicRepository.IndexingListener) { - throw NotImplementedError() - } - - override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { - throw NotImplementedError() - } - - override fun registerWorker(worker: MusicRepository.IndexingWorker) { - throw NotImplementedError() - } - - override fun unregisterWorker(worker: MusicRepository.IndexingWorker) { - throw NotImplementedError() - } - - override fun find(uid: Music.UID): Music? { - throw NotImplementedError() - } - - override suspend fun createPlaylist(name: String, songs: List) { - throw NotImplementedError() - } - - override suspend fun renamePlaylist(playlist: Playlist, name: String) { - throw NotImplementedError() - } - - override suspend fun deletePlaylist(playlist: Playlist) { - throw NotImplementedError() - } - - override suspend fun addToPlaylist(songs: List, playlist: Playlist) { - throw NotImplementedError() - } - - override suspend fun rewritePlaylist(playlist: Playlist, songs: List) { - throw NotImplementedError() - } - - override fun requestIndex(withCache: Boolean) { - throw NotImplementedError() - } - - override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean): Job { - throw NotImplementedError() - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt deleted file mode 100644 index 14924f4f1..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicSettings.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeMusicSettings.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music - -import org.oxycblt.auxio.list.sort.Sort -import org.oxycblt.auxio.music.fs.MusicDirectories - -open class FakeMusicSettings : MusicSettings { - override fun registerListener(listener: MusicSettings.Listener) = throw NotImplementedError() - - override fun unregisterListener(listener: MusicSettings.Listener) = throw NotImplementedError() - - override var musicDirs: MusicDirectories - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override val excludeNonMusic: Boolean - get() = throw NotImplementedError() - - override val shouldBeObserving: Boolean - get() = throw NotImplementedError() - - override var multiValueSeparators: String - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override val intelligentSorting: Boolean - get() = throw NotImplementedError() - - override var songSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override var albumSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override var artistSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override var genreSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override var playlistSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override var albumSongSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override var artistSongSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() - - override var genreSongSort: Sort - get() = throw NotImplementedError() - set(_) = throw NotImplementedError() -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt deleted file mode 100644 index c11985970..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicModeTest.kt +++ /dev/null @@ -1,32 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MusicModeTest.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music - -import org.junit.Assert.assertEquals -import org.junit.Test - -class MusicModeTest { - @Test - fun intCode() { - assertEquals(MusicType.SONGS, MusicType.fromIntCode(MusicType.SONGS.intCode)) - assertEquals(MusicType.ALBUMS, MusicType.fromIntCode(MusicType.ALBUMS.intCode)) - assertEquals(MusicType.ARTISTS, MusicType.fromIntCode(MusicType.ARTISTS.intCode)) - assertEquals(MusicType.GENRES, MusicType.fromIntCode(MusicType.GENRES.intCode)) - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt deleted file mode 100644 index b25c2c0b1..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ /dev/null @@ -1,133 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MusicViewModelTest.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue -import org.junit.Test -import org.oxycblt.auxio.music.device.DeviceLibrary -import org.oxycblt.auxio.music.device.FakeDeviceLibrary -import org.oxycblt.auxio.util.forceClear - -class MusicViewModelTest { - @Test - fun indexerState() { - val indexer = - TestMusicRepository().apply { - indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate) - } - val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) - assertTrue(indexer.updateListener is MusicViewModel) - assertTrue(indexer.indexingListener is MusicViewModel) - assertEquals( - IndexingProgress.Indeterminate, - (musicViewModel.indexingState.value as IndexingState.Indexing).progress) - indexer.indexingState = null - assertEquals(null, musicViewModel.indexingState.value) - musicViewModel.forceClear() - assertTrue(indexer.indexingListener == null) - } - - @Test - fun statistics() { - val musicRepository = TestMusicRepository() - val musicViewModel = MusicViewModel(musicRepository, FakeMusicSettings()) - assertEquals(null, musicViewModel.statistics.value) - musicRepository.deviceLibrary = TestDeviceLibrary() - assertEquals( - MusicViewModel.Statistics( - 2, - 3, - 4, - 1, - 161616 * 2, - ), - musicViewModel.statistics.value) - } - - @Test - fun requests() { - val indexer = TestMusicRepository() - val musicViewModel = MusicViewModel(indexer, FakeMusicSettings()) - musicViewModel.refresh() - musicViewModel.rescan() - assertEquals(listOf(true, false), indexer.requests) - } - - private class TestMusicRepository : FakeMusicRepository() { - override var deviceLibrary: DeviceLibrary? = null - set(value) { - field = value - updateListener?.onMusicChanges( - MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - } - - override var indexingState: IndexingState? = null - set(value) { - field = value - indexingListener?.onIndexingStateChanged() - } - - var updateListener: MusicRepository.UpdateListener? = null - var indexingListener: MusicRepository.IndexingListener? = null - val requests = mutableListOf() - - override fun addUpdateListener(listener: MusicRepository.UpdateListener) { - listener.onMusicChanges( - MusicRepository.Changes(deviceLibrary = true, userLibrary = false)) - this.updateListener = listener - } - - override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { - this.updateListener = null - } - - override fun addIndexingListener(listener: MusicRepository.IndexingListener) { - listener.onIndexingStateChanged() - this.indexingListener = listener - } - - override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { - this.indexingListener = null - } - - override fun requestIndex(withCache: Boolean) { - requests.add(withCache) - } - } - - private class TestDeviceLibrary : FakeDeviceLibrary() { - override val songs: List - get() = listOf(TestSong(), TestSong()) - - override val albums: List - get() = listOf(FakeAlbum(), FakeAlbum(), FakeAlbum()) - - override val artists: List - get() = listOf(FakeArtist(), FakeArtist(), FakeArtist(), FakeArtist()) - - override val genres: List - get() = listOf(FakeGenre()) - } - - private class TestSong : FakeSong() { - override val durationMs: Long - get() = 161616 - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt b/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt deleted file mode 100644 index 2c4805486..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/device/DeviceMusicImplTest.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * DeviceMusicImplTest.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music.device - -import java.util.UUID -import org.junit.Assert.assertTrue -import org.junit.Test - -class DeviceMusicImplTest { - @Test - fun albumRaw_equals_inconsistentCase() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Paraglow", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian Glow"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "paraglow", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Parannoul"), RawArtist(name = "Asian glow"))) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun albumRaw_equals_withMbids() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), - name = "Weezer", - sortName = "Blue Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("923d5ba6-7eee-3bce-bcb2-c913b2bd69d4"), - name = "Weezer", - sortName = "Green Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun albumRaw_equals_inconsistentMbids() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = UUID.fromString("c7b245c9-8099-32ea-af95-893acedde2cf"), - name = "Weezer", - sortName = "Blue Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Weezer", - sortName = "Green Album", - releaseType = null, - rawArtists = listOf(RawArtist(name = "Weezer"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun albumRaw_equals_withArtists() { - val a = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Album", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Artist A"))) - val b = - RawAlbum( - mediaStoreId = -1, - musicBrainzId = null, - name = "Album", - sortName = null, - releaseType = null, - rawArtists = listOf(RawArtist(name = "Artist B"))) - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun artistRaw_equals_inconsistentCase() { - val a = RawArtist(musicBrainzId = null, name = "Parannoul") - val b = RawArtist(musicBrainzId = null, name = "parannoul") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun artistRaw_equals_withMbids() { - val a = - RawArtist( - musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), - name = "Artist") - val b = - RawArtist( - musicBrainzId = UUID.fromString("6b625592-d88d-48c8-ac1a-c5b476d78bcc"), - name = "Artist") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun artistRaw_equals_inconsistentMbids() { - val a = - RawArtist( - musicBrainzId = UUID.fromString("677325ef-d850-44bb-8258-0d69bbc0b3f7"), - name = "Artist") - val b = RawArtist(musicBrainzId = null, name = "Artist") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun artistRaw_equals_missingNames() { - val a = RawArtist(name = null) - val b = RawArtist(name = null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun artistRaw_equals_inconsistentNames() { - val a = RawArtist(name = null) - val b = RawArtist(name = "Parannoul") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } - - @Test - fun genreRaw_equals_inconsistentCase() { - val a = RawGenre("Future Garage") - val b = RawGenre("future garage") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun genreRaw_equals_missingNames() { - val a = RawGenre(name = null) - val b = RawGenre(name = null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun genreRaw_equals_inconsistentNames() { - val a = RawGenre(name = null) - val b = RawGenre(name = "Future Garage") - assertTrue(a != b) - assertTrue(a.hashCode() != b.hashCode()) - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt b/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt deleted file mode 100644 index dab0834a3..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/device/FakeDeviceLibrary.kt +++ /dev/null @@ -1,61 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeDeviceLibrary.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.music.device - -import android.content.Context -import android.net.Uri -import org.oxycblt.auxio.music.Album -import org.oxycblt.auxio.music.Artist -import org.oxycblt.auxio.music.Genre -import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.Song - -open class FakeDeviceLibrary : DeviceLibrary { - override val songs: List - get() = throw NotImplementedError() - - override val albums: List - get() = throw NotImplementedError() - - override val artists: List - get() = throw NotImplementedError() - - override val genres: List - get() = throw NotImplementedError() - - override fun findSong(uid: Music.UID): Song? { - throw NotImplementedError() - } - - override fun findSongForUri(context: Context, uri: Uri): Song? { - throw NotImplementedError() - } - - override fun findAlbum(uid: Music.UID): Album? { - throw NotImplementedError() - } - - override fun findArtist(uid: Music.UID): Artist? { - throw NotImplementedError() - } - - override fun findGenre(uid: Music.UID): Genre? { - throw NotImplementedError() - } -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt index 40c95bc56..b63639e27 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DateTest.kt @@ -1,4 +1,91 @@ +/* + * Copyright (c) 2023 Auxio Project + * DateTest.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.oxycblt.auxio.music.info -class NameTest { -} \ No newline at end of file +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class DateTest { + @Test + fun date_equals_varyingPrecision() { + assertTrue( + requireNotNull(Date.from("2016-08-16T00:01:02")) != + requireNotNull(Date.from("2016-08-16"))) + } + + @Test + fun date_compareTo_dates() { + val a = requireNotNull(Date.from("2016-08-16T00:01:02")) + val b = requireNotNull(Date.from("2016-09-16T00:01:02")) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun date_compareTo_times() { + val a = requireNotNull(Date.from("2016-08-16T00:02:02")) + val b = requireNotNull(Date.from("2016-08-16T00:01:02")) + assertEquals(1, a.compareTo(b)) + } + + @Test + fun date_compareTo_varyingPrecision() { + val a = requireNotNull(Date.from("2016-08-16T00:01:02")) + val b = requireNotNull(Date.from("2016-08-16")) + assertEquals( + 1, + a.compareTo(b), + ) + } + + @Test + fun date_from_values() { + assertEquals("2016", Date.from(2016).toString()) + assertEquals("2016-08-16", Date.from(2016, 8, 16).toString()) + assertEquals("2016-08-16T00:01Z", Date.from(2016, 8, 16, 0, 1).toString()) + } + + @Test + fun date_from_yearDate() { + assertEquals("2016-08-16", Date.from(20160816).toString()) + assertEquals("2016-08-16", Date.from("20160816").toString()) + } + + @Test + fun date_from_timestamp() { + assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16T00:01:02").toString()) + assertEquals("2016-08-16T00:01:02Z", Date.from("2016-08-16 00:01:02").toString()) + } + + @Test + fun date_from_lesserPrecision() { + assertEquals("2016", Date.from("2016").toString()) + assertEquals("2016-08", Date.from("2016-08").toString()) + assertEquals("2016-08-16", Date.from("2016-08-16").toString()) + assertEquals("2016-08-16T00:01Z", Date.from("2016-08-16T00:01").toString()) + } + + @Test + fun date_from_wack() { + assertEquals(null, Date.from(0)) + assertEquals(null, Date.from("")) + assertEquals(null, Date.from("2016-08-16:00:01:02")) + assertEquals("2016-11", Date.from("2016-11-32 25:43:01").toString()) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt index 260ca67cb..9b428acac 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/DiscTest.kt @@ -19,30 +19,36 @@ package org.oxycblt.auxio.music.info import org.junit.Assert.assertEquals -import org.junit.Assert.assertTrue import org.junit.Test class DiscTest { @Test - fun disc_compare() { - val a = Disc(1, "Part I") - val b = Disc(2, "Part II") + fun disc_equals_byNum() { + val a = Disc(0, null) + val b = Disc(0, null) + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun disc_equals_bySubtitle() { + val a = Disc(0, "z subtitle") + val b = Disc(0, "a subtitle") + assertEquals(a, b) + assertEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun disc_compareTo_byNum() { + val a = Disc(0, null) + val b = Disc(1, null) assertEquals(-1, a.compareTo(b)) } @Test - fun disc_equals_correct() { - val a = Disc(1, "Part I") - val b = Disc(1, "Part I") - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) - } - - @Test - fun disc_equals_inconsistentNames() { - val a = Disc(1, "Part I") - val b = Disc(1, null) - assertTrue(a == b) - assertTrue(a.hashCode() == b.hashCode()) + fun disc_compareTo_bySubtitle() { + val a = Disc(0, "z subtitle") + val b = Disc(1, "a subtitle") + assertEquals(-1, a.compareTo(b)) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt index 40c95bc56..9ede93b5e 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -1,4 +1,430 @@ +/* + * Copyright (c) 2023 Auxio Project + * NameTest.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + package org.oxycblt.auxio.music.info +import io.mockk.every +import io.mockk.mockk +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Test +import org.oxycblt.auxio.music.MusicSettings + class NameTest { -} \ No newline at end of file + private fun mockIntelligentSorting(enabled: Boolean) = + mockk().apply { every { intelligentSorting } returns enabled } + + @Test + fun name_from_simple_withoutPunct() { + val name = Name.Known.from("Loveless", null, mockIntelligentSorting(false)) + assertEquals("Loveless", name.raw) + assertEquals(null, name.sort) + assertEquals("L", name.thumb) + val only = name.sortTokens.single() + assertEquals("Loveless", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_simple_withPunct() { + val name = Name.Known.from("alt-J", null, mockIntelligentSorting(false)) + assertEquals("alt-J", name.raw) + assertEquals(null, name.sort) + assertEquals("A", name.thumb) + val only = name.sortTokens.single() + assertEquals("altJ", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_simple_oopsAllPunct() { + val name = Name.Known.from("!!!", null, mockIntelligentSorting(false)) + assertEquals("!!!", name.raw) + assertEquals(null, name.sort) + assertEquals("!", name.thumb) + val only = name.sortTokens.single() + assertEquals("!!!", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_simple_spacedPunct() { + val name = Name.Known.from("& Yet & Yet", null, mockIntelligentSorting(false)) + assertEquals("& Yet & Yet", name.raw) + assertEquals(null, name.sort) + assertEquals("Y", name.thumb) + val first = name.sortTokens[0] + assertEquals("Yet Yet", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_simple_withSort() { + val name = Name.Known.from("The Smile", "Smile", mockIntelligentSorting(false)) + assertEquals("The Smile", name.raw) + assertEquals("Smile", name.sort) + assertEquals("S", name.thumb) + val only = name.sortTokens.single() + assertEquals("Smile", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.from("Loveless", null, mockIntelligentSorting(true)) + assertEquals("Loveless", name.raw) + assertEquals(null, name.sort) + assertEquals("L", name.thumb) + val only = name.sortTokens.single() + assertEquals("Loveless", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { + val name = Name.Known.from("15 Step", null, mockIntelligentSorting(true)) + assertEquals("15 Step", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("15", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals("Step", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { + val name = Name.Known.from("23Kid", null, mockIntelligentSorting(true)) + assertEquals("23Kid", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("23", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals("Kid", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { + val name = Name.Known.from("Foo 1 2 Bar", null, mockIntelligentSorting(true)) + assertEquals("Foo 1 2 Bar", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("1", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals(" ", third.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + val fourth = name.sortTokens[3] + assertEquals("2", fourth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, fourth.type) + val fifth = name.sortTokens[4] + assertEquals("Bar", fifth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, fifth.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { + val name = Name.Known.from("Foo12Bar", null, mockIntelligentSorting(true)) + assertEquals("Foo12Bar", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("12", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals("Bar", third.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { + val name = Name.Known.from("Foo 1", null, mockIntelligentSorting(true)) + assertEquals("Foo 1", name.raw) + assertEquals(null, name.sort) + assertEquals("F", name.thumb) + val first = name.sortTokens[0] + assertEquals("Foo", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("1", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { + val name = Name.Known.from("Error404", null, mockIntelligentSorting(true)) + assertEquals("Error404", name.raw) + assertEquals(null, name.sort) + assertEquals("E", name.thumb) + val first = name.sortTokens[0] + assertEquals("Error", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("404", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withThe_withoutNumerics() { + val name = Name.Known.from("The National Anthem", null, mockIntelligentSorting(true)) + assertEquals("The National Anthem", name.raw) + assertEquals(null, name.sort) + assertEquals("N", name.thumb) + val first = name.sortTokens[0] + assertEquals("National Anthem", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withAn_withoutNumerics() { + val name = Name.Known.from("An Eagle in Your Mind", null, mockIntelligentSorting(true)) + assertEquals("An Eagle in Your Mind", name.raw) + assertEquals(null, name.sort) + assertEquals("E", name.thumb) + val first = name.sortTokens[0] + assertEquals("Eagle in Your Mind", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_intelligent_withoutPunct_withA_withoutNumerics() { + val name = Name.Known.from("A Song For Our Fathers", null, mockIntelligentSorting(true)) + assertEquals("A Song For Our Fathers", name.raw) + assertEquals(null, name.sort) + assertEquals("S", name.thumb) + val first = name.sortTokens[0] + assertEquals("Song For Our Fathers", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_intelligent_withPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.from("alt-J", null, mockIntelligentSorting(true)) + assertEquals("alt-J", name.raw) + assertEquals(null, name.sort) + assertEquals("A", name.thumb) + val only = name.sortTokens.single() + assertEquals("altJ", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.from("!!!", null, mockIntelligentSorting(true)) + assertEquals("!!!", name.raw) + assertEquals(null, name.sort) + assertEquals("!", name.thumb) + val only = name.sortTokens.single() + assertEquals("!!!", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_from_intelligent_withoutPunct_shortArticle_withNumerics() { + val name = Name.Known.from("the 1", null, mockIntelligentSorting(true)) + assertEquals("the 1", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("1", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + } + + @Test + fun name_from_intelligent_spacedPunct_withoutArticle_withoutNumerics() { + val name = Name.Known.from("& Yet & Yet", null, mockIntelligentSorting(true)) + assertEquals("& Yet & Yet", name.raw) + assertEquals(null, name.sort) + assertEquals("Y", name.thumb) + val first = name.sortTokens[0] + assertEquals("Yet Yet", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + } + + @Test + fun name_from_intelligent_withPunct_withoutArticle_withNumerics() { + val name = Name.Known.from("Design : 2 : 3", null, mockIntelligentSorting(true)) + assertEquals("Design : 2 : 3", name.raw) + assertEquals(null, name.sort) + assertEquals("D", name.thumb) + val first = name.sortTokens[0] + assertEquals("Design", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + val second = name.sortTokens[1] + assertEquals("2", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + val third = name.sortTokens[2] + assertEquals(" ", third.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + val fourth = name.sortTokens[3] + assertEquals("3", fourth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, fourth.type) + } + + @Test + fun name_from_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { + val name = Name.Known.from("2 + 2 = 5", null, mockIntelligentSorting(true)) + assertEquals("2 + 2 = 5", name.raw) + assertEquals(null, name.sort) + assertEquals("#", name.thumb) + val first = name.sortTokens[0] + assertEquals("2", first.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + val second = name.sortTokens[1] + assertEquals(" ", second.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + val third = name.sortTokens[2] + assertEquals("2", third.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, third.type) + val fourth = name.sortTokens[3] + assertEquals(" ", fourth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, fourth.type) + val fifth = name.sortTokens[4] + assertEquals("5", fifth.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.NUMERIC, fifth.type) + } + + @Test + fun name_from_intelligent_withSort() { + val name = Name.Known.from("The Smile", "Smile", mockIntelligentSorting(true)) + assertEquals("The Smile", name.raw) + assertEquals("Smile", name.sort) + assertEquals("S", name.thumb) + val only = name.sortTokens.single() + assertEquals("Smile", only.collationKey.sourceString) + assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + } + + @Test + fun name_equals_simple() { + val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) + val b = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) + assertEquals(a, b) + } + + @Test + fun name_equals_differentSort() { + val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) + val b = Name.Known.from("The Same", null, mockIntelligentSorting(false)) + assertNotEquals(a, b) + assertNotEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun name_equals_intelligent_differentTokens() { + val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(true)) + val b = Name.Known.from("Same", "Same", mockIntelligentSorting(true)) + assertNotEquals(a, b) + assertNotEquals(a.hashCode(), b.hashCode()) + } + + @Test + fun name_compareTo_simple_withoutSort_withoutArticle_withoutNumeric() { + val a = Name.Known.from("A", null, mockIntelligentSorting(false)) + val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_simple_withoutSort_withArticle_withoutNumeric() { + val a = Name.Known.from("A Brain in a Bottle", null, mockIntelligentSorting(false)) + val b = Name.Known.from("Acid Rain", null, mockIntelligentSorting(false)) + val c = Name.Known.from("Boralis / Contrastellar", null, mockIntelligentSorting(false)) + val d = Name.Known.from("Breathe In", null, mockIntelligentSorting(false)) + assertEquals(-1, a.compareTo(b)) + assertEquals(-1, a.compareTo(c)) + assertEquals(-1, a.compareTo(d)) + } + + @Test + fun name_compareTo_simple_withSort_withoutArticle_withNumeric() { + val a = Name.Known.from("15 Step", null, mockIntelligentSorting(false)) + val b = Name.Known.from("128 Harps", null, mockIntelligentSorting(false)) + val c = Name.Known.from("1969", null, mockIntelligentSorting(false)) + assertEquals(1, a.compareTo(b)) + assertEquals(-1, a.compareTo(c)) + } + + @Test + fun name_compareTo_simple_withPartialSort() { + val a = Name.Known.from("A", "C", mockIntelligentSorting(false)) + val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + assertEquals(1, a.compareTo(b)) + } + + @Test + fun name_compareTo_simple_withSort() { + val a = Name.Known.from("D", "A", mockIntelligentSorting(false)) + val b = Name.Known.from("C", "B", mockIntelligentSorting(false)) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withoutArticle_withoutNumeric() { + val a = Name.Known.from("A", null, mockIntelligentSorting(true)) + val b = Name.Known.from("B", null, mockIntelligentSorting(true)) + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withArticle_withoutNumeric() { + val a = Name.Known.from("A Brain in a Bottle", null, mockIntelligentSorting(true)) + val b = Name.Known.from("Acid Rain", null, mockIntelligentSorting(true)) + val c = Name.Known.from("Boralis / Contrastellar", null, mockIntelligentSorting(true)) + val d = Name.Known.from("Breathe In", null, mockIntelligentSorting(true)) + assertEquals(1, a.compareTo(b)) + assertEquals(1, a.compareTo(c)) + assertEquals(-1, a.compareTo(d)) + } + + @Test + fun name_compareTo_intelligent_withoutSort_withoutArticle_withNumeric() { + val a = Name.Known.from("15 Step", null, mockIntelligentSorting(true)) + val b = Name.Known.from("128 Harps", null, mockIntelligentSorting(true)) + val c = Name.Known.from("1969", null, mockIntelligentSorting(true)) + assertEquals(-1, a.compareTo(b)) + assertEquals(-1, b.compareTo(c)) + assertEquals(-2, a.compareTo(c)) + } + + @Test + fun name_compareTo_intelligent_withPartialSort_withoutArticle_withoutNumeric() { + val a = Name.Known.from("A", "C", mockIntelligentSorting(false)) + val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + assertEquals(1, a.compareTo(b)) + } + + @Test + fun name_compareTo_intelligent_withSort_withoutArticle_withoutNumeric() { + val a = Name.Known.from("D", "A", mockIntelligentSorting(true)) + val b = Name.Known.from("C", "B", mockIntelligentSorting(true)) + assertEquals(-1, a.compareTo(b)) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt index db340f187..5f6c67c89 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt @@ -18,27 +18,32 @@ package org.oxycblt.auxio.music.metadata +import io.mockk.every +import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Test -import org.oxycblt.auxio.music.FakeMusicSettings +import org.oxycblt.auxio.music.MusicSettings class TagUtilTest { + private fun mockSeparators(separators: String) = + mockk().apply { every { multiValueSeparators } returns separators } + @Test fun parseMultiValue_single() { - assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(TestMusicSettings(","))) + assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(mockSeparators(","))) } @Test fun parseMultiValue_many() { assertEquals( - listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(TestMusicSettings(","))) + listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(mockSeparators(","))) } @Test fun parseMultiValue_several() { assertEquals( listOf("a", "b", "c", "d", "e", "f"), - listOf("a,b;c/d+e&f").parseMultiValue(TestMusicSettings(",;/+&"))) + listOf("a,b;c/d+e&f").parseMultiValue(mockSeparators(",;/+&"))) } @Test @@ -131,43 +136,37 @@ class TagUtilTest { fun parseId3v2Genre_multi() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(mockSeparators(","))) } @Test fun parseId3v2Genre_multiId3v1() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("176", "178", "Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("176", "178", "Glitch").parseId3GenreNames(mockSeparators(","))) } @Test fun parseId3v2Genre_wackId3() { - assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(TestMusicSettings(","))) + assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(mockSeparators(","))) } @Test fun parseId3v2Genre_singleId3v23() { assertEquals( listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"), - listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(mockSeparators(","))) } @Test fun parseId3v2Genre_singleSeparated() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(TestMusicSettings(","))) + listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(mockSeparators(","))) } @Test fun parsId3v2Genre_singleId3v1() { - assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(TestMusicSettings(","))) - } - - class TestMusicSettings(private val separators: String) : FakeMusicSettings() { - override var multiValueSeparators: String - get() = separators - set(_) = throw NotImplementedError() + assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(mockSeparators(","))) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt index 6cd22fdcb..73c5b926d 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TextTagsTest.kt @@ -39,6 +39,7 @@ class TextTagsTest { assertEquals(listOf("2022"), textTags.vorbis["date"]) assertEquals(listOf("ep"), textTags.vorbis["releasetype"]) assertEquals(listOf("+2 dB"), textTags.vorbis["replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["APIC"]) } @Test @@ -51,6 +52,7 @@ class TextTagsTest { assertEquals(listOf("2022"), textTags.id3v2["TDRC"]) assertEquals(listOf("ep"), textTags.id3v2["TXXX:musicbrainz album type"]) assertEquals(listOf("+2 dB"), textTags.id3v2["TXXX:replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["metadata_block_picture"]) } @Test @@ -62,10 +64,13 @@ class TextTagsTest { assertEquals(listOf("2022"), textTags.vorbis["date"]) assertEquals(listOf("ep"), textTags.vorbis["releasetype"]) assertEquals(listOf("+2 dB"), textTags.vorbis["replaygain_track_gain"]) + assertEquals(null, textTags.id3v2["metadata_block_picture"]) + assertEquals(listOf("Wheel"), textTags.id3v2["TIT2"]) assertEquals(listOf("Paraglow"), textTags.id3v2["TALB"]) assertEquals(listOf("Parannoul", "Asian Glow"), textTags.id3v2["TPE1"]) assertEquals(listOf("2022"), textTags.id3v2["TDRC"]) + assertEquals(null, textTags.id3v2["APIC"]) assertEquals(listOf("ep"), textTags.id3v2["TXXX:musicbrainz album type"]) assertEquals(listOf("+2 dB"), textTags.id3v2["TXXX:replaygain_track_gain"]) } diff --git a/app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt b/app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt deleted file mode 100644 index 5da90ab54..000000000 --- a/app/src/test/java/org/oxycblt/auxio/util/TestingUtil.kt +++ /dev/null @@ -1,28 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * TestingUtil.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio.util - -import androidx.lifecycle.ViewModel - -private val VM_CLEAR_METHOD = - ViewModel::class.java.getDeclaredMethod("clear").apply { isAccessible = true } - -fun ViewModel.forceClear() { - VM_CLEAR_METHOD.invoke(this) -} From c1655a9ecad30a6ac66e48d8c92062a6b1c8d7e9 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 14:11:25 -0600 Subject: [PATCH 21/25] music: move multi-value util to separators Move all multi-value utilities to a new Separators interface. This should allow separator config to be dynamically compared across song instances, and generally make songs easier to test. --- .../org/oxycblt/auxio/music/MusicSettings.kt | 4 +- .../auxio/music/device/DeviceLibrary.kt | 5 +- .../auxio/music/device/DeviceMusicImpl.kt | 128 ++++++++++-------- .../auxio/music/metadata/Separators.kt | 59 ++++++++ .../auxio/music/metadata/SeparatorsDialog.kt | 13 +- .../oxycblt/auxio/music/metadata/TagUtil.kt | 43 +----- .../org/oxycblt/auxio/music/info/NameTest.kt | 2 +- .../auxio/music/metadata/SeparatorsTest.kt | 47 +++++++ .../auxio/music/metadata/TagUtilTest.kt | 41 +----- 9 files changed, 198 insertions(+), 144 deletions(-) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index f2930d3ec..488c98126 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -43,7 +43,7 @@ interface MusicSettings : Settings { /** Whether to be actively watching for changes in the music library. */ val shouldBeObserving: Boolean /** A [String] of characters representing the desired characters to denote multi-value tags. */ - var multiValueSeparators: String + var separators: String /** Whether to enable more advanced sorting by articles and numbers. */ val intelligentSorting: Boolean @@ -85,7 +85,7 @@ class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context override val shouldBeObserving: Boolean get() = sharedPreferences.getBoolean(getString(R.string.set_key_observing), false) - override var multiValueSeparators: String + override var separators: String // Differ from convention and store a string of separator characters instead of an int // code. This makes it easier to use and more extendable. get() = sharedPreferences.getString(getString(R.string.set_key_separators), "") ?: "" diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index cd75ba578..eae4265ff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery +import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.unlikelyToBeNull @@ -118,6 +119,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu rawSongs: Channel, processedSongs: Channel ): DeviceLibraryImpl { + val separators = Separators.from(musicSettings.separators) + val songGrouping = mutableMapOf() val albumGrouping = mutableMapOf>() val artistGrouping = mutableMapOf>() @@ -127,7 +130,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu // All music information is grouped as it is indexed by other components. for (rawSong in rawSongs) { - val song = SongImpl(rawSong, musicSettings) + val song = SongImpl(rawSong, musicSettings, separators) // At times the indexer produces duplicate songs, try to filter these. Comparing by // UID is sufficient for something like this, and also prevents collisions from // causing severe issues elsewhere. diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index 1d2ce2a26..b8b20f06b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -36,8 +36,8 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.info.Disc import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.info.ReleaseType +import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.music.metadata.parseId3GenreNames -import org.oxycblt.auxio.music.metadata.parseMultiValue import org.oxycblt.auxio.playback.replaygain.ReplayGainAdjustment import org.oxycblt.auxio.util.positiveOrNull import org.oxycblt.auxio.util.toUuidOrNull @@ -48,10 +48,12 @@ import org.oxycblt.auxio.util.update * Library-backed implementation of [Song]. * * @param rawSong The [RawSong] to derive the member data from. + * @param separators The [Separators] to parse multi-value tags with. * @param musicSettings [MusicSettings] to for user parsing configuration. * @author Alexander Capehart (OxygenCobalt) */ -class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Song { +class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings, separators: Separators) : + Song { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) } @@ -95,42 +97,11 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son track = rawSong.replayGainTrackAdjustment, album = rawSong.replayGainAlbumAdjustment) override val dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" } + private var _album: AlbumImpl? = null override val album: Album get() = unlikelyToBeNull(_album) - private val hashCode = 31 * uid.hashCode() + rawSong.hashCode() - - override fun hashCode() = hashCode - - override fun equals(other: Any?) = - other is SongImpl && uid == other.uid && rawSong == other.rawSong - - override fun toString() = "Song(uid=$uid, name=$name)" - - private val artistMusicBrainzIds = rawSong.artistMusicBrainzIds.parseMultiValue(musicSettings) - private val artistNames = rawSong.artistNames.parseMultiValue(musicSettings) - private val artistSortNames = rawSong.artistSortNames.parseMultiValue(musicSettings) - private val rawIndividualArtists = - artistNames.mapIndexed { i, name -> - RawArtist( - artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), - name, - artistSortNames.getOrNull(i)) - } - - private val albumArtistMusicBrainzIds = - rawSong.albumArtistMusicBrainzIds.parseMultiValue(musicSettings) - private val albumArtistNames = rawSong.albumArtistNames.parseMultiValue(musicSettings) - private val albumArtistSortNames = rawSong.albumArtistSortNames.parseMultiValue(musicSettings) - private val rawAlbumArtists = - albumArtistNames.mapIndexed { i, name -> - RawArtist( - albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), - name, - albumArtistSortNames.getOrNull(i)) - } - private val _artists = mutableListOf() override val artists: List get() = _artists @@ -139,44 +110,87 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings) : Son override val genres: List get() = _genres + private val hashCode = 31 * uid.hashCode() + rawSong.hashCode() + + override fun hashCode() = hashCode + + // TODO: I cant compare by raw information actually, as it also means that any settings + // configuration will be lost as well. + override fun equals(other: Any?) = + other is SongImpl && uid == other.uid && rawSong == other.rawSong + + override fun toString() = "Song(uid=$uid, name=$name)" + /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an * [Album]. */ - val rawAlbum = - RawAlbum( - mediaStoreId = requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" }, - musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), - name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, - sortName = rawSong.albumSortName, - releaseType = ReleaseType.parse(rawSong.releaseTypes.parseMultiValue(musicSettings)), - rawArtists = - rawAlbumArtists - .ifEmpty { rawIndividualArtists } - .distinctBy { it.key } - .ifEmpty { listOf(RawArtist(null, null)) }) + val rawAlbum: RawAlbum /** * The [RawArtist] instances collated by the [Song]. The artists of the song take priority, * followed by the album artists. If there are no artists, this field will be a single "unknown" * [RawArtist]. This can be used to group up [Song]s into an [Artist]. */ - val rawArtists = - rawIndividualArtists - .ifEmpty { rawAlbumArtists } - .distinctBy { it.key } - .ifEmpty { listOf(RawArtist()) } + val rawArtists: List /** * The [RawGenre] instances collated by the [Song]. This can be used to group up [Song]s into a * [Genre]. ID3v2 Genre names are automatically converted to their resolved names. */ - val rawGenres = - rawSong.genreNames - .parseId3GenreNames(musicSettings) - .map { RawGenre(it) } - .distinctBy { it.key } - .ifEmpty { listOf(RawGenre()) } + val rawGenres: List + + init { + val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds) + val artistNames = separators.split(rawSong.artistNames) + val artistSortNames = separators.split(rawSong.artistSortNames) + val rawIndividualArtists = + artistNames + .mapIndexedTo(mutableSetOf()) { i, name -> + RawArtist( + artistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + artistSortNames.getOrNull(i)) + } + .toList() + + val albumArtistMusicBrainzIds = separators.split(rawSong.albumArtistMusicBrainzIds) + val albumArtistNames = separators.split(rawSong.albumArtistNames) + val albumArtistSortNames = separators.split(rawSong.albumArtistSortNames) + val rawAlbumArtists = + albumArtistNames + .mapIndexedTo(mutableSetOf()) { i, name -> + RawArtist( + albumArtistMusicBrainzIds.getOrNull(i)?.toUuidOrNull(), + name, + albumArtistSortNames.getOrNull(i)) + } + .toList() + + rawAlbum = + RawAlbum( + mediaStoreId = + requireNotNull(rawSong.albumMediaStoreId) { "Invalid raw: No album id" }, + musicBrainzId = rawSong.albumMusicBrainzId?.toUuidOrNull(), + name = requireNotNull(rawSong.albumName) { "Invalid raw: No album name" }, + sortName = rawSong.albumSortName, + releaseType = ReleaseType.parse(separators.split(rawSong.releaseTypes)), + rawArtists = + rawAlbumArtists + .ifEmpty { rawIndividualArtists } + .ifEmpty { listOf(RawArtist()) }) + + rawArtists = + rawIndividualArtists.ifEmpty { rawAlbumArtists }.ifEmpty { listOf(RawArtist()) } + + val genreNames = + (rawSong.genreNames.parseId3GenreNames() ?: separators.split(rawSong.genreNames)) + rawGenres = + genreNames + .mapTo(mutableSetOf()) { RawGenre(it) } + .toList() + .ifEmpty { listOf(RawGenre()) } + } /** * Links this [Song] with a parent [Album]. diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt new file mode 100644 index 000000000..5142c6905 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2023 Auxio Project + * Separators.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.metadata + +/** + * Defines the user-specified parsing of multi-value tags. This should be used to parse any tags + * that may be delimited with a separator character. + * + * @author Alexander Capehart (OxygenCobalt) + */ +interface Separators { + /** + * Parse a separated value from one or more strings. If the value is already composed of more + * than one value, nothing is done. Otherwise, it will attempt to split it based on the user's + * separator preferences. + * + * @return A new list of one or more [String]s parsed by the separator configuration + */ + fun split(strings: List): List + + companion object { + const val COMMA = ',' + const val SEMICOLON = ';' + const val SLASH = '/' + const val PLUS = '+' + const val AND = '&' + + fun from(selector: String) = + if (selector.isNotEmpty()) CharSeparators(selector.toSet()) else NoSeparators + } +} + +private data class CharSeparators(private val chars: Set) : Separators { + override fun split(strings: List) = + if (strings.size == 1) splitImpl(strings.first()) else strings + + private fun splitImpl(string: String) = + string.splitEscaped { chars.contains(it) }.correctWhitespace() +} + +private object NoSeparators : Separators { + override fun split(strings: List) = strings +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt index d74b9ba53..31195c408 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/SeparatorsDialog.kt @@ -52,7 +52,7 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment - musicSettings.multiValueSeparators = getCurrentSeparators() + musicSettings.separators = getCurrentSeparators() } } @@ -68,8 +68,7 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment binding.separatorComma.isChecked = true @@ -102,14 +101,6 @@ class SeparatorsDialog : ViewBindingMaterialDialogFragment.parseMultiValue(settings: MusicSettings) = - if (size == 1) { - first().maybeParseBySeparators(settings) - } else { - // Nothing to do. - this - } - // TODO: Remove the escaping checks, it's too expensive to do this for every single tag. /** @@ -101,17 +84,6 @@ fun String.correctWhitespace() = trim().ifBlank { null } */ fun List.correctWhitespace() = mapNotNull { it.correctWhitespace() } -/** - * Attempt to parse a string by the user's separator preferences. - * - * @param settings [MusicSettings] required to obtain user separator configuration. - * @return A list of one or more [String]s that were split up by the user-defined separators. - */ -private fun String.maybeParseBySeparators(settings: MusicSettings): List { - if (settings.multiValueSeparators.isEmpty()) return listOf(this) - return splitEscaped { settings.multiValueSeparators.contains(it) }.correctWhitespace() -} - /// --- ID3v2 PARSING --- /** @@ -165,12 +137,12 @@ fun transformPositionField(pos: Int?, total: Int?) = * representations of genre fields into their named counterparts, and split up singular ID3v2-style * integer genre fields into one or more genres. * - * @param settings [MusicSettings] required to obtain user separator configuration. - * @return A list of one or more genre names.. + * @return A list of one or more genre names, or null if this multi-value list has no valid + * formatting. */ -fun List.parseId3GenreNames(settings: MusicSettings) = +fun List.parseId3GenreNames() = if (size == 1) { - first().parseId3MultiValueGenre(settings) + first().parseId3MultiValueGenre() } else { // Nothing to split, just map any ID3v1 genres to their name counterparts. map { it.parseId3v1Genre() ?: it } @@ -179,11 +151,10 @@ fun List.parseId3GenreNames(settings: MusicSettings) = /** * Parse a single ID3v1/ID3v2 integer genre field into their named representations. * - * @param settings [MusicSettings] required to obtain user separator configuration. - * @return A list of one or more genre names. + * @return list of one or more genre names, or null if this is not in ID3v2 format. */ -private fun String.parseId3MultiValueGenre(settings: MusicSettings) = - parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() ?: maybeParseBySeparators(settings) +private fun String.parseId3MultiValueGenre() = + parseId3v1Genre()?.let { listOf(it) } ?: parseId3v2Genre() /** * Parse an ID3v1 integer genre field. diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt index 9ede93b5e..f23f76f16 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -27,7 +27,7 @@ import org.oxycblt.auxio.music.MusicSettings class NameTest { private fun mockIntelligentSorting(enabled: Boolean) = - mockk().apply { every { intelligentSorting } returns enabled } + mockk() { every { intelligentSorting } returns enabled } @Test fun name_from_simple_withoutPunct() { diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt new file mode 100644 index 000000000..440f044e3 --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/SeparatorsTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2023 Auxio Project + * SeparatorsTest.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.metadata + +import org.junit.Assert.assertEquals +import org.junit.Test + +class SeparatorsTest { + @Test + fun separators_split_withString_withSingleChar() { + assertEquals(listOf("a", "b", "c"), Separators.from(",").split(listOf("a,b,c"))) + } + + @Test + fun separators_split_withMultiple_withSingleChar() { + assertEquals(listOf("a,b", "c", "d"), Separators.from(",").split(listOf("a,b", "c", "d"))) + } + + @Test + fun separators_split_withString_withMultipleChar() { + assertEquals( + listOf("a", "b", "c", "d", "e", "f"), + Separators.from(",;/+&").split(listOf("a,b;c/d+e&f"))) + } + + @Test + fun separators_split_withList_withMultipleChar() { + assertEquals( + listOf("a,b;c/d", "e&f"), Separators.from(",;/+&").split(listOf("a,b;c/d", "e&f"))) + } +} diff --git a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt index 5f6c67c89..7c900d42c 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/metadata/TagUtilTest.kt @@ -18,34 +18,10 @@ package org.oxycblt.auxio.music.metadata -import io.mockk.every -import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Test -import org.oxycblt.auxio.music.MusicSettings class TagUtilTest { - private fun mockSeparators(separators: String) = - mockk().apply { every { multiValueSeparators } returns separators } - - @Test - fun parseMultiValue_single() { - assertEquals(listOf("a", "b", "c"), listOf("a,b,c").parseMultiValue(mockSeparators(","))) - } - - @Test - fun parseMultiValue_many() { - assertEquals( - listOf("a", "b", "c"), listOf("a", "b", "c").parseMultiValue(mockSeparators(","))) - } - - @Test - fun parseMultiValue_several() { - assertEquals( - listOf("a", "b", "c", "d", "e", "f"), - listOf("a,b;c/d+e&f").parseMultiValue(mockSeparators(",;/+&"))) - } - @Test fun splitEscaped_correct() { assertEquals(listOf("a", "b", "c"), "a,b,c".splitEscaped { it == ',' }) @@ -136,37 +112,30 @@ class TagUtilTest { fun parseId3v2Genre_multi() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames(mockSeparators(","))) + listOf("Post-Rock", "Shoegaze", "Glitch").parseId3GenreNames()) } @Test fun parseId3v2Genre_multiId3v1() { assertEquals( listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("176", "178", "Glitch").parseId3GenreNames(mockSeparators(","))) + listOf("176", "178", "Glitch").parseId3GenreNames()) } @Test fun parseId3v2Genre_wackId3() { - assertEquals(listOf("2941"), listOf("2941").parseId3GenreNames(mockSeparators(","))) + assertEquals(null, listOf("2941").parseId3GenreNames()) } @Test fun parseId3v2Genre_singleId3v23() { assertEquals( listOf("Post-Rock", "Shoegaze", "Remix", "Cover", "Glitch"), - listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames(mockSeparators(","))) - } - - @Test - fun parseId3v2Genre_singleSeparated() { - assertEquals( - listOf("Post-Rock", "Shoegaze", "Glitch"), - listOf("Post-Rock, Shoegaze, Glitch").parseId3GenreNames(mockSeparators(","))) + listOf("(176)(178)(RX)(CR)Glitch").parseId3GenreNames()) } @Test fun parsId3v2Genre_singleId3v1() { - assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames(mockSeparators(","))) + assertEquals(listOf("Post-Rock"), listOf("176").parseId3GenreNames()) } } From fcffb560210a901e0d78cf2e226f23f5536b0295 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 15:27:45 -0600 Subject: [PATCH 22/25] music: use factory to create known names Implement a new Name.Known.Factory instance that replaces the usage of Name.Known.from. This again allows songs to be differentiated on tag interpretation and is generally easier to test. --- .../oxycblt/auxio/music/MusicRepository.kt | 3 +- .../auxio/music/device/DeviceLibrary.kt | 14 +- .../auxio/music/device/DeviceMusicImpl.kt | 37 ++- .../java/org/oxycblt/auxio/music/info/Name.kt | 100 ++++--- .../auxio/music/metadata/Separators.kt | 20 +- .../oxycblt/auxio/music/metadata/TagWorker.kt | 1 - .../oxycblt/auxio/music/user/PlaylistImpl.kt | 22 +- .../oxycblt/auxio/music/user/UserLibrary.kt | 16 +- .../org/oxycblt/auxio/music/info/NameTest.kt | 257 ++++++++++-------- 9 files changed, 263 insertions(+), 207 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt index d5263da7b..662eb49c5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -223,7 +223,8 @@ constructor( private val mediaStoreExtractor: MediaStoreExtractor, private val tagExtractor: TagExtractor, private val deviceLibraryFactory: DeviceLibrary.Factory, - private val userLibraryFactory: UserLibrary.Factory + private val userLibraryFactory: UserLibrary.Factory, + private val musicSettings: MusicSettings ) : MusicRepository { private val updateListeners = mutableListOf() private val indexingListeners = mutableListOf() diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt index eae4265ff..739faba8c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceLibrary.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.contentResolverSafe import org.oxycblt.auxio.music.fs.useQuery +import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.music.metadata.Separators import org.oxycblt.auxio.util.logW import org.oxycblt.auxio.util.unlikelyToBeNull @@ -108,7 +109,7 @@ interface DeviceLibrary { */ suspend fun create( rawSongs: Channel, - processedSongs: Channel + processedSongs: Channel, ): DeviceLibraryImpl } } @@ -119,7 +120,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu rawSongs: Channel, processedSongs: Channel ): DeviceLibraryImpl { - val separators = Separators.from(musicSettings.separators) + val nameFactory = Name.Known.Factory.from(musicSettings) + val separators = Separators.from(musicSettings) val songGrouping = mutableMapOf() val albumGrouping = mutableMapOf>() @@ -130,7 +132,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu // All music information is grouped as it is indexed by other components. for (rawSong in rawSongs) { - val song = SongImpl(rawSong, musicSettings, separators) + val song = SongImpl(rawSong, nameFactory, separators) // At times the indexer produces duplicate songs, try to filter these. Comparing by // UID is sufficient for something like this, and also prevents collisions from // causing severe issues elsewhere. @@ -210,7 +212,7 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu // Now that all songs are processed, also process albums and group them into their // respective artists. - val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, musicSettings) } + val albums = albumGrouping.values.mapTo(mutableSetOf()) { AlbumImpl(it, nameFactory) } for (album in albums) { for (rawArtist in album.rawArtists) { val key = RawArtist.Key(rawArtist) @@ -246,8 +248,8 @@ class DeviceLibraryFactoryImpl @Inject constructor(private val musicSettings: Mu } // Artists and genres do not need to be grouped and can be processed immediately. - val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, musicSettings) } - val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, musicSettings) } + val artists = artistGrouping.values.mapTo(mutableSetOf()) { ArtistImpl(it, nameFactory) } + val genres = genreGrouping.values.mapTo(mutableSetOf()) { GenreImpl(it, nameFactory) } return DeviceLibraryImpl(songGrouping.values.toSet(), albums, artists, genres) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index b8b20f06b..d513fcbff 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -25,7 +25,6 @@ import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.fs.MimeType @@ -48,12 +47,15 @@ import org.oxycblt.auxio.util.update * Library-backed implementation of [Song]. * * @param rawSong The [RawSong] to derive the member data from. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @param separators The [Separators] to parse multi-value tags with. - * @param musicSettings [MusicSettings] to for user parsing configuration. * @author Alexander Capehart (OxygenCobalt) */ -class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings, separators: Separators) : - Song { +class SongImpl( + private val rawSong: RawSong, + nameFactory: Name.Known.Factory, + separators: Separators +) : Song { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. rawSong.musicBrainzId?.toUuidOrNull()?.let { Music.UID.musicBrainz(MusicType.SONGS, it) } @@ -72,10 +74,8 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings, separ update(rawSong.albumArtistNames) } override val name = - Name.Known.from( - requireNotNull(rawSong.name) { "Invalid raw: No title" }, - rawSong.sortName, - musicSettings) + nameFactory.parse( + requireNotNull(rawSong.name) { "Invalid raw: No title" }, rawSong.sortName) override val track = rawSong.track override val disc = rawSong.disc?.let { Disc(it, rawSong.subtitle) } @@ -256,13 +256,10 @@ class SongImpl(private val rawSong: RawSong, musicSettings: MusicSettings, separ * Library-backed implementation of [Album]. * * @param grouping [Grouping] to derive the member data from. - * @param musicSettings [MusicSettings] to for user parsing configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class AlbumImpl( - grouping: Grouping, - musicSettings: MusicSettings, -) : Album { +class AlbumImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Album { private val rawAlbum = grouping.raw.inner override val uid = @@ -275,7 +272,7 @@ class AlbumImpl( update(rawAlbum.name) update(rawAlbum.rawArtists.map { it.name }) } - override val name = Name.Known.from(rawAlbum.name, rawAlbum.sortName, musicSettings) + override val name = nameFactory.parse(rawAlbum.name, rawAlbum.sortName) override val dates: Date.Range? override val releaseType = rawAlbum.releaseType ?: ReleaseType.Album(null) override val coverUri = CoverUri(rawAlbum.mediaStoreId.toCoverUri(), grouping.raw.src.uri) @@ -376,10 +373,10 @@ class AlbumImpl( * Library-backed implementation of [Artist]. * * @param grouping [Grouping] to derive the member data from. - * @param musicSettings [MusicSettings] to for user parsing configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistImpl(grouping: Grouping, musicSettings: MusicSettings) : Artist { +class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Artist { private val rawArtist = grouping.raw.inner override val uid = @@ -387,7 +384,7 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti rawArtist.musicBrainzId?.let { Music.UID.musicBrainz(MusicType.ARTISTS, it) } ?: Music.UID.auxio(MusicType.ARTISTS) { update(rawArtist.name) } override val name = - rawArtist.name?.let { Name.Known.from(it, rawArtist.sortName, musicSettings) } + rawArtist.name?.let { nameFactory.parse(it, rawArtist.sortName) } ?: Name.Unknown(R.string.def_artist) override val songs: Set @@ -473,15 +470,15 @@ class ArtistImpl(grouping: Grouping, musicSettings: MusicSetti * Library-backed implementation of [Genre]. * * @param grouping [Grouping] to derive the member data from. - * @param musicSettings [MusicSettings] to for user parsing configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class GenreImpl(grouping: Grouping, musicSettings: MusicSettings) : Genre { +class GenreImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Genre { private val rawGenre = grouping.raw.inner override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) } override val name = - rawGenre.name?.let { Name.Known.from(it, rawGenre.name, musicSettings) } + rawGenre.name?.let { nameFactory.parse(it, rawGenre.name) } ?: Name.Unknown(R.string.def_genre) override val songs: Set diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index c47e561cf..b0f0b029d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -57,36 +57,6 @@ sealed interface Name : Comparable { /** A tokenized version of the name that will be compared. */ @VisibleForTesting(VisibleForTesting.PROTECTED) abstract val sortTokens: List - /** An individual part of a name string that can be compared intelligently. */ - @VisibleForTesting(VisibleForTesting.PROTECTED) - data class SortToken(val collationKey: CollationKey, val type: Type) : - Comparable { - override fun compareTo(other: SortToken): Int { - // Numeric tokens should always be lower than lexicographic tokens. - val modeComp = type.compareTo(other.type) - if (modeComp != 0) { - return modeComp - } - - // Numeric strings must be ordered by magnitude, thus immediately short-circuit - // the comparison if the lengths do not match. - if (type == Type.NUMERIC && - collationKey.sourceString.length != other.collationKey.sourceString.length) { - return collationKey.sourceString.length - other.collationKey.sourceString.length - } - - return collationKey.compareTo(other.collationKey) - } - - /** Denotes the type of comparison to be performed with this token. */ - enum class Type { - /** Compare as a digit string, like "65". */ - NUMERIC, - /** Compare as a standard alphanumeric string, like "65daysofstatic" */ - LEXICOGRAPHIC - } - } - final override val thumb: String get() = // TODO: Remove these safety checks once you have real unit testing @@ -110,20 +80,30 @@ sealed interface Name : Comparable { is Unknown -> 1 } - companion object { + interface Factory { /** * Create a new instance of [Name.Known] * * @param raw The raw name obtained from the music item * @param sort The raw sort name obtained from the music item - * @param musicSettings [MusicSettings] required for name configuration. */ - fun from(raw: String, sort: String?, musicSettings: MusicSettings): Known = - if (musicSettings.intelligentSorting) { - IntelligentKnownName(raw, sort) - } else { - SimpleKnownName(raw, sort) - } + fun parse(raw: String, sort: String?): Known + + companion object { + /** + * Creates a new instance from the **current state** of the given [MusicSettings]'s + * user-defined name configuration. + * + * @param settings The [MusicSettings] to use. + * @return A new [Factory] instance reflecting the configuration state. + */ + fun from(settings: MusicSettings) = + if (settings.intelligentSorting) { + IntelligentKnownName.Factory() + } else { + SimpleKnownName.Factory() + } + } } } @@ -157,8 +137,8 @@ private val punctRegex by lazy { Regex("[\\p{Punct}+]") } * * @author Alexander Capehart (OxygenCobalt) */ -private data class SimpleKnownName(override val raw: String, override val sort: String?) : - Name.Known() { +@VisibleForTesting +data class SimpleKnownName(override val raw: String, override val sort: String?) : Name.Known() { override val sortTokens = listOf(parseToken(sort ?: raw)) private fun parseToken(name: String): SortToken { @@ -168,6 +148,10 @@ private data class SimpleKnownName(override val raw: String, override val sort: // Always use lexicographic mode since we aren't parsing any numeric components return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) } + + class Factory : Name.Known.Factory { + override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort) + } } /** @@ -175,7 +159,8 @@ private data class SimpleKnownName(override val raw: String, override val sort: * * @author Alexander Capehart (OxygenCobalt) */ -private data class IntelligentKnownName(override val raw: String, override val sort: String?) : +@VisibleForTesting +data class IntelligentKnownName(override val raw: String, override val sort: String?) : Name.Known() { override val sortTokens = parseTokens(sort ?: raw) @@ -223,7 +208,40 @@ private data class IntelligentKnownName(override val raw: String, override val s } } + class Factory : Name.Known.Factory { + override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort) + } + companion object { private val TOKEN_REGEX by lazy { Regex("(\\d+)|(\\D+)") } } } + +/** An individual part of a name string that can be compared intelligently. */ +@VisibleForTesting(VisibleForTesting.PROTECTED) +data class SortToken(val collationKey: CollationKey, val type: Type) : Comparable { + override fun compareTo(other: SortToken): Int { + // Numeric tokens should always be lower than lexicographic tokens. + val modeComp = type.compareTo(other.type) + if (modeComp != 0) { + return modeComp + } + + // Numeric strings must be ordered by magnitude, thus immediately short-circuit + // the comparison if the lengths do not match. + if (type == Type.NUMERIC && + collationKey.sourceString.length != other.collationKey.sourceString.length) { + return collationKey.sourceString.length - other.collationKey.sourceString.length + } + + return collationKey.compareTo(other.collationKey) + } + + /** Denotes the type of comparison to be performed with this token. */ + enum class Type { + /** Compare as a digit string, like "65". */ + NUMERIC, + /** Compare as a standard alphanumeric string, like "65daysofstatic" */ + LEXICOGRAPHIC + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt index 5142c6905..989a7b128 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -18,6 +18,9 @@ package org.oxycblt.auxio.music.metadata +import androidx.annotation.VisibleForTesting +import org.oxycblt.auxio.music.MusicSettings + /** * Defines the user-specified parsing of multi-value tags. This should be used to parse any tags * that may be delimited with a separator character. @@ -40,9 +43,22 @@ interface Separators { const val SLASH = '/' const val PLUS = '+' const val AND = '&' + /** + * Creates a new instance from the **current state** of the given [MusicSettings]'s + * user-defined separator configuration. + * + * @param settings The [MusicSettings] to use. + * @return A new [Separators] instance reflecting the configuration state. + */ + fun from(settings: MusicSettings) = from(settings.separators) - fun from(selector: String) = - if (selector.isNotEmpty()) CharSeparators(selector.toSet()) else NoSeparators + @VisibleForTesting + fun from(chars: String) = + if (chars.isNotEmpty()) { + CharSeparators(chars.toSet()) + } else { + NoSeparators + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt index fae02585e..196c7c0dc 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagWorker.kt @@ -77,7 +77,6 @@ private class TagWorkerImpl( private val rawSong: RawSong, private val future: Future ) : TagWorker { - override fun poll(): RawSong? { if (!future.isDone) { // Not done yet, nothing to do. diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt index ffe7a5174..fe4418894 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/PlaylistImpl.kt @@ -19,7 +19,6 @@ package org.oxycblt.auxio.music.user import org.oxycblt.auxio.music.Music -import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicType import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song @@ -51,10 +50,10 @@ private constructor( * Clone the data in this instance to a new [PlaylistImpl] with the given [name]. * * @param name The new name to use. - * @param musicSettings [MusicSettings] required for name configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. */ - fun edit(name: String, musicSettings: MusicSettings) = - PlaylistImpl(uid, Name.Known.from(name, null, musicSettings), songs) + fun edit(name: String, nameFactory: Name.Known.Factory) = + PlaylistImpl(uid, nameFactory.parse(name, null), songs) /** * Clone the data in this instance to a new [PlaylistImpl] with the given [Song]s. @@ -76,29 +75,26 @@ private constructor( * * @param name The name of the playlist. * @param songs The songs to initially populate the playlist with. - * @param musicSettings [MusicSettings] required for name configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. */ - fun from(name: String, songs: List, musicSettings: MusicSettings) = - PlaylistImpl( - Music.UID.auxio(MusicType.PLAYLISTS), - Name.Known.from(name, null, musicSettings), - songs) + fun from(name: String, songs: List, nameFactory: Name.Known.Factory) = + PlaylistImpl(Music.UID.auxio(MusicType.PLAYLISTS), nameFactory.parse(name, null), songs) /** * Populate a new instance from a read [RawPlaylist]. * * @param rawPlaylist The [RawPlaylist] to read from. * @param deviceLibrary The [DeviceLibrary] to initialize from. - * @param musicSettings [MusicSettings] required for name configuration. + * @param nameFactory The [Name.Known.Factory] to interpret name information with. */ fun fromRaw( rawPlaylist: RawPlaylist, deviceLibrary: DeviceLibrary, - musicSettings: MusicSettings + nameFactory: Name.Known.Factory ) = PlaylistImpl( rawPlaylist.playlistInfo.playlistUid, - Name.Known.from(rawPlaylist.playlistInfo.name, null, musicSettings), + nameFactory.parse(rawPlaylist.playlistInfo.name, null), rawPlaylist.songs.mapNotNull { deviceLibrary.findSong(it.songUid) }) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt index 06de6d64f..faae9594b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/user/UserLibrary.kt @@ -26,6 +26,7 @@ import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Playlist import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.device.DeviceLibrary +import org.oxycblt.auxio.music.info.Name import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logE @@ -144,7 +145,9 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus UserLibrary.Factory { override suspend fun query() = try { - playlistDao.readRawPlaylists() + val rawPlaylists = playlistDao.readRawPlaylists() + logD("Successfully read ${rawPlaylists.size} playlists") + rawPlaylists } catch (e: Exception) { logE("Unable to read playlists: $e") listOf() @@ -154,11 +157,10 @@ constructor(private val playlistDao: PlaylistDao, private val musicSettings: Mus rawPlaylists: List, deviceLibrary: DeviceLibrary ): MutableUserLibrary { - logD("Successfully read ${rawPlaylists.size} playlists") - // Convert the database playlist information to actual usable playlists. + val nameFactory = Name.Known.Factory.from(musicSettings) val playlistMap = mutableMapOf() for (rawPlaylist in rawPlaylists) { - val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, musicSettings) + val playlistImpl = PlaylistImpl.fromRaw(rawPlaylist, deviceLibrary, nameFactory) playlistMap[playlistImpl.uid] = playlistImpl } return UserLibraryImpl(playlistDao, playlistMap, musicSettings) @@ -184,7 +186,7 @@ private class UserLibraryImpl( override fun findPlaylist(name: String) = playlistMap.values.find { it.name.raw == name } override suspend fun createPlaylist(name: String, songs: List): Playlist? { - val playlistImpl = PlaylistImpl.from(name, songs, musicSettings) + val playlistImpl = PlaylistImpl.from(name, songs, Name.Known.Factory.from(musicSettings)) synchronized(this) { playlistMap[playlistImpl.uid] = playlistImpl } val rawPlaylist = RawPlaylist( @@ -207,7 +209,9 @@ private class UserLibraryImpl( val playlistImpl = synchronized(this) { requireNotNull(playlistMap[playlist.uid]) { "Cannot rename invalid playlist" } - .also { playlistMap[it.uid] = it.edit(name, musicSettings) } + .also { + playlistMap[it.uid] = it.edit(name, Name.Known.Factory.from(musicSettings)) + } } return try { diff --git a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt index f23f76f16..fd80d51c4 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/info/NameTest.kt @@ -22,342 +22,352 @@ import io.mockk.every import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue import org.junit.Test import org.oxycblt.auxio.music.MusicSettings class NameTest { - private fun mockIntelligentSorting(enabled: Boolean) = - mockk() { every { intelligentSorting } returns enabled } + @Test + fun name_simple_from_settings() { + val musicSettings = mockk { every { intelligentSorting } returns false } + assertTrue(Name.Known.Factory.from(musicSettings) is SimpleKnownName.Factory) + } @Test - fun name_from_simple_withoutPunct() { - val name = Name.Known.from("Loveless", null, mockIntelligentSorting(false)) + fun name_intelligent_from_settings() { + val musicSettings = mockk { every { intelligentSorting } returns true } + assertTrue(Name.Known.Factory.from(musicSettings) is IntelligentKnownName.Factory) + } + + @Test + fun name_simple_withoutPunct() { + val name = SimpleKnownName("Loveless", null) assertEquals("Loveless", name.raw) assertEquals(null, name.sort) assertEquals("L", name.thumb) val only = name.sortTokens.single() assertEquals("Loveless", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_simple_withPunct() { - val name = Name.Known.from("alt-J", null, mockIntelligentSorting(false)) + fun name_simple_withPunct() { + val name = SimpleKnownName("alt-J", null) assertEquals("alt-J", name.raw) assertEquals(null, name.sort) assertEquals("A", name.thumb) val only = name.sortTokens.single() assertEquals("altJ", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_simple_oopsAllPunct() { - val name = Name.Known.from("!!!", null, mockIntelligentSorting(false)) + fun name_simple_oopsAllPunct() { + val name = SimpleKnownName("!!!", null) assertEquals("!!!", name.raw) assertEquals(null, name.sort) assertEquals("!", name.thumb) val only = name.sortTokens.single() assertEquals("!!!", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_simple_spacedPunct() { - val name = Name.Known.from("& Yet & Yet", null, mockIntelligentSorting(false)) + fun name_simple_spacedPunct() { + val name = SimpleKnownName("& Yet & Yet", null) assertEquals("& Yet & Yet", name.raw) assertEquals(null, name.sort) assertEquals("Y", name.thumb) val first = name.sortTokens[0] assertEquals("Yet Yet", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_simple_withSort() { - val name = Name.Known.from("The Smile", "Smile", mockIntelligentSorting(false)) + fun name_simple_withSort() { + val name = SimpleKnownName("The Smile", "Smile") assertEquals("The Smile", name.raw) assertEquals("Smile", name.sort) assertEquals("S", name.thumb) val only = name.sortTokens.single() assertEquals("Smile", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withoutNumerics() { - val name = Name.Known.from("Loveless", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withoutNumerics() { + val name = IntelligentKnownName("Loveless", null) assertEquals("Loveless", name.raw) assertEquals(null, name.sort) assertEquals("L", name.thumb) val only = name.sortTokens.single() assertEquals("Loveless", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { - val name = Name.Known.from("15 Step", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withSpacedStartNumerics() { + val name = IntelligentKnownName("15 Step", null) assertEquals("15 Step", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) val first = name.sortTokens[0] assertEquals("15", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + assertEquals(SortToken.Type.NUMERIC, first.type) val second = name.sortTokens[1] assertEquals("Step", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { - val name = Name.Known.from("23Kid", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withPackedStartNumerics() { + val name = IntelligentKnownName("23Kid", null) assertEquals("23Kid", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) val first = name.sortTokens[0] assertEquals("23", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + assertEquals(SortToken.Type.NUMERIC, first.type) val second = name.sortTokens[1] assertEquals("Kid", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { - val name = Name.Known.from("Foo 1 2 Bar", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withSpacedMiddleNumerics() { + val name = IntelligentKnownName("Foo 1 2 Bar", null) assertEquals("Foo 1 2 Bar", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) val first = name.sortTokens[0] assertEquals("Foo", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("1", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) val third = name.sortTokens[2] assertEquals(" ", third.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) val fourth = name.sortTokens[3] assertEquals("2", fourth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, fourth.type) + assertEquals(SortToken.Type.NUMERIC, fourth.type) val fifth = name.sortTokens[4] assertEquals("Bar", fifth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, fifth.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, fifth.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { - val name = Name.Known.from("Foo12Bar", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withPackedMiddleNumerics() { + val name = IntelligentKnownName("Foo12Bar", null) assertEquals("Foo12Bar", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) val first = name.sortTokens[0] assertEquals("Foo", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("12", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) val third = name.sortTokens[2] assertEquals("Bar", third.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { - val name = Name.Known.from("Foo 1", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withSpacedEndNumerics() { + val name = IntelligentKnownName("Foo 1", null) assertEquals("Foo 1", name.raw) assertEquals(null, name.sort) assertEquals("F", name.thumb) val first = name.sortTokens[0] assertEquals("Foo", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("1", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) } @Test - fun name_from_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { - val name = Name.Known.from("Error404", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withoutArticle_withPackedEndNumerics() { + val name = IntelligentKnownName("Error404", null) assertEquals("Error404", name.raw) assertEquals(null, name.sort) assertEquals("E", name.thumb) val first = name.sortTokens[0] assertEquals("Error", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("404", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) } @Test - fun name_from_intelligent_withoutPunct_withThe_withoutNumerics() { - val name = Name.Known.from("The National Anthem", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withThe_withoutNumerics() { + val name = IntelligentKnownName("The National Anthem", null) assertEquals("The National Anthem", name.raw) assertEquals(null, name.sort) assertEquals("N", name.thumb) val first = name.sortTokens[0] assertEquals("National Anthem", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_intelligent_withoutPunct_withAn_withoutNumerics() { - val name = Name.Known.from("An Eagle in Your Mind", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withAn_withoutNumerics() { + val name = IntelligentKnownName("An Eagle in Your Mind", null) assertEquals("An Eagle in Your Mind", name.raw) assertEquals(null, name.sort) assertEquals("E", name.thumb) val first = name.sortTokens[0] assertEquals("Eagle in Your Mind", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_intelligent_withoutPunct_withA_withoutNumerics() { - val name = Name.Known.from("A Song For Our Fathers", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_withA_withoutNumerics() { + val name = IntelligentKnownName("A Song For Our Fathers", null) assertEquals("A Song For Our Fathers", name.raw) assertEquals(null, name.sort) assertEquals("S", name.thumb) val first = name.sortTokens[0] assertEquals("Song For Our Fathers", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_intelligent_withPunct_withoutArticle_withoutNumerics() { - val name = Name.Known.from("alt-J", null, mockIntelligentSorting(true)) + fun name_intelligent_withPunct_withoutArticle_withoutNumerics() { + val name = IntelligentKnownName("alt-J", null) assertEquals("alt-J", name.raw) assertEquals(null, name.sort) assertEquals("A", name.thumb) val only = name.sortTokens.single() assertEquals("altJ", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { - val name = Name.Known.from("!!!", null, mockIntelligentSorting(true)) + fun name_intelligent_oopsAllPunct_withoutArticle_withoutNumerics() { + val name = IntelligentKnownName("!!!", null) assertEquals("!!!", name.raw) assertEquals(null, name.sort) assertEquals("!", name.thumb) val only = name.sortTokens.single() assertEquals("!!!", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test - fun name_from_intelligent_withoutPunct_shortArticle_withNumerics() { - val name = Name.Known.from("the 1", null, mockIntelligentSorting(true)) + fun name_intelligent_withoutPunct_shortArticle_withNumerics() { + val name = IntelligentKnownName("the 1", null) assertEquals("the 1", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) val first = name.sortTokens[0] assertEquals("1", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + assertEquals(SortToken.Type.NUMERIC, first.type) } @Test - fun name_from_intelligent_spacedPunct_withoutArticle_withoutNumerics() { - val name = Name.Known.from("& Yet & Yet", null, mockIntelligentSorting(true)) + fun name_intelligent_spacedPunct_withoutArticle_withoutNumerics() { + val name = IntelligentKnownName("& Yet & Yet", null) assertEquals("& Yet & Yet", name.raw) assertEquals(null, name.sort) assertEquals("Y", name.thumb) val first = name.sortTokens[0] assertEquals("Yet Yet", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) } @Test - fun name_from_intelligent_withPunct_withoutArticle_withNumerics() { - val name = Name.Known.from("Design : 2 : 3", null, mockIntelligentSorting(true)) + fun name_intelligent_withPunct_withoutArticle_withNumerics() { + val name = IntelligentKnownName("Design : 2 : 3", null) assertEquals("Design : 2 : 3", name.raw) assertEquals(null, name.sort) assertEquals("D", name.thumb) val first = name.sortTokens[0] assertEquals("Design", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, first.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, first.type) val second = name.sortTokens[1] assertEquals("2", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, second.type) + assertEquals(SortToken.Type.NUMERIC, second.type) val third = name.sortTokens[2] assertEquals(" ", third.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, third.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, third.type) val fourth = name.sortTokens[3] assertEquals("3", fourth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, fourth.type) + assertEquals(SortToken.Type.NUMERIC, fourth.type) } @Test - fun name_from_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { - val name = Name.Known.from("2 + 2 = 5", null, mockIntelligentSorting(true)) + fun name_intelligent_oopsAllPunct_withoutArticle_oopsAllNumerics() { + val name = IntelligentKnownName("2 + 2 = 5", null) assertEquals("2 + 2 = 5", name.raw) assertEquals(null, name.sort) assertEquals("#", name.thumb) val first = name.sortTokens[0] assertEquals("2", first.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, first.type) + assertEquals(SortToken.Type.NUMERIC, first.type) val second = name.sortTokens[1] assertEquals(" ", second.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, second.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, second.type) val third = name.sortTokens[2] assertEquals("2", third.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, third.type) + assertEquals(SortToken.Type.NUMERIC, third.type) val fourth = name.sortTokens[3] assertEquals(" ", fourth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, fourth.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, fourth.type) val fifth = name.sortTokens[4] assertEquals("5", fifth.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.NUMERIC, fifth.type) + assertEquals(SortToken.Type.NUMERIC, fifth.type) } @Test - fun name_from_intelligent_withSort() { - val name = Name.Known.from("The Smile", "Smile", mockIntelligentSorting(true)) + fun name_intelligent_withSort() { + val name = IntelligentKnownName("The Smile", "Smile") assertEquals("The Smile", name.raw) assertEquals("Smile", name.sort) assertEquals("S", name.thumb) val only = name.sortTokens.single() assertEquals("Smile", only.collationKey.sourceString) - assertEquals(Name.Known.SortToken.Type.LEXICOGRAPHIC, only.type) + assertEquals(SortToken.Type.LEXICOGRAPHIC, only.type) } @Test fun name_equals_simple() { - val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) - val b = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) + val a = SimpleKnownName("The Same", "Same") + val b = SimpleKnownName("The Same", "Same") assertEquals(a, b) } @Test fun name_equals_differentSort() { - val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(false)) - val b = Name.Known.from("The Same", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("The Same", "Same") + val b = SimpleKnownName("The Same", null) assertNotEquals(a, b) assertNotEquals(a.hashCode(), b.hashCode()) } @Test fun name_equals_intelligent_differentTokens() { - val a = Name.Known.from("The Same", "Same", mockIntelligentSorting(true)) - val b = Name.Known.from("Same", "Same", mockIntelligentSorting(true)) + val a = IntelligentKnownName("The Same", "Same") + val b = IntelligentKnownName("Same", "Same") assertNotEquals(a, b) assertNotEquals(a.hashCode(), b.hashCode()) } @Test fun name_compareTo_simple_withoutSort_withoutArticle_withoutNumeric() { - val a = Name.Known.from("A", null, mockIntelligentSorting(false)) - val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("A", null) + val b = SimpleKnownName("B", null) assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_simple_withoutSort_withArticle_withoutNumeric() { - val a = Name.Known.from("A Brain in a Bottle", null, mockIntelligentSorting(false)) - val b = Name.Known.from("Acid Rain", null, mockIntelligentSorting(false)) - val c = Name.Known.from("Boralis / Contrastellar", null, mockIntelligentSorting(false)) - val d = Name.Known.from("Breathe In", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("A Brain in a Bottle", null) + val b = SimpleKnownName("Acid Rain", null) + val c = SimpleKnownName("Boralis / Contrastellar", null) + val d = SimpleKnownName("Breathe In", null) assertEquals(-1, a.compareTo(b)) assertEquals(-1, a.compareTo(c)) assertEquals(-1, a.compareTo(d)) @@ -365,40 +375,40 @@ class NameTest { @Test fun name_compareTo_simple_withSort_withoutArticle_withNumeric() { - val a = Name.Known.from("15 Step", null, mockIntelligentSorting(false)) - val b = Name.Known.from("128 Harps", null, mockIntelligentSorting(false)) - val c = Name.Known.from("1969", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("15 Step", null) + val b = SimpleKnownName("128 Harps", null) + val c = SimpleKnownName("1969", null) assertEquals(1, a.compareTo(b)) assertEquals(-1, a.compareTo(c)) } @Test fun name_compareTo_simple_withPartialSort() { - val a = Name.Known.from("A", "C", mockIntelligentSorting(false)) - val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("A", "C") + val b = SimpleKnownName("B", null) assertEquals(1, a.compareTo(b)) } @Test fun name_compareTo_simple_withSort() { - val a = Name.Known.from("D", "A", mockIntelligentSorting(false)) - val b = Name.Known.from("C", "B", mockIntelligentSorting(false)) + val a = SimpleKnownName("D", "A") + val b = SimpleKnownName("C", "B") assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withoutSort_withoutArticle_withoutNumeric() { - val a = Name.Known.from("A", null, mockIntelligentSorting(true)) - val b = Name.Known.from("B", null, mockIntelligentSorting(true)) + val a = IntelligentKnownName("A", null) + val b = IntelligentKnownName("B", null) assertEquals(-1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withoutSort_withArticle_withoutNumeric() { - val a = Name.Known.from("A Brain in a Bottle", null, mockIntelligentSorting(true)) - val b = Name.Known.from("Acid Rain", null, mockIntelligentSorting(true)) - val c = Name.Known.from("Boralis / Contrastellar", null, mockIntelligentSorting(true)) - val d = Name.Known.from("Breathe In", null, mockIntelligentSorting(true)) + val a = IntelligentKnownName("A Brain in a Bottle", null) + val b = IntelligentKnownName("Acid Rain", null) + val c = IntelligentKnownName("Boralis / Contrastellar", null) + val d = IntelligentKnownName("Breathe In", null) assertEquals(1, a.compareTo(b)) assertEquals(1, a.compareTo(c)) assertEquals(-1, a.compareTo(d)) @@ -406,9 +416,9 @@ class NameTest { @Test fun name_compareTo_intelligent_withoutSort_withoutArticle_withNumeric() { - val a = Name.Known.from("15 Step", null, mockIntelligentSorting(true)) - val b = Name.Known.from("128 Harps", null, mockIntelligentSorting(true)) - val c = Name.Known.from("1969", null, mockIntelligentSorting(true)) + val a = IntelligentKnownName("15 Step", null) + val b = IntelligentKnownName("128 Harps", null) + val c = IntelligentKnownName("1969", null) assertEquals(-1, a.compareTo(b)) assertEquals(-1, b.compareTo(c)) assertEquals(-2, a.compareTo(c)) @@ -416,15 +426,28 @@ class NameTest { @Test fun name_compareTo_intelligent_withPartialSort_withoutArticle_withoutNumeric() { - val a = Name.Known.from("A", "C", mockIntelligentSorting(false)) - val b = Name.Known.from("B", null, mockIntelligentSorting(false)) + val a = SimpleKnownName("A", "C") + val b = SimpleKnownName("B", null) assertEquals(1, a.compareTo(b)) } @Test fun name_compareTo_intelligent_withSort_withoutArticle_withoutNumeric() { - val a = Name.Known.from("D", "A", mockIntelligentSorting(true)) - val b = Name.Known.from("C", "B", mockIntelligentSorting(true)) + val a = IntelligentKnownName("D", "A") + val b = IntelligentKnownName("C", "B") + assertEquals(-1, a.compareTo(b)) + } + + @Test + fun name_unknown() { + val a = Name.Unknown(0) + assertEquals("?", a.thumb) + } + + @Test + fun name_compareTo_mixed() { + val a = Name.Unknown(0) + val b = IntelligentKnownName("A", null) assertEquals(-1, a.compareTo(b)) } } From 9a67a0d539e36e2a2919cec2cb20ceba946a9db2 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 15:38:51 -0600 Subject: [PATCH 23/25] util: use timber for logging This will make testing app components a lot easier since it removes the logging dependency used in most shared objects. --- app/build.gradle | 4 +++- app/src/main/java/org/oxycblt/auxio/Auxio.kt | 5 ++++ .../auxio/home/FlipFloatingActionButton.kt | 2 +- .../oxycblt/auxio/music/metadata/TagUtil.kt | 3 +++ .../playback/system/MediaSessionComponent.kt | 3 +-- .../oxycblt/auxio/search/SearchFragment.kt | 2 +- .../java/org/oxycblt/auxio/util/LogUtil.kt | 24 ++++++------------- 7 files changed, 21 insertions(+), 22 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 45fdff5ea..e0725339f 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -142,13 +142,15 @@ dependencies { implementation "com.google.dagger:hilt-android:$hilt_version" kapt "com.google.dagger:hilt-android-compiler:$hilt_version" + // Logging + implementation 'com.jakewharton.timber:timber:5.0.1' + // Testing debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.7" androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' - } spotless { diff --git a/app/src/main/java/org/oxycblt/auxio/Auxio.kt b/app/src/main/java/org/oxycblt/auxio/Auxio.kt index df737e4c2..ebcffb5e2 100644 --- a/app/src/main/java/org/oxycblt/auxio/Auxio.kt +++ b/app/src/main/java/org/oxycblt/auxio/Auxio.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeSettings import org.oxycblt.auxio.image.ImageSettings import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.ui.UISettings +import timber.log.Timber /** * A simple, rational music player for android. @@ -44,6 +45,10 @@ class Auxio : Application() { override fun onCreate() { super.onCreate() + if (BuildConfig.DEBUG) { + Timber.plant(Timber.DebugTree()) + } + // Migrate any settings that may have changed in an app update. imageSettings.migrate() playbackSettings.migrate() diff --git a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt index a03adccfd..c3cd4a82f 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/FlipFloatingActionButton.kt @@ -51,7 +51,7 @@ constructor( // Apply the new configuration possibly set in flipTo. This should occur even if // a flip was canceled by a hide. pendingConfig?.run { - this@FlipFloatingActionButton.logD("Applying pending configuration") + logD("Applying pending configuration") setImageResource(iconRes) contentDescription = context.getString(contentDescriptionRes) setOnClickListener(clickListener) diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt index c8fc5560a..f7465c73c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagUtil.kt @@ -24,6 +24,9 @@ import org.oxycblt.auxio.util.positiveOrNull // TODO: Remove the escaping checks, it's too expensive to do this for every single tag. +// TODO: I want to eventually be able to move a lot of this into TagWorker once I no longer have +// to deal with the cross-module dependencies of MediaStoreExtractor. + /** * Split a [String] by the given selector, automatically handling escaped characters that satisfy * the selector. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt index d3e7b8cda..1910b1a01 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/MediaSessionComponent.kt @@ -338,8 +338,7 @@ constructor( song, object : BitmapProvider.Target { override fun onCompleted(bitmap: Bitmap?) { - this@MediaSessionComponent.logD( - "Bitmap loaded, applying media session and posting notification") + logD("Bitmap loaded, applying media session and posting notification") builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ART, bitmap) builder.putBitmap(MediaMetadataCompat.METADATA_KEY_ALBUM_ART, bitmap) val metadata = builder.build() diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt index 48a436730..da74d66a2 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchFragment.kt @@ -119,7 +119,7 @@ class SearchFragment : ListFragment() { if (!launchedKeyboard) { // Auto-open the keyboard when this view is shown - this@SearchFragment.logD("Keyboard is not shown yet") + logD("Keyboard is not shown yet") showKeyboard(this) launchedKeyboard = true } diff --git a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt index f7418a61e..4b1f800b4 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt @@ -18,27 +18,24 @@ package org.oxycblt.auxio.util -import android.util.Log import org.oxycblt.auxio.BuildConfig - -// Shortcut functions for logging. -// Yes, I know timber exists but this does what I need. +import timber.log.Timber /** * Log an object to the debug channel. Automatically handles tags. * * @param obj The object to log. */ -fun Any.logD(obj: Any?) = logD("$obj") +fun logD(obj: Any?) = logD("$obj") /** * Log a string message to the debug channel. Automatically handles tags. * * @param msg The message to log. */ -fun Any.logD(msg: String) { +fun logD(msg: String) { if (BuildConfig.DEBUG && !copyleftNotice()) { - Log.d(autoTag, msg) + Timber.d(msg) } } @@ -47,21 +44,14 @@ fun Any.logD(msg: String) { * * @param msg The message to log. */ -fun Any.logW(msg: String) = Log.w(autoTag, msg) +fun logW(msg: String) = Timber.w(msg) /** * Log a string message to the error channel. Automatically handles tags. * * @param msg The message to log. */ -fun Any.logE(msg: String) = Log.e(autoTag, msg) - -/** - * The LogCat-suitable tag for this string. Consists of the object's name, or "Anonymous Object" if - * the object does not exist. - */ -private val Any.autoTag: String - get() = "Auxio.${this::class.simpleName ?: "Anonymous Object"}" +fun logE(msg: String) = Timber.e(msg) /** * Please don't plagiarize Auxio! You are free to remove this as long as you continue to keep your @@ -71,7 +61,7 @@ private val Any.autoTag: String private fun copyleftNotice(): Boolean { if (BuildConfig.APPLICATION_ID != "org.oxycblt.auxio" && BuildConfig.APPLICATION_ID != "org.oxycblt.auxio.debug") { - Log.d( + Timber.d( "Auxio Project", "Friendly reminder: Auxio is licensed under the " + "GPLv3 and all derivative apps must be made open source!") From 881fb58648397c38856d9223133e65992028b61b Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 15:57:53 -0600 Subject: [PATCH 24/25] music: consider settings in equality Make it so that music items are meaningfully different when they were created under different settings. This resolves an issue where music information would not correctly update when separators or intelligent sorting would change. Resolves #546. --- CHANGELOG.md | 6 ++ .../auxio/music/device/DeviceMusicImpl.kt | 68 ++++++++++++++----- .../java/org/oxycblt/auxio/music/info/Name.kt | 10 +-- 3 files changed, 61 insertions(+), 23 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fd76d375..f3645510f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## dev + +#### What's Fixed +- Fixed app restart being required when changing intelligent sorting +or music separator settings + ## 3.2.0 #### What's New diff --git a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt index d513fcbff..d9f381ec9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/device/DeviceMusicImpl.kt @@ -53,8 +53,8 @@ import org.oxycblt.auxio.util.update */ class SongImpl( private val rawSong: RawSong, - nameFactory: Name.Known.Factory, - separators: Separators + private val nameFactory: Name.Known.Factory, + private val separators: Separators ) : Song { override val uid = // Attempt to use a MusicBrainz ID first before falling back to a hashed UID. @@ -110,17 +110,6 @@ class SongImpl( override val genres: List get() = _genres - private val hashCode = 31 * uid.hashCode() + rawSong.hashCode() - - override fun hashCode() = hashCode - - // TODO: I cant compare by raw information actually, as it also means that any settings - // configuration will be lost as well. - override fun equals(other: Any?) = - other is SongImpl && uid == other.uid && rawSong == other.rawSong - - override fun toString() = "Song(uid=$uid, name=$name)" - /** * The [RawAlbum] instances collated by the [Song]. This can be used to group [Song]s into an * [Album]. @@ -140,6 +129,8 @@ class SongImpl( */ val rawGenres: List + private var hashCode: Int = uid.hashCode() + init { val artistMusicBrainzIds = separators.split(rawSong.artistMusicBrainzIds) val artistNames = separators.split(rawSong.artistNames) @@ -190,8 +181,24 @@ class SongImpl( .mapTo(mutableSetOf()) { RawGenre(it) } .toList() .ifEmpty { listOf(RawGenre()) } + + hashCode = 31 * rawSong.hashCode() + hashCode = 31 * nameFactory.hashCode() } + override fun hashCode() = hashCode + + // Since equality on public-facing music models is not identical to the tag equality, + // we just compare raw instances and how they are interpreted. + override fun equals(other: Any?) = + other is SongImpl && + uid == other.uid && + nameFactory == other.nameFactory && + separators == other.separators && + rawSong == other.rawSong + + override fun toString() = "Song(uid=$uid, name=$name)" + /** * Links this [Song] with a parent [Album]. * @@ -259,7 +266,10 @@ class SongImpl( * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class AlbumImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Album { +class AlbumImpl( + grouping: Grouping, + private val nameFactory: Name.Known.Factory +) : Album { private val rawAlbum = grouping.raw.inner override val uid = @@ -322,13 +332,20 @@ class AlbumImpl(grouping: Grouping, nameFactory: Name.Known. dateAdded = earliestDateAdded hashCode = 31 * hashCode + rawAlbum.hashCode() + hashCode = 31 * nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } override fun hashCode() = hashCode + // Since equality on public-facing music models is not identical to the tag equality, + // we just compare raw instances and how they are interpreted. override fun equals(other: Any?) = - other is AlbumImpl && uid == other.uid && rawAlbum == other.rawAlbum && songs == other.songs + other is AlbumImpl && + uid == other.uid && + rawAlbum == other.rawAlbum && + nameFactory == other.nameFactory && + songs == other.songs override fun toString() = "Album(uid=$uid, name=$name)" @@ -376,7 +393,10 @@ class AlbumImpl(grouping: Grouping, nameFactory: Name.Known. * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Artist { +class ArtistImpl( + grouping: Grouping, + private val nameFactory: Name.Known.Factory +) : Artist { private val rawArtist = grouping.raw.inner override val uid = @@ -425,6 +445,7 @@ class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.F durationMs = songs.sumOf { it.durationMs }.positiveOrNull() hashCode = 31 * hashCode + rawArtist.hashCode() + hashCode = 31 * hashCode + nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } @@ -432,10 +453,13 @@ class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.F // the same UID but different songs are not equal. override fun hashCode() = hashCode + // Since equality on public-facing music models is not identical to the tag equality, + // we just compare raw instances and how they are interpreted. override fun equals(other: Any?) = other is ArtistImpl && uid == other.uid && rawArtist == other.rawArtist && + nameFactory == other.nameFactory && songs == other.songs override fun toString() = "Artist(uid=$uid, name=$name)" @@ -473,7 +497,10 @@ class ArtistImpl(grouping: Grouping, nameFactory: Name.Known.F * @param nameFactory The [Name.Known.Factory] to interpret name information with. * @author Alexander Capehart (OxygenCobalt) */ -class GenreImpl(grouping: Grouping, nameFactory: Name.Known.Factory) : Genre { +class GenreImpl( + grouping: Grouping, + private val nameFactory: Name.Known.Factory +) : Genre { private val rawGenre = grouping.raw.inner override val uid = Music.UID.auxio(MusicType.GENRES) { update(rawGenre.name) } @@ -502,13 +529,18 @@ class GenreImpl(grouping: Grouping, nameFactory: Name.Known. durationMs = totalDuration hashCode = 31 * hashCode + rawGenre.hashCode() + hashCode = 31 * nameFactory.hashCode() hashCode = 31 * hashCode + songs.hashCode() } override fun hashCode() = hashCode override fun equals(other: Any?) = - other is GenreImpl && uid == other.uid && rawGenre == other.rawGenre && songs == other.songs + other is GenreImpl && + uid == other.uid && + rawGenre == other.rawGenre && + nameFactory == other.nameFactory && + songs == other.songs override fun toString() = "Genre(uid=$uid, name=$name)" diff --git a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt index b0f0b029d..09f4d8035 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/info/Name.kt @@ -95,13 +95,13 @@ sealed interface Name : Comparable { * user-defined name configuration. * * @param settings The [MusicSettings] to use. - * @return A new [Factory] instance reflecting the configuration state. + * @return A [Factory] instance reflecting the configuration state. */ fun from(settings: MusicSettings) = if (settings.intelligentSorting) { - IntelligentKnownName.Factory() + IntelligentKnownName.Factory } else { - SimpleKnownName.Factory() + SimpleKnownName.Factory } } } @@ -149,7 +149,7 @@ data class SimpleKnownName(override val raw: String, override val sort: String?) return SortToken(collationKey, SortToken.Type.LEXICOGRAPHIC) } - class Factory : Name.Known.Factory { + data object Factory : Name.Known.Factory { override fun parse(raw: String, sort: String?) = SimpleKnownName(raw, sort) } } @@ -208,7 +208,7 @@ data class IntelligentKnownName(override val raw: String, override val sort: Str } } - class Factory : Name.Known.Factory { + data object Factory : Name.Known.Factory { override fun parse(raw: String, sort: String?) = IntelligentKnownName(raw, sort) } From ad672ed919dbbff9819454fcae575fe531a3498c Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Fri, 18 Aug 2023 19:24:13 -0600 Subject: [PATCH 25/25] music: add cache repository test Add tests for the cache repository and cache data structure. --- app/build.gradle | 2 + .../java/org/oxycblt/auxio/StubTest.kt | 40 --- .../auxio/music/cache/CacheDatabase.kt | 4 +- .../auxio/music/metadata/Separators.kt | 1 + .../auxio/music/cache/CacheRepositoryTest.kt | 266 ++++++++++++++++++ 5 files changed, 271 insertions(+), 42 deletions(-) delete mode 100644 app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt create mode 100644 app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt diff --git a/app/build.gradle b/app/build.gradle index e0725339f..5559d0b2b 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -149,6 +149,8 @@ dependencies { debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12' testImplementation "junit:junit:4.13.2" testImplementation "io.mockk:mockk:1.13.7" + testImplementation "org.robolectric:robolectric:4.9" + testImplementation 'androidx.test:core-ktx:1.5.0' androidTestImplementation 'androidx.test.ext:junit:1.1.5' androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' } diff --git a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt b/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt deleted file mode 100644 index a0ba54a3d..000000000 --- a/app/src/androidTest/java/org/oxycblt/auxio/StubTest.kt +++ /dev/null @@ -1,40 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * StubTest.kt is part of Auxio. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package org.oxycblt.auxio - -import androidx.test.ext.junit.runners.AndroidJUnit4 -import androidx.test.platform.app.InstrumentationRegistry -import org.junit.Assert.* -import org.junit.Test -import org.junit.runner.RunWith - -/** - * Instrumented test, which will execute on an Android device. - * - * See [testing documentation](http://d.android.com/tools/testing). - */ -@RunWith(AndroidJUnit4::class) -class StubTest { - // TODO: Make tests - @Test - fun useAppContext() { - val appContext = InstrumentationRegistry.getInstrumentation().targetContext - assertEquals("org.oxycblt.auxio.debug", appContext.packageName) - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index d28547239..2e3e8a944 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -63,9 +63,9 @@ data class CachedSong( /** @see RawSong */ var durationMs: Long, /** @see RawSong.replayGainTrackAdjustment */ - val replayGainTrackAdjustment: Float?, + val replayGainTrackAdjustment: Float? = null, /** @see RawSong.replayGainAlbumAdjustment */ - val replayGainAlbumAdjustment: Float?, + val replayGainAlbumAdjustment: Float? = null, /** @see RawSong.musicBrainzId */ var musicBrainzId: String? = null, /** @see RawSong.name */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt index 989a7b128..8d2740e74 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/Separators.kt @@ -43,6 +43,7 @@ interface Separators { const val SLASH = '/' const val PLUS = '+' const val AND = '&' + /** * Creates a new instance from the **current state** of the given [MusicSettings]'s * user-defined separator configuration. diff --git a/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt new file mode 100644 index 000000000..9914dbe5f --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt @@ -0,0 +1,266 @@ +/* + * Copyright (c) 2023 Auxio Project + * CacheRepositoryTest.kt is part of Auxio. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.cache + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerifyAll +import io.mockk.coVerifySequence +import io.mockk.just +import io.mockk.mockk +import io.mockk.slot +import java.lang.IllegalStateException +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.music.info.Date + +class CacheRepositoryTest { + @Test + fun cache_read_noInvalidate() { + val dao = + mockk { + coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) + } + val cacheRepository = CacheRepositoryImpl(dao) + val cache = requireNotNull(runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + assertFalse(cache.invalidated) + + val songA = RawSong(mediaStoreId = 0, dateAdded = 1, dateModified = 2) + assertTrue(cache.populate(songA)) + assertEquals(RAW_SONG_A, songA) + + assertFalse(cache.invalidated) + + val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11) + assertTrue(cache.populate(songB)) + assertEquals(RAW_SONG_B, songB) + + assertFalse(cache.invalidated) + } + + @Test + fun cache_read_invalidate() { + val dao = + mockk { + coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) + } + val cacheRepository = CacheRepositoryImpl(dao) + val cache = requireNotNull(runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + assertFalse(cache.invalidated) + + val nullStart = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0) + val nullEnd = RawSong(mediaStoreId = 0, dateAdded = 0, dateModified = 0) + assertFalse(cache.populate(nullStart)) + assertEquals(nullStart, nullEnd) + + assertTrue(cache.invalidated) + + val songB = RawSong(mediaStoreId = 9, dateAdded = 10, dateModified = 11) + assertTrue(cache.populate(songB)) + assertEquals(RAW_SONG_B, songB) + + assertTrue(cache.invalidated) + } + + @Test + fun cache_read_crashes() { + val dao = mockk { coEvery { readSongs() } throws IllegalStateException() } + val cacheRepository = CacheRepositoryImpl(dao) + assertEquals(null, runBlocking { cacheRepository.readCache() }) + coVerifyAll { dao.readSongs() } + } + + @Test + fun cache_write() { + var currentlyStoredSongs = listOf() + val insertSongsArg = slot>() + val dao = + mockk { + coEvery { nukeSongs() } answers { currentlyStoredSongs = listOf() } + + coEvery { insertSongs(capture(insertSongsArg)) } answers + { + currentlyStoredSongs = insertSongsArg.captured + } + } + + val cacheRepository = CacheRepositoryImpl(dao) + + val rawSongs = listOf(RAW_SONG_A, RAW_SONG_B) + runBlocking { cacheRepository.writeCache(rawSongs) } + + val cachedSongs = listOf(CACHED_SONG_A, CACHED_SONG_B) + coVerifySequence { + dao.nukeSongs() + dao.insertSongs(cachedSongs) + } + assertEquals(cachedSongs, currentlyStoredSongs) + } + + @Test + fun cache_write_nukeCrashes() { + val dao = + mockk { + coEvery { nukeSongs() } throws IllegalStateException() + coEvery { insertSongs(listOf()) } just Runs + } + val cacheRepository = CacheRepositoryImpl(dao) + runBlocking { cacheRepository.writeCache(listOf()) } + coVerifyAll { dao.nukeSongs() } + } + + @Test + fun cache_write_insertCrashes() { + val dao = + mockk { + coEvery { nukeSongs() } just Runs + coEvery { insertSongs(listOf()) } throws IllegalStateException() + } + val cacheRepository = CacheRepositoryImpl(dao) + runBlocking { cacheRepository.writeCache(listOf()) } + coVerifySequence { + dao.nukeSongs() + dao.insertSongs(listOf()) + } + } + + private companion object { + val CACHED_SONG_A = + CachedSong( + mediaStoreId = 0, + dateAdded = 1, + dateModified = 2, + size = 3, + durationMs = 4, + replayGainTrackAdjustment = 5.5f, + replayGainAlbumAdjustment = 6.6f, + musicBrainzId = "Song MBID A", + name = "Song Name A", + sortName = "Song Sort Name A", + track = 7, + disc = 8, + subtitle = "Subtitle A", + date = Date.from("2020-10-10"), + albumMusicBrainzId = "Album MBID A", + albumName = "Album Name A", + albumSortName = "Album Sort Name A", + releaseTypes = listOf("Release Type A"), + artistMusicBrainzIds = listOf("Artist MBID A"), + artistNames = listOf("Artist Name A"), + artistSortNames = listOf("Artist Sort Name A"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID A"), + albumArtistNames = listOf("Album Artist Name A"), + albumArtistSortNames = listOf("Album Artist Sort Name A"), + genreNames = listOf("Genre Name A"), + ) + + val RAW_SONG_A = + RawSong( + mediaStoreId = 0, + dateAdded = 1, + dateModified = 2, + size = 3, + durationMs = 4, + replayGainTrackAdjustment = 5.5f, + replayGainAlbumAdjustment = 6.6f, + musicBrainzId = "Song MBID A", + name = "Song Name A", + sortName = "Song Sort Name A", + track = 7, + disc = 8, + subtitle = "Subtitle A", + date = Date.from("2020-10-10"), + albumMusicBrainzId = "Album MBID A", + albumName = "Album Name A", + albumSortName = "Album Sort Name A", + releaseTypes = listOf("Release Type A"), + artistMusicBrainzIds = listOf("Artist MBID A"), + artistNames = listOf("Artist Name A"), + artistSortNames = listOf("Artist Sort Name A"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID A"), + albumArtistNames = listOf("Album Artist Name A"), + albumArtistSortNames = listOf("Album Artist Sort Name A"), + genreNames = listOf("Genre Name A"), + ) + + val CACHED_SONG_B = + CachedSong( + mediaStoreId = 9, + dateAdded = 10, + dateModified = 11, + size = 12, + durationMs = 13, + replayGainTrackAdjustment = 14.14f, + replayGainAlbumAdjustment = 15.15f, + musicBrainzId = "Song MBID B", + name = "Song Name B", + sortName = "Song Sort Name B", + track = 16, + disc = 17, + subtitle = "Subtitle B", + date = Date.from("2021-11-11"), + albumMusicBrainzId = "Album MBID B", + albumName = "Album Name B", + albumSortName = "Album Sort Name B", + releaseTypes = listOf("Release Type B"), + artistMusicBrainzIds = listOf("Artist MBID B"), + artistNames = listOf("Artist Name B"), + artistSortNames = listOf("Artist Sort Name B"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID B"), + albumArtistNames = listOf("Album Artist Name B"), + albumArtistSortNames = listOf("Album Artist Sort Name B"), + genreNames = listOf("Genre Name B"), + ) + + val RAW_SONG_B = + RawSong( + mediaStoreId = 9, + dateAdded = 10, + dateModified = 11, + size = 12, + durationMs = 13, + replayGainTrackAdjustment = 14.14f, + replayGainAlbumAdjustment = 15.15f, + musicBrainzId = "Song MBID B", + name = "Song Name B", + sortName = "Song Sort Name B", + track = 16, + disc = 17, + subtitle = "Subtitle B", + date = Date.from("2021-11-11"), + albumMusicBrainzId = "Album MBID B", + albumName = "Album Name B", + albumSortName = "Album Sort Name B", + releaseTypes = listOf("Release Type B"), + artistMusicBrainzIds = listOf("Artist MBID B"), + artistNames = listOf("Artist Name B"), + artistSortNames = listOf("Artist Sort Name B"), + albumArtistMusicBrainzIds = listOf("Album Artist MBID B"), + albumArtistNames = listOf("Album Artist Name B"), + albumArtistSortNames = listOf("Album Artist Sort Name B"), + genreNames = listOf("Genre Name B"), + ) + } +}