music: hide indexer impl

Hide the implementation of Indexer behind an interface.

This is in preparation for dependency injection.
This commit is contained in:
Alexander Capehart 2023-01-29 16:10:51 -07:00
parent b34e6fdc8a
commit 41bc6f9dfc
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 210 additions and 207 deletions

View file

@ -27,7 +27,7 @@ import org.oxycblt.auxio.music.system.Indexer
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class MusicViewModel : ViewModel(), Indexer.Listener { class MusicViewModel : ViewModel(), Indexer.Listener {
private val indexer = Indexer.getInstance() private val indexer = Indexer.get()
private val _indexerState = MutableStateFlow<Indexer.State?>(null) private val _indexerState = MutableStateFlow<Indexer.State?>(null)
/** The current music loading state, or null if no loading is going on. */ /** The current music loading state, or null if no loading is going on. */

View file

@ -44,23 +44,15 @@ import org.oxycblt.auxio.util.logW
* *
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class Indexer private constructor() { interface Indexer {
@Volatile private var lastResponse: Result<Library>? = null
@Volatile private var indexingState: Indexing? = null
@Volatile private var controller: Controller? = null
@Volatile private var listener: Listener? = null
/** Whether music loading is occurring or not. */ /** Whether music loading is occurring or not. */
val isIndexing: Boolean val isIndexing: Boolean
get() = indexingState != null
/** /**
* Whether this instance has not completed a loading process and is not currently loading music. * 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 * This often occurs early in an app's lifecycle, and consumers should try to avoid showing any
* state when this flag is true. * state when this flag is true.
*/ */
val isIndeterminate: Boolean val isIndeterminate: Boolean
get() = lastResponse == null && indexingState == null
/** /**
* Register a [Controller] for this instance. This instance will handle any commands to start * Register a [Controller] for this instance. This instance will handle any commands to start
@ -68,19 +60,7 @@ class Indexer private constructor() {
* [Listener] methods to initialize the instance with the current state. * [Listener] methods to initialize the instance with the current state.
* @param controller The [Controller] to register. Will do nothing if already registered. * @param controller The [Controller] to register. Will do nothing if already registered.
*/ */
@Synchronized fun registerController(controller: Controller)
fun registerController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller != null) {
logW("Controller is already registered")
return
}
// Initialize the controller with the current state.
val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller.onIndexerStateChanged(currentState)
this.controller = controller
}
/** /**
* Unregister the [Controller] from this instance, prevent it from recieving any further * Unregister the [Controller] from this instance, prevent it from recieving any further
@ -88,15 +68,7 @@ class Indexer private constructor() {
* @param controller The [Controller] to unregister. Must be the current [Controller]. Does * @param controller The [Controller] to unregister. Must be the current [Controller]. Does
* nothing if invoked by another [Controller] implementation. * nothing if invoked by another [Controller] implementation.
*/ */
@Synchronized fun unregisterController(controller: Controller)
fun unregisterController(controller: Controller) {
if (BuildConfig.DEBUG && this.controller !== controller) {
logW("Given controller did not match current controller")
return
}
this.controller = null
}
/** /**
* Register the [Listener] for this instance. This can be used to receive rapid-fire updates to * Register the [Listener] for this instance. This can be used to receive rapid-fire updates to
@ -104,19 +76,7 @@ class Indexer private constructor() {
* [Listener] methods to initialize the instance with the current state. * [Listener] methods to initialize the instance with the current state.
* @param listener The [Listener] to add. * @param listener The [Listener] to add.
*/ */
@Synchronized fun registerListener(listener: Listener)
fun registerListener(listener: Listener) {
if (BuildConfig.DEBUG && this.listener != null) {
logW("Listener is already registered")
return
}
// Initialize the listener with the current state.
val currentState =
indexingState?.let { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
listener.onIndexerStateChanged(currentState)
this.listener = listener
}
/** /**
* Unregister a [Listener] from this instance, preventing it from recieving any further updates. * Unregister a [Listener] from this instance, preventing it from recieving any further updates.
@ -124,15 +84,7 @@ class Indexer private constructor() {
* invoked by another [Listener] implementation. * invoked by another [Listener] implementation.
* @see Listener * @see Listener
*/ */
@Synchronized fun unregisterListener(listener: Listener)
fun unregisterListener(listener: Listener) {
if (BuildConfig.DEBUG && this.listener !== listener) {
logW("Given controller did not match current controller")
return
}
this.listener = null
}
/** /**
* Start the indexing process. This should be done from in the background from [Controller]'s * Start the indexing process. This should be done from in the background from [Controller]'s
@ -141,159 +93,22 @@ class Indexer private constructor() {
* @param withCache Whether to use the cache or not when loading. If false, the cache will still * @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. * be written, but no cache entries will be loaded into the new library.
*/ */
suspend fun index(context: Context, withCache: Boolean) { suspend fun index(context: Context, withCache: Boolean)
val result =
try {
val start = System.currentTimeMillis()
val library = indexImpl(context, withCache)
logD(
"Music indexing completed successfully in " +
"${System.currentTimeMillis() - start}ms")
Result.success(library)
} 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)
}
/** /**
* Request that the music library should be reloaded. This should be used by components that do * 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 [Controller] should call [index] * not manage the indexing process in order to signal that the [Indexer.Controller] should call
* eventually. * [index] eventually.
* @param withCache Whether to use the cache when loading music. Does nothing if there is no * @param withCache Whether to use the cache when loading music. Does nothing if there is no
* [Controller]. * [Indexer.Controller].
*/ */
@Synchronized fun requestReindex(withCache: Boolean)
fun requestReindex(withCache: Boolean) {
logD("Requesting reindex")
controller?.onStartIndexing(withCache)
}
/** /**
* Reset the current loading state to signal that the instance is not loading. This should be * 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. * called by [Controller] after it's indexing co-routine was cancelled.
*/ */
@Synchronized fun reset()
fun reset() {
logD("Cancelling last job")
emitIndexing(null)
}
/**
* Internal implementation of the music loading 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.
* @return A newly-loaded [Library].
* @throws NoPermissionException If [PERMISSION_READ_AUDIO] was not granted.
* @throws NoMusicException If no music was found on the device.
*/
private suspend fun indexImpl(context: Context, withCache: Boolean): Library {
if (ContextCompat.checkSelfPermission(context, PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
// No permissions, signal that we can't do anything.
throw NoPermissionException()
}
// Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music
// experience.
val cacheExtractor = CacheExtractor.from(context, withCache)
val mediaStoreExtractor = MediaStoreExtractor.from(context, cacheExtractor)
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw NoMusicException() }
// Build the rest of the music library from the song list. This is much more powerful
// and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis()
val library = Library(rawSongs, MusicSettings.from(context))
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
return library
}
/**
* Load a list of [Song]s from the device.
* @param metadataExtractor The completed [MetadataExtractor] instance to use to load [Song.Raw]
* instances.
* @return A possibly empty list of [Song]s. These [Song]s will be incomplete and must be linked
* with parent [Album], [Artist], and [Genre] items in order to be usable.
*/
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<Song.Raw> {
logD("Starting indexing process")
val start = System.currentTimeMillis()
// Start initializing the extractors. Use an indeterminate state, as there is no ETA on
// how long a media database query will take.
emitIndexing(Indexing.Indeterminate)
val total = metadataExtractor.init()
yield()
// Note: We use a set here so we can eliminate song duplicates.
val rawSongs = mutableListOf<Song.Raw>()
metadataExtractor.extract().collect { rawSong ->
rawSongs.add(rawSong)
// Now we can signal a defined progress by showing how many songs we have
// loaded, and the projected amount of songs we found in the library
// (obtained by the extractors)
yield()
emitIndexing(Indexing.Songs(rawSongs.size, total))
}
// Finalize the extractors with the songs we have now loaded. There is no ETA
// on this process, so go back to an indeterminate state.
emitIndexing(Indexing.Indeterminate)
metadataExtractor.finalize(rawSongs)
logD(
"Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms")
return rawSongs
}
/**
* Emit a new [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 [Indexing] state to emit, or null if no loading process is occurring.
*/
@Synchronized
private fun emitIndexing(indexing: 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 { State.Indexing(it) } ?: lastResponse?.let { State.Complete(it) }
controller?.onIndexerStateChanged(state)
listener?.onIndexerStateChanged(state)
}
/**
* Emit a new [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 = State.Complete(result)
controller?.onIndexerStateChanged(state)
listener?.onIndexerStateChanged(state)
}
}
}
/** Represents the current state of [Indexer]. */ /** Represents the current state of [Indexer]. */
sealed class State { sealed class State {
@ -394,17 +209,209 @@ class Indexer private constructor() {
* Get a singleton instance. * Get a singleton instance.
* @return The (possibly newly-created) singleton instance. * @return The (possibly newly-created) singleton instance.
*/ */
fun getInstance(): Indexer { fun get(): Indexer {
val currentInstance = INSTANCE val currentInstance = INSTANCE
if (currentInstance != null) { if (currentInstance != null) {
return currentInstance return currentInstance
} }
synchronized(this) { synchronized(this) {
val newInstance = Indexer() val newInstance = RealIndexer()
INSTANCE = newInstance INSTANCE = newInstance
return newInstance return newInstance
} }
} }
} }
} }
private class RealIndexer : 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 suspend fun index(context: Context, withCache: Boolean) {
val result =
try {
val start = System.currentTimeMillis()
val library = indexImpl(context, withCache)
logD(
"Music indexing completed successfully in " +
"${System.currentTimeMillis() - start}ms")
Result.success(library)
} 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): Library {
if (ContextCompat.checkSelfPermission(context, Indexer.PERMISSION_READ_AUDIO) ==
PackageManager.PERMISSION_DENIED) {
// No permissions, signal that we can't do anything.
throw Indexer.NoPermissionException()
}
// Create the chain of extractors. Each extractor builds on the previous and
// enables version-specific features in order to create the best possible music
// experience.
val cacheExtractor = CacheExtractor.from(context, withCache)
val mediaStoreExtractor = MediaStoreExtractor.from(context, cacheExtractor)
val metadataExtractor = MetadataExtractor(context, mediaStoreExtractor)
val rawSongs = loadRawSongs(metadataExtractor).ifEmpty { throw Indexer.NoMusicException() }
// Build the rest of the music library from the song list. This is much more powerful
// and reliable compared to using MediaStore to obtain grouping information.
val buildStart = System.currentTimeMillis()
val library = Library(rawSongs, MusicSettings.from(context))
logD("Successfully built library in ${System.currentTimeMillis() - buildStart}ms")
return library
}
private suspend fun loadRawSongs(metadataExtractor: MetadataExtractor): List<Song.Raw> {
logD("Starting indexing process")
val start = System.currentTimeMillis()
// 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)
val total = metadataExtractor.init()
yield()
// Note: We use a set here so we can eliminate song duplicates.
val rawSongs = mutableListOf<Song.Raw>()
metadataExtractor.extract().collect { rawSong ->
rawSongs.add(rawSong)
// Now we can signal a defined progress by showing how many songs we have
// loaded, and the projected amount of songs we found in the library
// (obtained by the extractors)
yield()
emitIndexing(Indexer.Indexing.Songs(rawSongs.size, total))
}
// Finalize the extractors with the songs we have now loaded. There is no ETA
// on this process, so go back to an indeterminate state.
emitIndexing(Indexer.Indexing.Indeterminate)
metadataExtractor.finalize(rawSongs)
logD(
"Successfully loaded ${rawSongs.size} raw songs in ${System.currentTimeMillis() - start}ms")
return rawSongs
}
/**
* 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

@ -54,7 +54,7 @@ import org.oxycblt.auxio.util.logD
* @author Alexander Capehart (OxygenCobalt) * @author Alexander Capehart (OxygenCobalt)
*/ */
class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener { class IndexerService : Service(), Indexer.Controller, MusicSettings.Listener {
private val indexer = Indexer.getInstance() private val indexer = Indexer.get()
private val musicRepository = MusicRepository.get() private val musicRepository = MusicRepository.get()
private val playbackManager = PlaybackStateManager.get() private val playbackManager = PlaybackStateManager.get()
private val serviceJob = Job() private val serviceJob = Job()

View file

@ -37,8 +37,11 @@ import org.oxycblt.auxio.music.Song
* @author OxygenCobalt * @author OxygenCobalt
*/ */
interface Queue { interface Queue {
/** The index of the currently playing [Song] in the current mapping. */
val index: Int val index: Int
/** The currently playing [Song]. */
val currentSong: Song? val currentSong: Song?
/** Whether this queue is shuffled. */
val isShuffled: Boolean val isShuffled: Boolean
/** /**
* Resolve this queue into a more conventional list of [Song]s. * Resolve this queue into a more conventional list of [Song]s.
@ -96,25 +99,18 @@ class EditableQueue : Queue {
@Volatile private var heap = mutableListOf<Song>() @Volatile private var heap = mutableListOf<Song>()
@Volatile private var orderedMapping = mutableListOf<Int>() @Volatile private var orderedMapping = mutableListOf<Int>()
@Volatile private var shuffledMapping = mutableListOf<Int>() @Volatile private var shuffledMapping = mutableListOf<Int>()
/** The index of the currently playing [Song] in the current mapping. */
@Volatile @Volatile
override var index = -1 override var index = -1
private set private set
/** The currently playing [Song]. */
override val currentSong: Song? override val currentSong: Song?
get() = get() =
shuffledMapping shuffledMapping
.ifEmpty { orderedMapping.ifEmpty { null } } .ifEmpty { orderedMapping.ifEmpty { null } }
?.getOrNull(index) ?.getOrNull(index)
?.let(heap::get) ?.let(heap::get)
/** Whether this queue is shuffled. */
override val isShuffled: Boolean override val isShuffled: Boolean
get() = shuffledMapping.isNotEmpty() get() = shuffledMapping.isNotEmpty()
/**
* Resolve this queue into a more conventional list of [Song]s.
* @return A list of [Song] corresponding to the current queue mapping.
*/
override fun resolve() = override fun resolve() =
if (currentSong != null) { if (currentSong != null) {
shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } } shuffledMapping.map { heap[it] }.ifEmpty { orderedMapping.map { heap[it] } }