music: unify indexer and repository

Unify Indexer and MusicRepository into a single class.

This is meant to create a single dependency on PlaylistDatabase and
reduce the amount of orchestration.
This commit is contained in:
Alexander Capehart 2023-03-20 15:26:22 -06:00
parent 4033a791a7
commit 9a282e2be9
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
18 changed files with 689 additions and 852 deletions

View file

@ -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

View file

@ -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<Library>) {
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
}
}
}

View file

@ -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<Song>())
/** 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<Boolean> = _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))
}
/**

View file

@ -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<Music>())
/** the currently selected items. These are ordered in earliest selected and latest selected. */
val selected: StateFlow<List<Music>>
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)
}
/**

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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"
}

View file

@ -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
}

View file

@ -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<Playlist>?
/** 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<MusicRepository.Listener>()
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<MusicRepository.UpdateListener>()
private val indexingListeners = mutableListOf<MusicRepository.IndexingListener>()
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<Playlist>? = 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<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RawSong>(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<RawSong>()
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<Playlist>) {
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)
}
}
}

View file

@ -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<Indexer.State?>(null)
private val _indexingState = MutableStateFlow<IndexingState?>(null)
/** The current music loading state, or null if no loading is going on. */
val indexerState: StateFlow<Indexer.State?> = _indexerState
val indexingState: StateFlow<IndexingState?> = _indexingState
private val _statistics = MutableStateFlow<Statistics?>(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)
}
/**

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Library>) : 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<Library>? = 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<RawSong>(Channel.UNLIMITED)
val incompleteSongs = Channel<RawSong>(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<RawSong>()
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<Library>) {
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)
}
}
}
}

View file

@ -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
}
}

View file

@ -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)
}
}
}

View file

@ -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<Music?>(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<List<Genre>>
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()
}
}

View file

@ -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)
}

View file

@ -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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Playlist>?
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()
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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<Library?>()
override fun onLibraryChanged(library: Library?) {
updates.add(library)
}
}
private data class TestLibrary(private val id: Int) : FakeLibrary()
}

View file

@ -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<Boolean>()
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)
}
}

View file

@ -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 <https://www.gnu.org/licenses/>.
*/
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()
}
}