diff --git a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt index af6b1ca4d..050b5de16 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -36,7 +36,6 @@ import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.ReleaseType @@ -57,7 +56,7 @@ constructor( private val audioInfoProvider: AudioInfo.Provider, private val musicSettings: MusicSettings, private val playbackSettings: PlaybackSettings -) : ViewModel(), MusicRepository.Listener { +) : ViewModel(), MusicRepository.UpdateListener { private var currentSongJob: Job? = null // --- SONG --- @@ -152,18 +151,16 @@ constructor( get() = playbackSettings.inParentPlaybackMode init { - musicRepository.addListener(this) + musicRepository.addUpdateListener(this) } override fun onCleared() { - musicRepository.removeListener(this) + musicRepository.removeUpdateListener(this) } - override fun onLibraryChanged(library: Library?) { - if (library == null) { - // Nothing to do. - return - } + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.library) return + val library = musicRepository.library ?: return // If we are showing any item right now, we will need to refresh it (and any information // related to it) with the new library in order to prevent stale items from showing up 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 f20b31a8f..0b1bb4824 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -55,8 +55,6 @@ import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.selection.SelectionFragment import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library -import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.MainNavigationAction import org.oxycblt.auxio.ui.NavigationViewModel @@ -158,7 +156,7 @@ class HomeFragment : collect(homeModel.recreateTabs.flow, ::handleRecreate) collectImmediately(homeModel.currentTabMode, ::updateCurrentTab) collectImmediately(homeModel.songsList, homeModel.isFastScrolling, ::updateFab) - collectImmediately(musicModel.indexerState, ::updateIndexerState) + collectImmediately(musicModel.indexingState, ::updateIndexerState) collect(navModel.exploreNavigationItem.flow, ::handleNavigation) collectImmediately(selectionModel.selected, ::updateSelection) } @@ -340,14 +338,14 @@ class HomeFragment : homeModel.recreateTabs.consume() } - private fun updateIndexerState(state: Indexer.State?) { + private fun updateIndexerState(state: IndexingState?) { // TODO: Make music loading experience a bit more pleasant // 1. Loading placeholder for item lists // 2. Rework the "No Music" case to not be an error and instead result in a placeholder val binding = requireBinding() when (state) { - is Indexer.State.Complete -> setupCompleteState(binding, state.result) - is Indexer.State.Indexing -> setupIndexingState(binding, state.indexing) + is IndexingState.Completed -> setupCompleteState(binding, state.error) + is IndexingState.Indexing -> setupIndexingState(binding, state.progress) null -> { logD("Indexer is in indeterminate state") binding.homeIndexingContainer.visibility = View.INVISIBLE @@ -355,77 +353,77 @@ class HomeFragment : } } - private fun setupCompleteState(binding: FragmentHomeBinding, result: Result) { - if (result.isSuccess) { + private fun setupCompleteState(binding: FragmentHomeBinding, error: Throwable?) { + if (error == null) { logD("Received ok response") binding.homeFab.show() binding.homeIndexingContainer.visibility = View.INVISIBLE - } else { - logD("Received non-ok response") - val context = requireContext() - val throwable = unlikelyToBeNull(result.exceptionOrNull()) - binding.homeIndexingContainer.visibility = View.VISIBLE - binding.homeIndexingProgress.visibility = View.INVISIBLE - when (throwable) { - is Indexer.NoPermissionException -> { - logD("Updating UI to permission request state") - 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 - text = context.getString(R.string.lbl_grant) - setOnClickListener { - requireNotNull(storagePermissionLauncher) { - "Permission launcher was not available" - } - .launch(Indexer.PERMISSION_READ_AUDIO) - } + return + } + + logD("Received non-ok response") + val context = requireContext() + binding.homeIndexingContainer.visibility = View.VISIBLE + binding.homeIndexingProgress.visibility = View.INVISIBLE + when (error) { + is NoAudioPermissionException -> { + logD("Updating UI to permission request state") + 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 + text = context.getString(R.string.lbl_grant) + setOnClickListener { + requireNotNull(storagePermissionLauncher) { + "Permission launcher was not available" + } + .launch(PERMISSION_READ_AUDIO) } } - is Indexer.NoMusicException -> { - logD("Updating UI to no music state") - binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) - // Configure the action to act as a reload trigger. - binding.homeIndexingAction.apply { - visibility = View.VISIBLE - text = context.getString(R.string.lbl_retry) - setOnClickListener { musicModel.refresh() } - } + } + is NoMusicException -> { + logD("Updating UI to no music state") + binding.homeIndexingStatus.text = context.getString(R.string.err_no_music) + // Configure the action to act as a reload trigger. + binding.homeIndexingAction.apply { + visibility = View.VISIBLE + text = context.getString(R.string.lbl_retry) + setOnClickListener { musicModel.refresh() } } - else -> { - logD("Updating UI to error state") - binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) - // Configure the action to act as a reload trigger. - binding.homeIndexingAction.apply { - visibility = View.VISIBLE - text = context.getString(R.string.lbl_retry) - setOnClickListener { musicModel.rescan() } - } + } + else -> { + logD("Updating UI to error state") + binding.homeIndexingStatus.text = context.getString(R.string.err_index_failed) + // Configure the action to act as a reload trigger. + binding.homeIndexingAction.apply { + visibility = View.VISIBLE + text = context.getString(R.string.lbl_retry) + setOnClickListener { musicModel.rescan() } } } } } - private fun setupIndexingState(binding: FragmentHomeBinding, indexing: Indexer.Indexing) { + private fun setupIndexingState(binding: FragmentHomeBinding, progress: IndexingProgress) { // Remove all content except for the progress indicator. binding.homeIndexingContainer.visibility = View.VISIBLE binding.homeIndexingProgress.visibility = View.VISIBLE binding.homeIndexingAction.visibility = View.INVISIBLE - when (indexing) { - is Indexer.Indexing.Indeterminate -> { + when (progress) { + is IndexingProgress.Indeterminate -> { // In a query/initialization state, show a generic loading status. binding.homeIndexingStatus.text = getString(R.string.lng_indexing) binding.homeIndexingProgress.isIndeterminate = true } - is Indexer.Indexing.Songs -> { + is IndexingProgress.Songs -> { // Actively loading songs, show the current progress. binding.homeIndexingStatus.text = - getString(R.string.fmt_indexing, indexing.current, indexing.total) + getString(R.string.fmt_indexing, progress.current, progress.total) binding.homeIndexingProgress.apply { isIndeterminate = false - max = indexing.total - progress = indexing.current + max = progress.total + this.progress = progress.current } } } diff --git a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt index c7c3f6e1d..081343cdb 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -27,7 +27,6 @@ import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.UpdateInstructions import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.Event import org.oxycblt.auxio.util.MutableEvent @@ -46,7 +45,7 @@ constructor( private val playbackSettings: PlaybackSettings, private val musicRepository: MusicRepository, private val musicSettings: MusicSettings -) : ViewModel(), MusicRepository.Listener, HomeSettings.Listener { +) : ViewModel(), MusicRepository.UpdateListener, HomeSettings.Listener { private val _songsList = MutableStateFlow(listOf()) /** A list of [Song]s, sorted by the preferred [Sort], to be shown in the home view. */ @@ -117,37 +116,37 @@ constructor( val isFastScrolling: StateFlow = _isFastScrolling init { - musicRepository.addListener(this) + musicRepository.addUpdateListener(this) homeSettings.registerListener(this) } override fun onCleared() { super.onCleared() - musicRepository.removeListener(this) + musicRepository.removeUpdateListener(this) homeSettings.unregisterListener(this) } - override fun onLibraryChanged(library: Library?) { - if (library != null) { - logD("Library changed, refreshing library") - // Get the each list of items in the library to use as our list data. - // Applying the preferred sorting to them. - _songsInstructions.put(UpdateInstructions.Diff) - _songsList.value = musicSettings.songSort.songs(library.songs) - _albumsInstructions.put(UpdateInstructions.Diff) - _albumsLists.value = musicSettings.albumSort.albums(library.albums) - _artistsInstructions.put(UpdateInstructions.Diff) - _artistsList.value = - musicSettings.artistSort.artists( - if (homeSettings.shouldHideCollaborators) { - // Hide Collaborators is enabled, filter out collaborators. - library.artists.filter { !it.isCollaborator } - } else { - library.artists - }) - _genresInstructions.put(UpdateInstructions.Diff) - _genresList.value = musicSettings.genreSort.genres(library.genres) - } + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.library) return + val library = musicRepository.library ?: return + logD("Library changed, refreshing library") + // Get the each list of items in the library to use as our list data. + // Applying the preferred sorting to them. + _songsInstructions.put(UpdateInstructions.Diff) + _songsList.value = musicSettings.songSort.songs(library.songs) + _albumsInstructions.put(UpdateInstructions.Diff) + _albumsLists.value = musicSettings.albumSort.albums(library.albums) + _artistsInstructions.put(UpdateInstructions.Diff) + _artistsList.value = + musicSettings.artistSort.artists( + if (homeSettings.shouldHideCollaborators) { + // Hide Collaborators is enabled, filter out collaborators. + library.artists.filter { !it.isCollaborator } + } else { + library.artists + }) + _genresInstructions.put(UpdateInstructions.Diff) + _genresList.value = musicSettings.genreSort.genres(library.genres) } override fun onTabsChanged() { @@ -159,7 +158,7 @@ constructor( override fun onHideCollaboratorsChanged() { // Changes in the hide collaborator setting will change the artist contents // of the library, consider it a library update. - onLibraryChanged(musicRepository.library) + onMusicChanges(MusicRepository.Changes(library = true, playlists = false)) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt index 7971712ea..6104ebee1 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/selection/SelectionViewModel.kt @@ -24,7 +24,6 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library /** * A [ViewModel] that manages the current selection. @@ -33,21 +32,19 @@ import org.oxycblt.auxio.music.library.Library */ @HiltViewModel class SelectionViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.Listener { + ViewModel(), MusicRepository.UpdateListener { private val _selected = MutableStateFlow(listOf()) /** the currently selected items. These are ordered in earliest selected and latest selected. */ val selected: StateFlow> get() = _selected init { - musicRepository.addListener(this) + musicRepository.addUpdateListener(this) } - override fun onLibraryChanged(library: Library?) { - if (library == null) { - return - } - + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.library) return + val library = musicRepository.library ?: return // Sanitize the selection to remove items that no longer exist and thus // won't appear in any list. _selected.value = @@ -64,7 +61,7 @@ class SelectionViewModel @Inject constructor(private val musicRepository: MusicR override fun onCleared() { super.onCleared() - musicRepository.removeListener(this) + musicRepository.removeUpdateListener(this) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt new file mode 100644 index 000000000..7741136a8 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexing.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2023 Auxio Project + * Indexing.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.os.Build + +/** + * Version-aware permission identifier for reading audio files. + */ +val PERMISSION_READ_AUDIO = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + android.Manifest.permission.READ_MEDIA_AUDIO + } else { + android.Manifest.permission.READ_EXTERNAL_STORAGE + } + +/** + * Represents the current state of the music loader. + * @author Alexander Capehart (OxygenCobalt) + */ +sealed interface IndexingState { + /** + * Music loading is on-going. + * @param progress The current progress of the music loading. + */ + data class Indexing(val progress: IndexingProgress) : IndexingState + + /** + * Music loading has completed. + * @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 +} + +/** + * Represents the current progress of music loading. + * @author Alexander Capehart (OxygenCobalt) + */ +sealed interface IndexingProgress { + /** Other work is being done that does not have a defined progress. */ + object Indeterminate : IndexingProgress + + /** + * Songs are currently being loaded. + * @param current The current amount of songs loaded. + * @param total The projected total amount of songs. + */ + data class Songs(val current: Int, val total: Int) : IndexingProgress +} + +/** + * Thrown by the music loader when [PERMISSION_READ_AUDIO] was not granted. + * @author Alexander Capehart (OxygenCobalt) + */ +class NoAudioPermissionException : Exception() { + override val message = "Storage permissions are required to load music" +} + +/** + * Thrown when no music was found. + * @author Alexander Capehart (OxygenCobalt) + */ +class NoMusicException : Exception() { + override val message = "No music was found on the device" +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt index 58fd9b323..e875dd04b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt @@ -23,13 +23,10 @@ import dagger.Module import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -import org.oxycblt.auxio.music.system.Indexer -import org.oxycblt.auxio.music.system.IndexerImpl @Module @InstallIn(SingletonComponent::class) interface MusicModule { @Singleton @Binds fun repository(musicRepository: MusicRepositoryImpl): MusicRepository - @Singleton @Binds fun indexer(indexer: IndexerImpl): Indexer @Binds fun settings(musicSettingsImpl: MusicSettingsImpl): MusicSettings } 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 08e7e6452..07bbe4214 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -18,75 +18,327 @@ package org.oxycblt.auxio.music +import android.content.Context +import android.content.pm.PackageManager +import androidx.core.content.ContextCompat +import java.util.* import javax.inject.Inject +import kotlinx.coroutines.* +import kotlinx.coroutines.channels.Channel +import org.oxycblt.auxio.music.cache.CacheRepository import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.library.RawSong +import org.oxycblt.auxio.music.metadata.TagExtractor +import org.oxycblt.auxio.music.storage.MediaStoreExtractor +import org.oxycblt.auxio.util.logD +import org.oxycblt.auxio.util.logE +import org.oxycblt.auxio.util.logW /** - * A repository granting access to the music library. + * Primary manager of music information and loading. * - * This can be used to obtain certain music items, or await changes to the music library. It is - * generally recommended to use this over Indexer to keep track of the library state, as the - * interface will be less volatile. + * Music information is loaded in-memory by this repository using an [IndexingWorker]. + * Changes in music (loading) can be reacted to with [UpdateListener] and [IndexingListener]. * * @author Alexander Capehart (OxygenCobalt) */ interface MusicRepository { - /** - * The current [Library]. May be null if a [Library] has not been successfully loaded yet. This - * can change, so it's highly recommended to not access this directly and instead rely on - * [Listener]. - */ - var library: Library? + /** The current immutable music library loaded from the file-system. */ + val library: Library? + /** The current mutable user-defined playlists loaded from the file-system. */ + val playlists: List? + /** The current state of music loading. Null if no load has occurred yet. */ + val indexingState: IndexingState? /** - * Add a [Listener] to this instance. This can be used to receive changes in the music library. - * Will invoke all [Listener] methods to initialize the instance with the current state. - * - * @param listener The [Listener] to add. - * @see Listener + * Add an [UpdateListener] to receive updates from this instance. + * @param listener The [UpdateListener] to add. */ - fun addListener(listener: Listener) + fun addUpdateListener(listener: UpdateListener) /** - * Remove a [Listener] from this instance, preventing it from receiving any further updates. - * - * @param listener The [Listener] to remove. Does nothing if the [Listener] was never added in - * the first place. - * @see Listener + * Remove an [UpdateListener] such that it does not receive any further updates from this + * instance. + * @param listener The [UpdateListener] to remove. */ - fun removeListener(listener: Listener) + fun removeUpdateListener(listener: UpdateListener) - /** A listener for changes in [MusicRepository] */ - interface Listener { + /** + * Add an [IndexingListener] to receive updates from this instance. + * @param listener The [UpdateListener] to add. + */ + fun addIndexingListener(listener: IndexingListener) + + /** + * Remove an [IndexingListener] such that it does not receive any further updates from this + * instance. + * @param listener The [IndexingListener] to remove. + */ + fun removeIndexingListener(listener: IndexingListener) + + /** + * Register an [IndexingWorker] to handle loading operations. Will do nothing if one is already + * registered. + * @param worker The [IndexingWorker] to register. + */ + fun registerWorker(worker: IndexingWorker) + + /** + * Unregister an [IndexingWorker] and drop any work currently being done by it. Does nothing + * if given [IndexingWorker] is not the currently registered instance. + * @param worker The [IndexingWorker] to unregister. + */ + fun unregisterWorker(worker: IndexingWorker) + + /** + * Request that a music loading operation is started by the current [IndexingWorker]. Does + * nothing if one is not available. + * @param withCache Whether to load with the music cache or not. + */ + fun requestIndex(withCache: Boolean) + + /** + * Load the music library. Any prior loads will be canceled. + * @param worker The [IndexingWorker] to perform the work with. + * @param withCache Whether to load with the music cache or not. + * @return The top-level music loading [Job] started. + */ + fun index(worker: IndexingWorker, withCache: Boolean): Job + + /** + * A listener for changes to the stored music information. + */ + interface UpdateListener { /** - * Called when the current [Library] has changed. - * - * @param library The new [Library], or null if no [Library] has been loaded yet. + * Called when a change to the stored music information occurs. + * @param changes The [Changes] that have occured. */ - fun onLibraryChanged(library: Library?) + fun onMusicChanges(changes: Changes) + } + /** + * Flags indicating which kinds of music information changed. + * @param library Whether the current [Library] has changed. + * @param playlists Whether the current [Playlist]s have changed. + */ + data class Changes(val library: Boolean, val playlists: Boolean) + + /** + * A listener for events in the music loading process. + */ + interface IndexingListener { + /** + * Called when the music loading state changed. + */ + fun onIndexingStateChanged() + } + + /** + * A persistent worker that can load music in the background. + */ + interface IndexingWorker { + /** + * A [Context] required to read device storage + */ + val context: Context + + /** + * The [CoroutineScope] to perform coroutine music loading work on. + */ + val scope: CoroutineScope + + /** + * Request that the music loading process ([index]) should be started. Any prior + * loads should be canceled. + * @param withCache Whether to use the music cache when loading. + */ + fun requestIndex(withCache: Boolean) } } -class MusicRepositoryImpl @Inject constructor() : MusicRepository { - private val listeners = mutableListOf() +class MusicRepositoryImpl +@Inject +constructor( + private val musicSettings: MusicSettings, + private val cacheRepository: CacheRepository, + private val mediaStoreExtractor: MediaStoreExtractor, + private val tagExtractor: TagExtractor +) : MusicRepository { + private val updateListeners = mutableListOf() + private val indexingListeners = mutableListOf() + private var indexingWorker: MusicRepository.IndexingWorker? = null - @Volatile override var library: Library? = null - set(value) { - field = value - for (callback in listeners) { - callback.onLibraryChanged(library) + override var playlists: List? = null + private var previousCompletedState: IndexingState.Completed? = null + private var currentIndexingState: IndexingState? = null + override val indexingState: IndexingState? + get() = currentIndexingState ?: previousCompletedState + + @Synchronized + override fun addUpdateListener(listener: MusicRepository.UpdateListener) { + updateListeners.add(listener) + } + + @Synchronized + override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { + updateListeners.remove(listener) + } + + @Synchronized + override fun addIndexingListener(listener: MusicRepository.IndexingListener) { + indexingListeners.add(listener) + } + + @Synchronized + override fun removeIndexingListener(listener: MusicRepository.IndexingListener) { + indexingListeners.remove(listener) + } + + @Synchronized + override fun registerWorker(worker: MusicRepository.IndexingWorker) { + if (indexingWorker != null) { + logW("Worker is already registered") + return + } + indexingWorker = worker + if (indexingState == null) { + worker.requestIndex(true) + } + } + + @Synchronized + override fun unregisterWorker(worker: MusicRepository.IndexingWorker) { + if (indexingWorker !== worker) { + logW("Given worker did not match current worker") + return + } + indexingWorker = null + currentIndexingState = null + } + + override fun requestIndex(withCache: Boolean) { + indexingWorker?.requestIndex(withCache) + } + + override fun index(worker: MusicRepository.IndexingWorker, withCache: Boolean) = + worker.scope.launch { + try { + val start = System.currentTimeMillis() + indexImpl(worker, withCache) + logD( + "Music indexing completed successfully in " + + "${System.currentTimeMillis() - start}ms") + } catch (e: CancellationException) { + // Got cancelled, propagate upwards to top-level co-routine. + logD("Loading routine was cancelled") + throw e + } catch (e: Exception) { + // Music loading process failed due to something we have not handled. + logE("Music indexing failed") + logE(e.stackTraceToString()) + emitComplete(e) } } - @Synchronized - override fun addListener(listener: MusicRepository.Listener) { - listener.onLibraryChanged(library) - listeners.add(listener) + private suspend fun indexImpl(worker: MusicRepository.IndexingWorker, withCache: Boolean) { + if (ContextCompat.checkSelfPermission(worker.context, PERMISSION_READ_AUDIO) == + PackageManager.PERMISSION_DENIED) { + logE("Permission check failed") + // No permissions, signal that we can't do anything. + throw NoAudioPermissionException() + } + + // Start initializing the extractors. Use an indeterminate state, as there is no ETA on + // how long a media database query will take. + emitLoading(IndexingProgress.Indeterminate) + + // Do the initial query of the cache and media databases in parallel. + logD("Starting queries") + val mediaStoreQueryJob = worker.scope.async { mediaStoreExtractor.query() } + val cache = + if (withCache) { + cacheRepository.readCache() + } else { + null + } + val query = mediaStoreQueryJob.await() + + // Now start processing the queried song information in parallel. Songs that can't be + // received from the cache are consisted incomplete and pushed to a separate channel + // that will eventually be processed into completed raw songs. + logD("Starting song discovery") + val completeSongs = Channel(Channel.UNLIMITED) + val incompleteSongs = Channel(Channel.UNLIMITED) + val mediaStoreJob = + worker.scope.async { + mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) + } + val metadataJob = + worker.scope.async { tagExtractor.consume(incompleteSongs, completeSongs) } + + // Await completed raw songs as they are processed. + val rawSongs = LinkedList() + for (rawSong in completeSongs) { + rawSongs.add(rawSong) + emitLoading(IndexingProgress.Songs(rawSongs.size, query.projectedTotal)) + } + // These should be no-ops + mediaStoreJob.await() + metadataJob.await() + + if (rawSongs.isEmpty()) { + logE("Music library was empty") + throw NoMusicException() + } + + // Successfully loaded the library, now save the cache and create the library in + // parallel. + logD("Discovered ${rawSongs.size} songs, starting finalization") + emitLoading(IndexingProgress.Indeterminate) + val libraryJob = + worker.scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) } + if (cache == null || cache.invalidated) { + cacheRepository.writeCache(rawSongs) + } + val newLibrary = libraryJob.await() + withContext(Dispatchers.Main) { + emitComplete(null) + emitData(newLibrary, listOf()) + } + } + + private suspend fun emitLoading(progress: IndexingProgress) { + yield() + synchronized(this) { + currentIndexingState = IndexingState.Indexing(progress) + for (listener in indexingListeners) { + listener.onIndexingStateChanged() + } + } + } + + private suspend fun emitComplete(error: Exception?) { + yield() + synchronized(this) { + previousCompletedState = IndexingState.Completed(error) + currentIndexingState = null + for (listener in indexingListeners) { + listener.onIndexingStateChanged() + } + } } @Synchronized - override fun removeListener(listener: MusicRepository.Listener) { - listeners.remove(listener) + private fun emitData(library: Library, playlists: List) { + val libraryChanged = this.library != library + val playlistsChanged = this.playlists != playlists + if (!libraryChanged && !playlistsChanged) return + + this.library = library + this.playlists = playlists + val changes = MusicRepository.Changes(libraryChanged, playlistsChanged) + for (listener in updateListeners) { + listener.onMusicChanges(changes) + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt index 9b99c7f2e..6c4ab3680 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicViewModel.kt @@ -23,7 +23,6 @@ import dagger.hilt.android.lifecycle.HiltViewModel import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import org.oxycblt.auxio.music.system.Indexer /** * A [ViewModel] providing data specific to the music loading process. @@ -31,12 +30,12 @@ import org.oxycblt.auxio.music.system.Indexer * @author Alexander Capehart (OxygenCobalt) */ @HiltViewModel -class MusicViewModel @Inject constructor(private val indexer: Indexer) : - ViewModel(), Indexer.Listener { +class MusicViewModel @Inject constructor(private val musicRepository: MusicRepository) : + ViewModel(), MusicRepository.UpdateListener, MusicRepository.IndexingListener { - private val _indexerState = MutableStateFlow(null) + private val _indexingState = MutableStateFlow(null) /** The current music loading state, or null if no loading is going on. */ - val indexerState: StateFlow = _indexerState + val indexingState: StateFlow = _indexingState private val _statistics = MutableStateFlow(null) /** [Statistics] about the last completed music load. */ @@ -44,36 +43,39 @@ class MusicViewModel @Inject constructor(private val indexer: Indexer) : get() = _statistics init { - indexer.registerListener(this) + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) } override fun onCleared() { - indexer.unregisterListener(this) + musicRepository.removeUpdateListener(this) + musicRepository.removeIndexingListener(this) } - override fun onIndexerStateChanged(state: Indexer.State?) { - _indexerState.value = state - if (state is Indexer.State.Complete) { - // New state is a completed library, update the statistics values. - val library = state.result.getOrNull() ?: return - _statistics.value = - Statistics( - library.songs.size, - library.albums.size, - library.artists.size, - library.genres.size, - library.songs.sumOf { it.durationMs }) - } + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.library) return + val library = musicRepository.library ?: return + _statistics.value = + Statistics( + library.songs.size, + library.albums.size, + library.artists.size, + library.genres.size, + library.songs.sumOf { it.durationMs }) + } + + override fun onIndexingStateChanged() { + _indexingState.value = musicRepository.indexingState } /** Requests that the music library should be re-loaded while leveraging the cache. */ fun refresh() { - indexer.requestReindex(true) + musicRepository.requestIndex(true) } /** Requests that the music library be re-loaded without the cache. */ fun rescan() { - indexer.requestReindex(false) + musicRepository.requestIndex(false) } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt deleted file mode 100644 index 1fd305c63..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ /dev/null @@ -1,446 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * Indexer.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.system - -import android.Manifest -import android.content.Context -import android.content.pm.PackageManager -import android.os.Build -import androidx.core.content.ContextCompat -import java.util.LinkedList -import javax.inject.Inject -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlinx.coroutines.yield -import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.cache.CacheRepository -import org.oxycblt.auxio.music.library.Library -import org.oxycblt.auxio.music.library.RawSong -import org.oxycblt.auxio.music.metadata.TagExtractor -import org.oxycblt.auxio.music.storage.MediaStoreExtractor -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logE -import org.oxycblt.auxio.util.logW - -/** - * Core music loading state class. - * - * This class provides low-level access into the exact state of the music loading process. **This - * class should not be used in most cases.** It is highly volatile and provides far more information - * than is usually needed. Use [MusicRepository] instead if you do not need to work with the exact - * music loading state. - * - * @author Alexander Capehart (OxygenCobalt) - */ -interface Indexer { - /** Whether music loading is occurring or not. */ - val isIndexing: Boolean - /** - * Whether this instance has not completed a loading process and is not currently loading music. - * This often occurs early in an app's lifecycle, and consumers should try to avoid showing any - * state when this flag is true. - */ - val isIndeterminate: Boolean - - /** - * Register a [Controller] for this instance. This instance will handle any commands to start - * the music loading process. There can be only one [Controller] at a time. Will invoke all - * [Listener] methods to initialize the instance with the current state. - * - * @param controller The [Controller] to register. Will do nothing if already registered. - */ - fun registerController(controller: Controller) - - /** - * Unregister the [Controller] from this instance, prevent it from recieving any further - * commands. - * - * @param controller The [Controller] to unregister. Must be the current [Controller]. Does - * nothing if invoked by another [Controller] implementation. - */ - fun unregisterController(controller: Controller) - - /** - * Register the [Listener] for this instance. This can be used to receive rapid-fire updates to - * the current music loading state. There can be only one [Listener] at a time. Will invoke all - * [Listener] methods to initialize the instance with the current state. - * - * @param listener The [Listener] to add. - */ - fun registerListener(listener: Listener) - - /** - * Unregister a [Listener] from this instance, preventing it from recieving any further updates. - * - * @param listener The [Listener] to unregister. Must be the current [Listener]. Does nothing if - * invoked by another [Listener] implementation. - * @see Listener - */ - fun unregisterListener(listener: Listener) - - /** - * Start the indexing process. This should be done from in the background from [Controller]'s - * context after a command has been received to start the process. - * - * @param context [Context] required to load music. - * @param withCache Whether to use the cache or not when loading. If false, the cache will still - * be written, but no cache entries will be loaded into the new library. - * @param scope The [CoroutineScope] to run the indexing job in. - * @return The [Job] stacking the indexing status. - */ - fun index(context: Context, withCache: Boolean, scope: CoroutineScope): Job - - /** - * Request that the music library should be reloaded. This should be used by components that do - * not manage the indexing process in order to signal that the [Indexer.Controller] should call - * [index] eventually. - * - * @param withCache Whether to use the cache when loading music. Does nothing if there is no - * [Indexer.Controller]. - */ - fun requestReindex(withCache: Boolean) - - /** - * Reset the current loading state to signal that the instance is not loading. This should be - * called by [Controller] after it's indexing co-routine was cancelled. - */ - fun reset() - - /** Represents the current state of [Indexer]. */ - sealed class State { - /** - * Music loading is ongoing. - * - * @param indexing The current music loading progress.. - * @see Indexer.Indexing - */ - data class Indexing(val indexing: Indexer.Indexing) : State() - - /** - * Music loading has completed. - * - * @param result The outcome of the music loading process. - */ - data class Complete(val result: Result) : State() - } - - /** - * Represents the current progress of the music loader. Usually encapsulated in a [State]. - * - * @see State.Indexing - */ - sealed class Indexing { - /** - * Music loading is occurring, but no definite estimate can be put on the current progress. - */ - object Indeterminate : Indexing() - - /** - * Music loading has a definite progress. - * - * @param current The current amount of songs that have been loaded. - * @param total The projected total amount of songs that will be loaded. - */ - class Songs(val current: Int, val total: Int) : Indexing() - } - - /** Thrown when the required permissions to load the music library have not been granted yet. */ - class NoPermissionException : Exception() { - override val message: String - get() = "Not granted permissions to load music library" - } - - /** Thrown when no music was found on the device. */ - class NoMusicException : Exception() { - override val message: String - get() = "Unable to find any music" - } - - /** - * A listener for rapid-fire changes in the music loading state. - * - * This is only useful for code that absolutely must show the current loading process. - * Otherwise, [MusicRepository.Listener] is highly recommended due to it's updates only - * consisting of the [Library]. - */ - interface Listener { - /** - * Called when the current state of the Indexer changed. - * - * Notes: - * - Null means that no loading is going on, but no load has completed either. - * - [State.Complete] may represent a previous load, if the current loading process was - * canceled for one reason or another. - */ - fun onIndexerStateChanged(state: State?) - } - - /** - * Context that runs the music loading process. Implementations should be capable of running the - * background for long periods of time without android killing the process. - */ - interface Controller : Listener { - /** - * Called when a new music loading process was requested. Implementations should forward - * this to [index]. - * - * @param withCache Whether to use the cache or not when loading. If false, the cache should - * still be written, but no cache entries will be loaded into the new library. - * @see index - */ - fun onStartIndexing(withCache: Boolean) - } - - companion object { - /** - * A version-compatible identifier for the read external storage permission required by the - * system to load audio. - */ - val PERMISSION_READ_AUDIO = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - // READ_EXTERNAL_STORAGE was superseded by READ_MEDIA_AUDIO in Android 13 - Manifest.permission.READ_MEDIA_AUDIO - } else { - Manifest.permission.READ_EXTERNAL_STORAGE - } - } -} - -class IndexerImpl -@Inject -constructor( - private val musicSettings: MusicSettings, - private val cacheRepository: CacheRepository, - private val mediaStoreExtractor: MediaStoreExtractor, - private val tagExtractor: TagExtractor -) : Indexer { - @Volatile private var lastResponse: Result? = null - @Volatile private var indexingState: Indexer.Indexing? = null - @Volatile private var controller: Indexer.Controller? = null - @Volatile private var listener: Indexer.Listener? = null - - override val isIndexing: Boolean - get() = indexingState != null - - override val isIndeterminate: Boolean - get() = lastResponse == null && indexingState == null - - @Synchronized - override fun registerController(controller: Indexer.Controller) { - if (BuildConfig.DEBUG && this.controller != null) { - logW("Controller is already registered") - return - } - - // Initialize the controller with the current state. - val currentState = - indexingState?.let { Indexer.State.Indexing(it) } - ?: lastResponse?.let { Indexer.State.Complete(it) } - controller.onIndexerStateChanged(currentState) - this.controller = controller - } - - @Synchronized - override fun unregisterController(controller: Indexer.Controller) { - if (BuildConfig.DEBUG && this.controller !== controller) { - logW("Given controller did not match current controller") - return - } - - this.controller = null - } - - @Synchronized - override fun registerListener(listener: Indexer.Listener) { - if (BuildConfig.DEBUG && this.listener != null) { - logW("Listener is already registered") - return - } - - // Initialize the listener with the current state. - val currentState = - indexingState?.let { Indexer.State.Indexing(it) } - ?: lastResponse?.let { Indexer.State.Complete(it) } - listener.onIndexerStateChanged(currentState) - this.listener = listener - } - - @Synchronized - override fun unregisterListener(listener: Indexer.Listener) { - if (BuildConfig.DEBUG && this.listener !== listener) { - logW("Given controller did not match current controller") - return - } - - this.listener = null - } - - override fun index(context: Context, withCache: Boolean, scope: CoroutineScope) = - scope.launch { - val result = - try { - val start = System.currentTimeMillis() - val response = indexImpl(context, withCache, this) - logD( - "Music indexing completed successfully in " + - "${System.currentTimeMillis() - start}ms") - Result.success(response) - } catch (e: CancellationException) { - // Got cancelled, propagate upwards to top-level co-routine. - logD("Loading routine was cancelled") - throw e - } catch (e: Exception) { - // Music loading process failed due to something we have not handled. - logE("Music indexing failed") - logE(e.stackTraceToString()) - Result.failure(e) - } - emitCompletion(result) - } - - @Synchronized - override fun requestReindex(withCache: Boolean) { - logD("Requesting reindex") - controller?.onStartIndexing(withCache) - } - - @Synchronized - override fun reset() { - logD("Cancelling last job") - emitIndexing(null) - } - - private suspend fun indexImpl( - context: Context, - withCache: Boolean, - scope: CoroutineScope - ): Library { - if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) == - PackageManager.PERMISSION_DENIED) { - logE("Permission check failed") - // No permissions, signal that we can't do anything. - throw Indexer.NoPermissionException() - } - - // Start initializing the extractors. Use an indeterminate state, as there is no ETA on - // how long a media database query will take. - emitIndexing(Indexer.Indexing.Indeterminate) - - // Do the initial query of the cache and media databases in parallel. - logD("Starting queries") - val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() } - val cache = - if (withCache) { - cacheRepository.readCache() - } else { - null - } - val query = mediaStoreQueryJob.await() - - // Now start processing the queried song information in parallel. Songs that can't be - // received from the cache are consisted incomplete and pushed to a separate channel - // that will eventually be processed into completed raw songs. - logD("Starting song discovery") - val completeSongs = Channel(Channel.UNLIMITED) - val incompleteSongs = Channel(Channel.UNLIMITED) - val mediaStoreJob = - scope.async { - mediaStoreExtractor.consume(query, cache, incompleteSongs, completeSongs) - } - val metadataJob = scope.async { tagExtractor.consume(incompleteSongs, completeSongs) } - - // Await completed raw songs as they are processed. - val rawSongs = LinkedList() - for (rawSong in completeSongs) { - rawSongs.add(rawSong) - emitIndexing(Indexer.Indexing.Songs(rawSongs.size, query.projectedTotal)) - } - // These should be no-ops - mediaStoreJob.await() - metadataJob.await() - - if (rawSongs.isEmpty()) { - logE("Music library was empty") - throw Indexer.NoMusicException() - } - - // Successfully loaded the library, now save the cache and create the library in - // parallel. - logD("Discovered ${rawSongs.size} songs, starting finalization") - emitIndexing(Indexer.Indexing.Indeterminate) - val libraryJob = scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) } - if (cache == null || cache.invalidated) { - cacheRepository.writeCache(rawSongs) - } - return libraryJob.await() - } - - /** - * Emit a new [Indexer.State.Indexing] state. This can be used to signal the current state of - * the music loading process to external code. Assumes that the callee has already checked if - * they have not been canceled and thus have the ability to emit a new state. - * - * @param indexing The new [Indexer.Indexing] state to emit, or null if no loading process is - * occurring. - */ - @Synchronized - private fun emitIndexing(indexing: Indexer.Indexing?) { - indexingState = indexing - // If we have canceled the loading process, we want to revert to a previous completion - // whenever possible to prevent state inconsistency. - val state = - indexingState?.let { Indexer.State.Indexing(it) } - ?: lastResponse?.let { Indexer.State.Complete(it) } - controller?.onIndexerStateChanged(state) - listener?.onIndexerStateChanged(state) - } - - /** - * Emit a new [Indexer.State.Complete] state. This can be used to signal the completion of the - * music loading process to external code. Will check if the callee has not been canceled and - * thus has the ability to emit a new state - * - * @param result The new [Result] to emit, representing the outcome of the music loading - * process. - */ - private suspend fun emitCompletion(result: Result) { - yield() - // Swap to the Main thread so that downstream callbacks don't crash from being on - // a background thread. Does not occur in emitIndexing due to efficiency reasons. - withContext(Dispatchers.Main) { - synchronized(this) { - // Do not check for redundancy here, as we actually need to notify a switch - // from Indexing -> Complete and not Indexing -> Indexing or Complete -> Complete. - lastResponse = result - indexingState = null - // Signal that the music loading process has been completed. - val state = Indexer.State.Complete(result) - controller?.onIndexerStateChanged(state) - listener?.onIndexerStateChanged(state) - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt index 5f72fac18..301fa1b46 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerNotifications.kt @@ -24,6 +24,7 @@ import androidx.core.app.NotificationCompat import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R +import org.oxycblt.auxio.music.IndexingProgress import org.oxycblt.auxio.service.ForegroundServiceNotification import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.newMainPendingIntent @@ -56,22 +57,22 @@ class IndexingNotification(private val context: Context) : /** * Update this notification with the new music loading state. * - * @param indexing The new music loading state to display in the notification. + * @param progress The new music loading state to display in the notification. * @return true if the notification updated, false otherwise */ - fun updateIndexingState(indexing: Indexer.Indexing): Boolean { - when (indexing) { - is Indexer.Indexing.Indeterminate -> { + fun updateIndexingState(progress: IndexingProgress): Boolean { + when (progress) { + is IndexingProgress.Indeterminate -> { // Indeterminate state, use a vaguer description and in-determinate progress. // These events are not very frequent, and thus we don't need to safeguard // against rate limiting. - logD("Updating state to $indexing") + logD("Updating state to $progress") lastUpdateTime = -1 setContentText(context.getString(R.string.lng_indexing)) setProgress(0, 0, true) return true } - is Indexer.Indexing.Songs -> { + is IndexingProgress.Songs -> { // Determinate state, show an active progress meter. Since these updates arrive // highly rapidly, only update every 1.5 seconds to prevent notification rate // limiting. @@ -80,10 +81,10 @@ class IndexingNotification(private val context: Context) : return false } lastUpdateTime = SystemClock.elapsedRealtime() - logD("Updating state to $indexing") + logD("Updating state to $progress") setContentText( - context.getString(R.string.fmt_indexing, indexing.current, indexing.total)) - setProgress(indexing.total, indexing.current, false) + context.getString(R.string.fmt_indexing, progress.current, progress.total)) + setProgress(progress.total, progress.current, false) return true } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt index def283ba5..a12e252fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/IndexerService.kt @@ -28,13 +28,12 @@ import android.os.PowerManager import android.provider.MediaStore import coil.ImageLoader import dagger.hilt.android.AndroidEntryPoint +import java.lang.Runnable +import java.util.* import javax.inject.Inject -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job +import kotlinx.coroutines.* import org.oxycblt.auxio.BuildConfig -import org.oxycblt.auxio.music.MusicRepository -import org.oxycblt.auxio.music.MusicSettings +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.storage.contentResolverSafe import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.service.ForegroundManager @@ -56,12 +55,17 @@ import org.oxycblt.auxio.util.logD * TODO: Unify with PlaybackService as part of the service independence project */ @AndroidEntryPoint -class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { +class IndexerService : + Service(), + MusicRepository.IndexingWorker, + MusicRepository.IndexingListener, + MusicRepository.UpdateListener, + MusicSettings.Listener { @Inject lateinit var imageLoader: ImageLoader @Inject lateinit var musicRepository: MusicRepository - @Inject lateinit var indexer: Indexer @Inject lateinit var musicSettings: MusicSettings @Inject lateinit var playbackManager: PlaybackStateManager + private val serviceJob = Job() private val indexScope = CoroutineScope(serviceJob + Dispatchers.IO) private var currentIndexJob: Job? = null @@ -85,13 +89,9 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // condition to cause us to load music before we were fully initialize. indexerContentObserver = SystemContentObserver() musicSettings.registerListener(this) - indexer.registerController(this) - // An indeterminate indexer and a missing library implies we are extremely early - // in app initialization so start loading music. - if (musicRepository.library == null && indexer.isIndeterminate) { - logD("No library present and no previous response, indexing music now") - onStartIndexing(true) - } + musicRepository.addUpdateListener(this) + musicRepository.addIndexingListener(this) + musicRepository.registerWorker(this) logD("Service created.") } @@ -109,83 +109,66 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // events will not occur. indexerContentObserver.release() musicSettings.unregisterListener(this) - indexer.unregisterController(this) + musicRepository.removeUpdateListener(this) + musicRepository.removeIndexingListener(this) + musicRepository.unregisterWorker(this) // Then cancel any remaining music loading jobs. serviceJob.cancel() - indexer.reset() } // --- CONTROLLER CALLBACKS --- - override fun onStartIndexing(withCache: Boolean) { - if (indexer.isIndexing) { - // Cancel the previous music loading job. - currentIndexJob?.cancel() - indexer.reset() - } + override fun requestIndex(withCache: Boolean) { + // Cancel the previous music loading job. + currentIndexJob?.cancel() // Start a new music loading job on a co-routine. - currentIndexJob = indexer.index(this@IndexerService, withCache, indexScope) + currentIndexJob = + indexScope.launch { musicRepository.index(this@IndexerService, withCache) } } - override fun onIndexerStateChanged(state: Indexer.State?) { - when (state) { - is Indexer.State.Indexing -> updateActiveSession(state.indexing) - is Indexer.State.Complete -> { - val newLibrary = state.result.getOrNull() - if (newLibrary != null && newLibrary != musicRepository.library) { - logD("Applying new library") - // We only care if the newly-loaded library is going to replace a previously - // loaded library. - if (musicRepository.library != null) { - // Wipe possibly-invalidated outdated covers - imageLoader.memoryCache?.clear() - // Clear invalid models from PlaybackStateManager. This is not connected - // to a listener as it is bad practice for a shared object to attach to - // the listener system of another. - playbackManager.toSavedState()?.let { savedState -> - playbackManager.applySavedState( - PlaybackStateManager.SavedState( - parent = savedState.parent?.let(newLibrary::sanitize), - queueState = - savedState.queueState.remap { song -> - newLibrary.sanitize(requireNotNull(song)) - }, - positionMs = savedState.positionMs, - repeatMode = savedState.repeatMode), - true) - } - } - // Forward the new library to MusicStore to continue the update process. - musicRepository.library = newLibrary - } - // On errors, while we would want to show a notification that displays the - // error, that requires the Android 13 notification permission, which is not - // handled right now. - updateIdleSession() - } - null -> { - // Null is the indeterminate state that occurs on app startup or after - // the cancellation of a load, so in that case we want to stop foreground - // since (technically) nothing is loading. - updateIdleSession() - } + override val context = this + + override val scope = indexScope + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (!changes.library) return + val library = musicRepository.library ?: return + // Wipe possibly-invalidated outdated covers + imageLoader.memoryCache?.clear() + // Clear invalid models from PlaybackStateManager. This is not connected + // to a listener as it is bad practice for a shared object to attach to + // the listener system of another. + playbackManager.toSavedState()?.let { savedState -> + playbackManager.applySavedState( + PlaybackStateManager.SavedState( + parent = savedState.parent?.let(library::sanitize), + queueState = + savedState.queueState.remap { song -> + library.sanitize(requireNotNull(song)) + }, + positionMs = savedState.positionMs, + repeatMode = savedState.repeatMode), + true) + } + } + + override fun onIndexingStateChanged() { + val state = musicRepository.indexingState + if (state is IndexingState.Indexing) { + updateActiveSession(state.progress) + } else { + updateIdleSession() } } // --- INTERNAL --- - /** - * Update the current state to "Active", in which the service signals that music loading is - * on-going. - * - * @param state The current music loading state. - */ - private fun updateActiveSession(state: Indexer.Indexing) { + private fun updateActiveSession(progress: IndexingProgress) { // When loading, we want to enter the foreground state so that android does // not shut off the loading process. Note that while we will always post the // notification when initially starting, we will not update the notification // unless it indicates that it has changed. - val changed = indexingNotification.updateIndexingState(state) + val changed = indexingNotification.updateIndexingState(progress) if (!foregroundManager.tryStartForeground(indexingNotification) && changed) { logD("Notification changed, re-posting notification") indexingNotification.post() @@ -194,10 +177,6 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { wakeLock.acquireSafe() } - /** - * Update the current state to "Idle", in which it either does nothing or signals that it's - * currently monitoring the music library for changes. - */ private fun updateIdleSession() { if (musicSettings.shouldBeObserving) { // There are a few reasons why we stay in the foreground with automatic rescanning: @@ -244,7 +223,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { override fun onIndexingSettingChanged() { // Music loading configuration changed, need to reload music. - onStartIndexing(true) + requestIndex(true) } override fun onObservingChanged() { @@ -252,7 +231,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // notification if we were actively loading when the automatic rescanning // setting changed. In such a case, the state will still be updated when // the music loading process ends. - if (!indexer.isIndexing) { + if (currentIndexJob == null) { updateIdleSession() } } @@ -290,7 +269,7 @@ class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { // Check here if we should even start a reindex. This is much less bug-prone than // registering and de-registering this component as this setting changes. if (musicSettings.shouldBeObserving) { - onStartIndexing(true) + requestIndex(true) } } } diff --git a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt index e670078f5..cda23118c 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt @@ -24,7 +24,6 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.unlikelyToBeNull /** @@ -35,7 +34,7 @@ import org.oxycblt.auxio.util.unlikelyToBeNull */ @HiltViewModel class PickerViewModel @Inject constructor(private val musicRepository: MusicRepository) : - ViewModel(), MusicRepository.Listener { + ViewModel(), MusicRepository.UpdateListener { private val _currentItem = MutableStateFlow(null) /** The current item whose artists should be shown in the picker. Null if there is no item. */ @@ -52,12 +51,16 @@ class PickerViewModel @Inject constructor(private val musicRepository: MusicRepo val genreChoices: StateFlow> get() = _genreChoices - override fun onCleared() { - musicRepository.removeListener(this) + init { + musicRepository.addUpdateListener(this) } - override fun onLibraryChanged(library: Library?) { - if (library != null) { + override fun onCleared() { + musicRepository.removeUpdateListener(this) + } + + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.library && musicRepository.library != null) { refreshChoices() } } diff --git a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt index 8f4480845..5cc4c89fb 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/system/PlaybackService.kt @@ -48,7 +48,6 @@ import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor @@ -82,7 +81,7 @@ class PlaybackService : Player.Listener, InternalPlayer, MediaSessionComponent.Listener, - MusicRepository.Listener { + MusicRepository.UpdateListener { // Player components private lateinit var player: ExoPlayer @Inject lateinit var mediaSourceFactory: MediaSource.Factory @@ -148,7 +147,7 @@ class PlaybackService : // Initialize any listener-dependent components last as we wouldn't want a listener race // condition to cause us to load music before we were fully initialize. playbackManager.registerInternalPlayer(this) - musicRepository.addListener(this) + musicRepository.addUpdateListener(this) mediaSessionComponent.registerListener(this) registerReceiver( systemReceiver, @@ -187,7 +186,7 @@ class PlaybackService : // Pause just in case this destruction was unexpected. playbackManager.setPlaying(false) playbackManager.unregisterInternalPlayer(this) - musicRepository.removeListener(this) + musicRepository.removeUpdateListener(this) unregisterReceiver(systemReceiver) serviceJob.cancel() @@ -299,10 +298,8 @@ class PlaybackService : playbackManager.next() } - // --- MUSICSTORE OVERRIDES --- - - override fun onLibraryChanged(library: Library?) { - if (library != null) { + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.library && musicRepository.library != null) { // We now have a library, see if we have anything we need to do. playbackManager.requestAction(this) } diff --git a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt index a36b475c3..295eab901 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -50,7 +50,7 @@ constructor( private val searchEngine: SearchEngine, private val searchSettings: SearchSettings, private val playbackSettings: PlaybackSettings, -) : ViewModel(), MusicRepository.Listener { +) : ViewModel(), MusicRepository.UpdateListener { private var lastQuery: String? = null private var currentSearchJob: Job? = null @@ -64,17 +64,16 @@ constructor( get() = playbackSettings.inListPlaybackMode init { - musicRepository.addListener(this) + musicRepository.addUpdateListener(this) } override fun onCleared() { super.onCleared() - musicRepository.removeListener(this) + musicRepository.removeUpdateListener(this) } - override fun onLibraryChanged(library: Library?) { - if (library != null) { - // Make sure our query is up to date with the music library. + override fun onMusicChanges(changes: MusicRepository.Changes) { + if (changes.library && musicRepository.library != null) { search(lastQuery) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt new file mode 100644 index 000000000..3a11d25df --- /dev/null +++ b/app/src/test/java/org/oxycblt/auxio/music/FakeMusicRepository.kt @@ -0,0 +1,72 @@ +/* + * 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.library.Library + +open class FakeMusicRepository : MusicRepository { + override var indexingState: IndexingState? + get() = throw NotImplementedError() + set(_) { + throw NotImplementedError() + } + override var library: Library? + get() = throw NotImplementedError() + set(_) { + throw NotImplementedError() + } + override var playlists: List? + get() = throw NotImplementedError() + set(_) { + 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 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/MusicRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt deleted file mode 100644 index fe18dc326..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicRepositoryTest.kt +++ /dev/null @@ -1,53 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * MusicRepositoryTest.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 -import org.oxycblt.auxio.music.library.FakeLibrary -import org.oxycblt.auxio.music.library.Library - -class MusicRepositoryTest { - @Test - fun listeners() { - val listener = TestListener() - val impl = - MusicRepositoryImpl().apply { - library = null - addListener(listener) - } - impl.library = TestLibrary(0) - assertEquals(listOf(null, TestLibrary(0)), listener.updates) - - val listener2 = TestListener() - impl.addListener(listener2) - impl.library = TestLibrary(1) - assertEquals(listOf(TestLibrary(0), TestLibrary(1)), listener2.updates) - } - - private class TestListener : MusicRepository.Listener { - val updates = mutableListOf() - - override fun onLibraryChanged(library: Library?) { - updates.add(library) - } - } - - private data class TestLibrary(private val id: Int) : FakeLibrary() -} diff --git a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt index 078624d69..92b2534b0 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/MusicViewModelTest.kt @@ -22,31 +22,34 @@ import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue import org.junit.Test import org.oxycblt.auxio.music.library.FakeLibrary -import org.oxycblt.auxio.music.system.FakeIndexer -import org.oxycblt.auxio.music.system.Indexer +import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.util.forceClear class MusicViewModelTest { @Test fun indexerState() { val indexer = - TestIndexer().apply { state = Indexer.State.Indexing(Indexer.Indexing.Indeterminate) } + TestMusicRepository().apply { + indexingState = IndexingState.Indexing(IndexingProgress.Indeterminate) + } val musicViewModel = MusicViewModel(indexer) - assertTrue(indexer.listener is MusicViewModel) + assertTrue(indexer.updateListener is MusicViewModel) + assertTrue(indexer.indexingListener is MusicViewModel) assertEquals( - Indexer.Indexing.Indeterminate, - (musicViewModel.indexerState.value as Indexer.State.Indexing).indexing) - indexer.state = null - assertEquals(null, musicViewModel.indexerState.value) + IndexingProgress.Indeterminate, + (musicViewModel.indexingState.value as IndexingState.Indexing).progress) + indexer.indexingState = null + assertEquals(null, musicViewModel.indexingState.value) musicViewModel.forceClear() - assertTrue(indexer.listener == null) + assertTrue(indexer.indexingListener == null) } @Test fun statistics() { - val indexer = - TestIndexer().apply { state = Indexer.State.Complete(Result.success(TestLibrary())) } - val musicViewModel = MusicViewModel(indexer) + val musicRepository = TestMusicRepository() + val musicViewModel = MusicViewModel(musicRepository) + assertEquals(null, musicViewModel.statistics.value) + musicRepository.library = TestLibrary() assertEquals( MusicViewModel.Statistics( 2, @@ -60,33 +63,49 @@ class MusicViewModelTest { @Test fun requests() { - val indexer = TestIndexer() + val indexer = TestMusicRepository() val musicViewModel = MusicViewModel(indexer) musicViewModel.refresh() musicViewModel.rescan() assertEquals(listOf(true, false), indexer.requests) } - private class TestIndexer : FakeIndexer() { - var listener: Indexer.Listener? = null - var state: Indexer.State? = null + private class TestMusicRepository : FakeMusicRepository() { + override var library: Library? = null set(value) { field = value - listener?.onIndexerStateChanged(value) + updateListener?.onMusicChanges( + MusicRepository.Changes(library = true, playlists = 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 registerListener(listener: Indexer.Listener) { - this.listener = listener - listener.onIndexerStateChanged(state) + override fun addUpdateListener(listener: MusicRepository.UpdateListener) { + listener.onMusicChanges(MusicRepository.Changes(library = true, playlists = false)) + this.updateListener = listener } - override fun unregisterListener(listener: Indexer.Listener) { - this.listener = null + override fun removeUpdateListener(listener: MusicRepository.UpdateListener) { + this.updateListener = null } - override fun requestReindex(withCache: Boolean) { + 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) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/system/FakeIndexer.kt b/app/src/test/java/org/oxycblt/auxio/music/system/FakeIndexer.kt deleted file mode 100644 index ce3bcf210..000000000 --- a/app/src/test/java/org/oxycblt/auxio/music/system/FakeIndexer.kt +++ /dev/null @@ -1,58 +0,0 @@ -/* - * Copyright (c) 2023 Auxio Project - * FakeIndexer.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.system - -import android.content.Context -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Job - -open class FakeIndexer : Indexer { - override val isIndeterminate: Boolean - get() = throw NotImplementedError() - override val isIndexing: Boolean - get() = throw NotImplementedError() - - override fun index(context: Context, withCache: Boolean, scope: CoroutineScope): Job { - throw NotImplementedError() - } - - override fun registerController(controller: Indexer.Controller) { - throw NotImplementedError() - } - - override fun unregisterController(controller: Indexer.Controller) { - throw NotImplementedError() - } - - override fun registerListener(listener: Indexer.Listener) { - throw NotImplementedError() - } - - override fun unregisterListener(listener: Indexer.Listener) { - throw NotImplementedError() - } - - override fun requestReindex(withCache: Boolean) { - throw NotImplementedError() - } - - override fun reset() { - throw NotImplementedError() - } -}