From 6e55801513b44ba44fab6f04e207cb04c6ce75c8 Mon Sep 17 00:00:00 2001 From: Alexander Capehart Date: Sat, 11 Feb 2023 14:46:58 -0700 Subject: [PATCH] music: make extractors injectable Refactor the music module to make each individual extractor able to be injected directly. --- .../auxio/detail/AlbumDetailFragment.kt | 2 +- .../auxio/detail/ArtistDetailFragment.kt | 2 +- .../oxycblt/auxio/detail/DetailViewModel.kt | 4 +- .../auxio/detail/GenreDetailFragment.kt | 2 +- .../org/oxycblt/auxio/home/HomeFragment.kt | 4 +- .../org/oxycblt/auxio/home/HomeViewModel.kt | 4 +- .../auxio/home/list/AlbumListFragment.kt | 2 +- .../auxio/home/list/ArtistListFragment.kt | 2 +- .../auxio/home/list/GenreListFragment.kt | 2 +- .../auxio/home/list/SongListFragment.kt | 2 +- .../auxio/image/extractor/Components.kt | 2 +- .../main/java/org/oxycblt/auxio/list/Sort.kt | 2 +- .../list/selection/SelectionViewModel.kt | 2 +- .../org/oxycblt/auxio/music/MusicModule.kt | 16 +- .../oxycblt/auxio/music/MusicRepository.kt | 13 +- .../org/oxycblt/auxio/music/MusicSettings.kt | 6 +- .../CacheDatabase.kt} | 123 +---- .../cache/CacheModule.kt} | 26 +- .../auxio/music/cache/CacheRepository.kt | 126 +++++ .../oxycblt/auxio/music/metadata/AudioInfo.kt | 17 +- .../auxio/music/metadata/MetadataModule.kt | 30 ++ .../TagExtractor.kt} | 41 +- .../auxio/music/{library => model}/Library.kt | 2 +- .../music/{library => model}/RawMusic.kt | 2 +- .../music/{library => model}/RealMusic.kt | 2 +- .../MediaStoreExtractor.kt | 485 +++++++++--------- .../auxio/music/storage/StorageModule.kt | 34 ++ .../org/oxycblt/auxio/music/system/Indexer.kt | 43 +- .../oxycblt/auxio/picker/PickerViewModel.kt | 2 +- .../playback/persist/PersistenceDatabase.kt | 23 +- .../playback/persist/PersistenceRepository.kt | 2 +- .../auxio/playback/system/PlaybackService.kt | 2 +- .../oxycblt/auxio/search/SearchViewModel.kt | 4 +- .../music/{library => model}/RawMusicTest.kt | 2 +- 34 files changed, 564 insertions(+), 469 deletions(-) rename app/src/main/java/org/oxycblt/auxio/music/{extractor/MetadataCacheRepository.kt => cache/CacheDatabase.kt} (63%) rename app/src/main/java/org/oxycblt/auxio/{playback/persist/PersistenceConverters.kt => music/cache/CacheModule.kt} (58%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt create mode 100644 app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt rename app/src/main/java/org/oxycblt/auxio/music/{extractor/MetadataExtractor.kt => metadata/TagExtractor.kt} (91%) rename app/src/main/java/org/oxycblt/auxio/music/{library => model}/Library.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{library => model}/RawMusic.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{library => model}/RealMusic.kt (99%) rename app/src/main/java/org/oxycblt/auxio/music/{extractor => storage}/MediaStoreExtractor.kt (52%) create mode 100644 app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt rename app/src/test/java/org/oxycblt/auxio/music/{library => model}/RawMusicTest.kt (99%) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt index 7fce9482e..74a7eb6bd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/AlbumDetailFragment.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.databinding.FragmentDetailBinding import org.oxycblt.auxio.detail.recycler.AlbumDetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Album @@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.util.* diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt index 7875642ce..8bdca12ab 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ArtistDetailFragment.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.detail.recycler.ArtistDetailAdapter import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Album @@ -39,7 +40,6 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.util.collect 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 5f354baa1..7c738a768 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/DetailViewModel.kt @@ -32,12 +32,12 @@ import org.oxycblt.auxio.R import org.oxycblt.auxio.detail.recycler.SortHeader import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.metadata.Disc import org.oxycblt.auxio.music.metadata.ReleaseType +import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.* diff --git a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt index 5d65cf42c..ce9fa505c 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/GenreDetailFragment.kt @@ -32,6 +32,7 @@ import org.oxycblt.auxio.detail.recycler.DetailAdapter import org.oxycblt.auxio.detail.recycler.GenreDetailAdapter import org.oxycblt.auxio.list.Item import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.Album @@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.NavigationViewModel import org.oxycblt.auxio.util.collect 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 a3dbc87ac..41a7f2240 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeFragment.kt @@ -50,11 +50,11 @@ import org.oxycblt.auxio.home.list.ArtistListFragment import org.oxycblt.auxio.home.list.GenreListFragment import org.oxycblt.auxio.home.list.SongListFragment import org.oxycblt.auxio.home.tabs.AdaptiveTabStrategy +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.list.Sort +import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.music.system.Indexer import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.ui.MainNavigationAction 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 e6dc8c8bb..d949b13ce 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/HomeViewModel.kt @@ -23,9 +23,9 @@ import javax.inject.Inject import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import org.oxycblt.auxio.home.tabs.Tab -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt index acea4f078..8820db820 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/AlbumListFragment.kt @@ -31,13 +31,13 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter import org.oxycblt.auxio.list.recycler.AlbumViewHolder import org.oxycblt.auxio.list.selection.SelectionViewModel import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt index fb6bc59f6..5d9ec7357 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/ArtistListFragment.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter @@ -38,7 +39,6 @@ import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.ui.NavigationViewModel diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt index 7ff2233e0..863eb22cb 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/GenreListFragment.kt @@ -29,6 +29,7 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter @@ -38,7 +39,6 @@ import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.ui.NavigationViewModel diff --git a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt index 64391e721..1990737df 100644 --- a/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt +++ b/app/src/main/java/org/oxycblt/auxio/home/list/SongListFragment.kt @@ -31,6 +31,7 @@ import org.oxycblt.auxio.home.HomeViewModel import org.oxycblt.auxio.home.fastscroll.FastScrollRecyclerView import org.oxycblt.auxio.list.* import org.oxycblt.auxio.list.ListFragment +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.list.adapter.BasicListInstructions import org.oxycblt.auxio.list.adapter.ListDiffer import org.oxycblt.auxio.list.adapter.SelectionIndicatorAdapter @@ -40,7 +41,6 @@ import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.MusicMode import org.oxycblt.auxio.music.MusicParent import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.formatDurationMs import org.oxycblt.auxio.playback.secsToMs diff --git a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt index 1ce04fc46..edbcc69bc 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/extractor/Components.kt @@ -30,12 +30,12 @@ import coil.size.Size import kotlin.math.min import okio.buffer import okio.source +import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.Album import org.oxycblt.auxio.music.Artist import org.oxycblt.auxio.music.Genre import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.list.Sort /** * A [Keyer] implementation for [Music] data. diff --git a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt index 9ea6afaa2..941d7ffc5 100644 --- a/app/src/main/java/org/oxycblt/auxio/list/Sort.kt +++ b/app/src/main/java/org/oxycblt/auxio/list/Sort.kt @@ -21,8 +21,8 @@ import androidx.annotation.IdRes import kotlin.math.max import org.oxycblt.auxio.IntegerTable import org.oxycblt.auxio.R -import org.oxycblt.auxio.music.* import org.oxycblt.auxio.list.Sort.Mode +import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.Disc 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 140ca657e..66424b1d1 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 @@ -23,7 +23,7 @@ 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.music.model.Library /** * A [ViewModel] that manages the current selection. 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 6e7ae886b..159a28b7d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicModule.kt @@ -17,22 +17,18 @@ package org.oxycblt.auxio.music -import android.content.Context +import dagger.Binds import dagger.Module -import dagger.Provides import dagger.hilt.InstallIn -import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton -import org.oxycblt.auxio.music.metadata.AudioInfo import org.oxycblt.auxio.music.system.Indexer +import org.oxycblt.auxio.music.system.IndexerImpl @Module @InstallIn(SingletonComponent::class) -class MusicModule { - @Singleton @Provides fun musicRepository() = MusicRepository.new() - @Singleton @Provides fun indexer() = Indexer.new() - @Provides fun settings(@ApplicationContext context: Context) = MusicSettings.from(context) - @Provides - fun audioInfoProvider(@ApplicationContext context: Context) = AudioInfo.Provider.from(context) +interface MusicModule { + @Singleton @Binds fun musicRepository(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 3ff189768..9b4f73884 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicRepository.kt @@ -17,7 +17,8 @@ package org.oxycblt.auxio.music -import org.oxycblt.auxio.music.library.Library +import javax.inject.Inject +import org.oxycblt.auxio.music.model.Library /** * A repository granting access to the music library. @@ -60,17 +61,9 @@ interface MusicRepository { */ fun onLibraryChanged(library: Library?) } - - companion object { - /** - * Create a new instance. - * @return A newly-created implementation of [MusicRepository]. - */ - fun new(): MusicRepository = RealMusicRepository() - } } -private class RealMusicRepository : MusicRepository { +class MusicRepositoryImpl @Inject constructor() : MusicRepository { private val listeners = mutableListOf() @Volatile diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt index df432e612..a36ec925d 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicSettings.kt @@ -20,6 +20,8 @@ package org.oxycblt.auxio.music import android.content.Context import android.os.storage.StorageManager import androidx.core.content.edit +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import org.oxycblt.auxio.R import org.oxycblt.auxio.list.Sort import org.oxycblt.auxio.music.storage.Directory @@ -67,11 +69,11 @@ interface MusicSettings : Settings { * Get a framework-backed implementation. * @param context [Context] required. */ - fun from(context: Context): MusicSettings = RealMusicSettings(context) + fun from(context: Context): MusicSettings = MusicSettingsImpl(context) } } -private class RealMusicSettings(context: Context) : +class MusicSettingsImpl @Inject constructor(@ApplicationContext context: Context) : Settings.Real(context), MusicSettings { private val storageManager = context.getSystemServiceCompat(StorageManager::class) diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt similarity index 63% rename from app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt rename to app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt index 173835e11..bc2f8a21e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataCacheRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Auxio Project + * Copyright (c) 2023 Auxio Project * * 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 @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.extractor +package org.oxycblt.auxio.music.cache import android.content.Context import androidx.room.Dao @@ -28,126 +28,23 @@ import androidx.room.Room import androidx.room.RoomDatabase import androidx.room.TypeConverter import androidx.room.TypeConverters -import org.oxycblt.auxio.music.Song -import org.oxycblt.auxio.music.library.RawSong import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -import org.oxycblt.auxio.util.* - -/** - * A cache of music metadata obtained in prior music loading operations. Obtain an instance with - * [MetadataCacheRepository]. - * @author Alexander Capehart (OxygenCobalt) - */ -interface MetadataCache { - /** Whether this cache has encountered a [RawSong] that did not have a cache entry. */ - val invalidated: Boolean - - /** - * Populate a [RawSong] from a cache entry, if it exists. - * @param rawSong The [RawSong] to populate. - * @return true if a cache entry could be applied to [rawSong], false otherwise. - */ - fun populate(rawSong: RawSong): Boolean -} - -private class RealMetadataCache(cachedSongs: List) : MetadataCache { - private val cacheMap = buildMap { - for (cachedSong in cachedSongs) { - put(cachedSong.mediaStoreId, cachedSong) - } - } - - override var invalidated = false - override fun populate(rawSong: RawSong): Boolean { - - // For a cached raw song to be used, it must exist within the cache and have matching - // addition and modification timestamps. Technically the addition timestamp doesn't - // exist, but to safeguard against possible OEM-specific timestamp incoherence, we - // check for it anyway. - val cachedSong = cacheMap[rawSong.mediaStoreId] - if (cachedSong != null && - cachedSong.dateAdded == rawSong.dateAdded && - cachedSong.dateModified == rawSong.dateModified) { - cachedSong.copyToRaw(rawSong) - return true - } - - // We could not populate this song. This means our cache is stale and should be - // re-written with newly-loaded music. - invalidated = true - return false - } -} - -/** - * A repository allowing access to cached metadata obtained in prior music loading operations. - * @author Alexander Capehart (OxygenCobalt) - */ -interface MetadataCacheRepository { - /** - * Read the current [MetadataCache], if it exists. - * @return The stored [MetadataCache], or null if it could not be obtained. - */ - suspend fun readCache(): MetadataCache? - - /** - * Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data. - * @param rawSongs The [rawSongs] to write to the cache. - */ - suspend fun writeCache(rawSongs: List) - - companion object { - /** - * Create a framework-backed instance. - * @param context [Context] required. - * @return A new instance. - */ - fun from(context: Context): MetadataCacheRepository = RealMetadataCacheRepository(context) - } -} - -private class RealMetadataCacheRepository(private val context: Context) : MetadataCacheRepository { - private val cachedSongsDao: CachedSongsDao by lazy { - MetadataCacheDatabase.getInstance(context).cachedSongsDao() - } - - override suspend fun readCache() = - try { - // Faster to load the whole database into memory than do a query on each - // populate call. - RealMetadataCache(cachedSongsDao.readSongs()) - } catch (e: Exception) { - logE("Unable to load cache database.") - logE(e.stackTraceToString()) - null - } - - override suspend fun writeCache(rawSongs: List) { - try { - // Still write out whatever data was extracted. - cachedSongsDao.nukeSongs() - cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw)) - } catch (e: Exception) { - logE("Unable to save cache database.") - logE(e.stackTraceToString()) - } - } -} +import org.oxycblt.auxio.music.model.RawSong @Database(entities = [CachedSong::class], version = 27, exportSchema = false) -private abstract class MetadataCacheDatabase : RoomDatabase() { +abstract class CacheDatabase : RoomDatabase() { abstract fun cachedSongsDao(): CachedSongsDao companion object { - @Volatile private var INSTANCE: MetadataCacheDatabase? = null + @Volatile private var INSTANCE: CacheDatabase? = null /** * Get/create the shared instance of this database. * @param context [Context] required. */ - fun getInstance(context: Context): MetadataCacheDatabase { + fun getInstance(context: Context): CacheDatabase { val instance = INSTANCE if (instance != null) { return instance @@ -157,8 +54,8 @@ private abstract class MetadataCacheDatabase : RoomDatabase() { val newInstance = Room.databaseBuilder( context.applicationContext, - MetadataCacheDatabase::class.java, - "auxio_metadata_cache.db") + CacheDatabase::class.java, + "auxio_tag_cache.db") .fallbackToDestructiveMigration() .fallbackToDestructiveMigrationFrom(0) .fallbackToDestructiveMigrationOnDowngrade() @@ -171,7 +68,7 @@ private abstract class MetadataCacheDatabase : RoomDatabase() { } @Dao -private interface CachedSongsDao { +interface CachedSongsDao { @Query("SELECT * FROM ${CachedSong.TABLE_NAME}") suspend fun readSongs(): List @Query("DELETE FROM ${CachedSong.TABLE_NAME}") suspend fun nukeSongs() @Insert suspend fun insertSongs(songs: List) @@ -179,7 +76,7 @@ private interface CachedSongsDao { @Entity(tableName = CachedSong.TABLE_NAME) @TypeConverters(CachedSong.Converters::class) -private data class CachedSong( +data class CachedSong( /** * The ID of the [Song]'s audio file, obtained from MediaStore. Note that this ID is highly * unstable and should only be used for accessing the audio file. diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceConverters.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt similarity index 58% rename from app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceConverters.kt rename to app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt index d3ce62be9..9c50e12ca 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceConverters.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt @@ -15,19 +15,19 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.playback.persist +package org.oxycblt.auxio.music.cache -import androidx.room.TypeConverter -import org.oxycblt.auxio.music.Music +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.oxycblt.auxio.music.extractor.CacheRepository -/** - * Defines conversions used in the persistence table. - * @author Alexander Capehart (OxygenCobalt) - */ -object PersistenceConverters { - /** @see [Music.UID.toString] */ - @TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString() - - /** @see [Music.UID.fromString] */ - @TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString) +@Module +@InstallIn(SingletonComponent::class) +class CacheModule { + @Provides + fun cacheRepository(@ApplicationContext context: Context) = CacheRepository.from(context) } diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt new file mode 100644 index 000000000..131cde74d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2022 Auxio Project + * + * 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.extractor + +import android.content.Context +import org.oxycblt.auxio.music.cache.CacheDatabase +import org.oxycblt.auxio.music.cache.CachedSong +import org.oxycblt.auxio.music.cache.CachedSongsDao +import org.oxycblt.auxio.music.model.RawSong +import org.oxycblt.auxio.util.* + +/** + * A cache of music metadata obtained in prior music loading operations. Obtain an instance with + * [CacheRepository]. + * @author Alexander Capehart (OxygenCobalt) + */ +interface Cache { + /** Whether this cache has encountered a [RawSong] that did not have a cache entry. */ + val invalidated: Boolean + + /** + * Populate a [RawSong] from a cache entry, if it exists. + * @param rawSong The [RawSong] to populate. + * @return true if a cache entry could be applied to [rawSong], false otherwise. + */ + fun populate(rawSong: RawSong): Boolean +} + +private class RealCache(cachedSongs: List) : Cache { + private val cacheMap = buildMap { + for (cachedSong in cachedSongs) { + put(cachedSong.mediaStoreId, cachedSong) + } + } + + override var invalidated = false + override fun populate(rawSong: RawSong): Boolean { + + // For a cached raw song to be used, it must exist within the cache and have matching + // addition and modification timestamps. Technically the addition timestamp doesn't + // exist, but to safeguard against possible OEM-specific timestamp incoherence, we + // check for it anyway. + val cachedSong = cacheMap[rawSong.mediaStoreId] + if (cachedSong != null && + cachedSong.dateAdded == rawSong.dateAdded && + cachedSong.dateModified == rawSong.dateModified) { + cachedSong.copyToRaw(rawSong) + return true + } + + // We could not populate this song. This means our cache is stale and should be + // re-written with newly-loaded music. + invalidated = true + return false + } +} + +/** + * A repository allowing access to cached metadata obtained in prior music loading operations. + * @author Alexander Capehart (OxygenCobalt) + */ +interface CacheRepository { + /** + * Read the current [Cache], if it exists. + * @return The stored [Cache], or null if it could not be obtained. + */ + suspend fun readCache(): Cache? + + /** + * Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data. + * @param rawSongs The [rawSongs] to write to the cache. + */ + suspend fun writeCache(rawSongs: List) + + companion object { + /** + * Create a framework-backed instance. + * @param context [Context] required. + * @return A new instance. + */ + fun from(context: Context): CacheRepository = RealCacheRepository(context) + } +} + +private class RealCacheRepository(private val context: Context) : CacheRepository { + private val cachedSongsDao: CachedSongsDao by lazy { + CacheDatabase.getInstance(context).cachedSongsDao() + } + + override suspend fun readCache() = + try { + // Faster to load the whole database into memory than do a query on each + // populate call. + RealCache(cachedSongsDao.readSongs()) + } catch (e: Exception) { + logE("Unable to load cache database.") + logE(e.stackTraceToString()) + null + } + + override suspend fun writeCache(rawSongs: List) { + try { + // Still write out whatever data was extracted. + cachedSongsDao.nukeSongs() + cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw)) + } catch (e: Exception) { + logE("Unable to save cache database.") + logE(e.stackTraceToString()) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt index ae6f7f294..5f0172ba0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/AudioInfo.kt @@ -20,6 +20,8 @@ package org.oxycblt.auxio.music.metadata import android.content.Context import android.media.MediaExtractor import android.media.MediaFormat +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.storage.MimeType import org.oxycblt.auxio.util.logD @@ -46,18 +48,15 @@ data class AudioInfo( * @return The [AudioInfo] of the [Song], if possible to obtain. */ suspend fun extract(song: Song): AudioInfo - - companion object { - /** - * Get a framework-backed implementation. - * @param context [Context] required. - */ - fun from(context: Context): Provider = RealAudioInfoProvider(context) - } } } -private class RealAudioInfoProvider(private val context: Context) : AudioInfo.Provider { +/** + * A framework-backed implementation of [AudioInfo.Provider]. + * @param context [Context] required to read audio files. + */ +class AudioInfoProviderImpl @Inject constructor(@ApplicationContext private val context: Context) : + AudioInfo.Provider { // While we would use ExoPlayer to extract this information, it doesn't support // common data like bit rate in progressive data sources due to there being no // demand. Thus, we are stuck with the inferior OS-provided MediaExtractor. diff --git a/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt new file mode 100644 index 000000000..469d21a7b --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/MetadataModule.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package org.oxycblt.auxio.music.metadata + +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent + +@Module +@InstallIn(SingletonComponent::class) +interface MetadataModule { + @Binds fun tagExtractor(tagExtractor: TagExtractorImpl): TagExtractor + @Binds fun audioInfoProvider(audioInfoProvider: AudioInfoProviderImpl): AudioInfo.Provider +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt similarity index 91% rename from app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt rename to app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt index 9edbef5fa..2d6a8ed96 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MetadataExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/metadata/TagExtractor.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 Auxio Project + * Copyright (c) 2023 Auxio Project * * 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 @@ -15,19 +15,17 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.extractor +package org.oxycblt.auxio.music.metadata import android.content.Context import androidx.core.text.isDigitsOnly import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.MetadataRetriever +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield -import org.oxycblt.auxio.music.library.RawSong -import org.oxycblt.auxio.music.metadata.Date -import org.oxycblt.auxio.music.metadata.TextTags -import org.oxycblt.auxio.music.metadata.parseId3v2PositionField -import org.oxycblt.auxio.music.metadata.parseVorbisPositionField +import org.oxycblt.auxio.music.model.RawSong import org.oxycblt.auxio.music.storage.toAudioUri import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logW @@ -35,17 +33,30 @@ import org.oxycblt.auxio.util.logW /** * The extractor that leverages ExoPlayer's [MetadataRetriever] API to parse metadata. This is the * last step in the music extraction process and is mostly responsible for papering over the bad - * metadata that [RealMediaStoreExtractor] produces. + * metadata that other extractors produce. * - * @param context [Context] required for reading audio files. * @author Alexander Capehart (OxygenCobalt) */ -class MetadataExtractor(private val context: Context) { - // We can parallelize MetadataRetriever Futures to work around it's speed issues, - // producing similar throughput's to other kinds of manual metadata extraction. - private val taskPool: Array = arrayOfNulls(TASK_CAPACITY) +interface TagExtractor { + /** + * Extract the metadata of songs from [incompleteSongs] and send them to [completeSongs]. Will + * terminate as soon as [incompleteSongs] is closed. + * @param incompleteSongs A [Channel] of incomplete songs to process. + * @param completeSongs A [Channel] to send completed songs to. + */ + suspend fun consume(incompleteSongs: Channel, completeSongs: Channel) +} + +class TagExtractorImpl @Inject constructor(@ApplicationContext private val context: Context) : + TagExtractor { + override suspend fun consume( + incompleteSongs: Channel, + completeSongs: Channel + ) { + // We can parallelize MetadataRetriever Futures to work around it's speed issues, + // producing similar throughput's to other kinds of manual metadata extraction. + val taskPool: Array = arrayOfNulls(TASK_CAPACITY) - suspend fun consume(incompleteSongs: Channel, completeSongs: Channel) { spin@ while (true) { // Spin until there is an open slot we can insert a task in. for (i in taskPool.indices) { @@ -94,7 +105,7 @@ class MetadataExtractor(private val context: Context) { } /** - * Wraps a [MetadataExtractor] future and processes it into a [RawSong] when completed. + * Wraps a [TagExtractor] future and processes it into a [RawSong] when completed. * @param context [Context] required to open the audio file. * @param rawSong [RawSong] to process. * @author Alexander Capehart (OxygenCobalt) diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt b/app/src/main/java/org/oxycblt/auxio/music/model/Library.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/library/Library.kt rename to app/src/main/java/org/oxycblt/auxio/music/model/Library.kt index 2d4663afe..502a9367f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/Library.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/model/Library.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +package org.oxycblt.auxio.music.model import android.content.Context import android.net.Uri diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt rename to app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt index 7300af7ff..5ed44d775 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/RawMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/model/RawMusic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +package org.oxycblt.auxio.music.model import java.util.UUID import org.oxycblt.auxio.music.* diff --git a/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt b/app/src/main/java/org/oxycblt/auxio/music/model/RealMusic.kt similarity index 99% rename from app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt rename to app/src/main/java/org/oxycblt/auxio/music/model/RealMusic.kt index 595edb98f..58ca45f37 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/library/RealMusic.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/model/RealMusic.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +package org.oxycblt.auxio.music.model import android.content.Context import androidx.annotation.VisibleForTesting diff --git a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt similarity index 52% rename from app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt rename to app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt index 939cf9aa2..931bba9ed 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/extractor/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/MediaStoreExtractor.kt @@ -15,13 +15,12 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.extractor +package org.oxycblt.auxio.music.storage import android.content.Context import android.database.Cursor import android.os.Build import android.os.storage.StorageManager -import android.os.storage.StorageVolume import android.provider.MediaStore import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull @@ -30,91 +29,77 @@ import java.io.File import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.yield import org.oxycblt.auxio.music.MusicSettings -import org.oxycblt.auxio.music.library.RawSong +import org.oxycblt.auxio.music.extractor.Cache import org.oxycblt.auxio.music.metadata.Date import org.oxycblt.auxio.music.metadata.parseId3v2PositionField import org.oxycblt.auxio.music.metadata.transformPositionField -import org.oxycblt.auxio.music.storage.Directory -import org.oxycblt.auxio.music.storage.contentResolverSafe -import org.oxycblt.auxio.music.storage.directoryCompat -import org.oxycblt.auxio.music.storage.mediaStoreVolumeNameCompat -import org.oxycblt.auxio.music.storage.safeQuery -import org.oxycblt.auxio.music.storage.storageVolumesCompat -import org.oxycblt.auxio.music.storage.useQuery +import org.oxycblt.auxio.music.model.RawSong import org.oxycblt.auxio.util.getSystemServiceCompat import org.oxycblt.auxio.util.logD /** * The layer that loads music from the [MediaStore] database. This is an intermediate step in the * music extraction process and primarily intended for redundancy for files not natively supported - * by [MetadataExtractor]. Solely relying on this is not recommended, as it often produces bad + * by other extractors. Solely relying on this is not recommended, as it often produces bad * metadata. * @author Alexander Capehart (OxygenCobalt) */ interface MediaStoreExtractor { /** - * Query the media database, initializing this instance in the process. - * @return The new [Cursor] returned by the media databases. + * Query the media database. + * @return A new [Query] returned from the media database. */ - suspend fun query(): Cursor + suspend fun query(): Query /** * Consume the [Cursor] loaded after [query]. - * @param cache A [MetadataCache] used to avoid extracting metadata for cached songs, or null if - * no [MetadataCache] was available. - * @param incompleteSongs A channel where songs that could not be retrieved from the - * [MetadataCache] should be sent to. + * @param query The [Query] to consume. + * @param cache A [Cache] used to avoid extracting metadata for cached songs, or null if no + * [Cache] was available. + * @param incompleteSongs A channel where songs that could not be retrieved from the [Cache] + * should be sent to. * @param completeSongs A channel where completed songs should be sent to. */ suspend fun consume( - cache: MetadataCache?, + query: Query, + cache: Cache?, incompleteSongs: Channel, completeSongs: Channel ) + /** A black-box interface representing a query from the media database. */ + interface Query { + val projectedTotal: Int + fun moveToNext(): Boolean + fun close() + fun populateFileInfo(rawSong: RawSong) + fun populateTags(rawSong: RawSong) + } + companion object { /** * Create a framework-backed instance. * @param context [Context] required. + * @param musicSettings [MusicSettings] required. * @return A new [RealMediaStoreExtractor] that will work best on the device's API level. */ - fun from(context: Context): MediaStoreExtractor = + fun from(context: Context, musicSettings: MusicSettings): MediaStoreExtractor = when { - Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreExtractor(context) - Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreExtractor(context) - else -> Api21MediaStoreExtractor(context) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> + Api30MediaStoreExtractor(context, musicSettings) + Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> + Api29MediaStoreExtractor(context, musicSettings) + else -> Api21MediaStoreExtractor(context, musicSettings) } } } -private abstract class RealMediaStoreExtractor(private val context: Context) : MediaStoreExtractor { - private var cursor: Cursor? = null - private var idIndex = -1 - private var titleIndex = -1 - private var displayNameIndex = -1 - private var mimeTypeIndex = -1 - private var sizeIndex = -1 - private var dateAddedIndex = -1 - private var dateModifiedIndex = -1 - private var durationIndex = -1 - private var yearIndex = -1 - private var albumIndex = -1 - private var albumIdIndex = -1 - private var artistIndex = -1 - private var albumArtistIndex = -1 - private val genreNamesMap = mutableMapOf() - - /** - * The [StorageVolume]s currently scanned by [MediaStore]. This should be used to transform path - * information from the database into volume-aware paths. - */ - protected var volumes = listOf() - private set - - override suspend fun query(): Cursor { +private abstract class RealMediaStoreExtractor( + protected val context: Context, + private val musicSettings: MusicSettings +) : MediaStoreExtractor { + final override suspend fun query(): MediaStoreExtractor.Query { val start = System.currentTimeMillis() - val musicSettings = MusicSettings.from(context) - val storageManager = context.getSystemServiceCompat(StorageManager::class) val args = mutableListOf() var selector = BASE_SELECTOR @@ -156,30 +141,14 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M // Now we can actually query MediaStore. logD("Starting song query [proj: ${projection.toList()}, selector: $selector, args: $args]") val cursor = - context.contentResolverSafe - .safeQuery( - MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, - projection, - selector, - args.toTypedArray()) - .also { cursor = it } + context.contentResolverSafe.safeQuery( + MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, + projection, + selector, + args.toTypedArray()) logD("Song query succeeded [Projected total: ${cursor.count}]") - // Set up cursor indices for later use. - idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) - titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) - displayNameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) - mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE) - sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE) - dateAddedIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED) - dateModifiedIndex = - cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED) - durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION) - yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR) - albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM) - albumIdIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID) - artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST) - albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST) + val genreNamesMap = mutableMapOf() // Since we can't obtain the genre tag from a song query, we must construct our own // equivalent from genre database queries. Theoretically, this isn't needed since @@ -211,34 +180,31 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M } } - volumes = storageManager.storageVolumesCompat logD("Finished initialization in ${System.currentTimeMillis() - start}ms") - - return cursor + return wrapQuery(cursor, genreNamesMap) } - override suspend fun consume( - cache: MetadataCache?, + final override suspend fun consume( + query: MediaStoreExtractor.Query, + cache: Cache?, incompleteSongs: Channel, completeSongs: Channel ) { - val cursor = requireNotNull(cursor) { "Must call query first before running consume" } - while (cursor.moveToNext()) { + while (query.moveToNext()) { val rawSong = RawSong() - populateFileData(cursor, rawSong) + query.populateFileInfo(rawSong) if (cache?.populate(rawSong) == true) { completeSongs.send(rawSong) } else { - populateMetadata(cursor, rawSong) + query.populateFileInfo(rawSong) incompleteSongs.send(rawSong) } yield() } // Free the cursor and signal that no more incomplete songs will be produced by // this extractor. - cursor.close() + query.close() incompleteSongs.close() - this.cursor = null } /** @@ -280,62 +246,80 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M */ protected abstract fun addDirToSelector(dir: Directory, args: MutableList): Boolean - /** - * Populate a [RawSong] with the "File Data" of the given [MediaStore] [Cursor], which is the - * data that cannot be cached. This includes any information not intrinsic to the file and - * instead dependent on the file-system, which could change without invalidating the cache due - * to volume additions or removals. - * @param cursor The [Cursor] to read from. - * @param rawSong The [RawSong] to populate. - * @see populateMetadata - */ - protected open fun populateFileData(cursor: Cursor, rawSong: RawSong) { - rawSong.mediaStoreId = cursor.getLong(idIndex) - rawSong.dateAdded = cursor.getLong(dateAddedIndex) - rawSong.dateModified = cursor.getLong(dateAddedIndex) - // Try to use the DISPLAY_NAME column to obtain a (probably sane) file name - // from the android system. - rawSong.fileName = cursor.getStringOrNull(displayNameIndex) - rawSong.extensionMimeType = cursor.getString(mimeTypeIndex) - rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex) - } + protected abstract fun wrapQuery( + cursor: Cursor, + genreNamesMap: Map + ): MediaStoreExtractor.Query - /** - * Populate a [RawSong] with the Metadata of the given [MediaStore] [Cursor], which is the data - * about a [RawSong] that can be cached. This includes any information intrinsic to the file or - * it's file format, such as music tags. - * @param cursor The [Cursor] to read from. - * @param rawSong The [RawSong] to populate. - * @see populateFileData - */ - protected open fun populateMetadata(cursor: Cursor, rawSong: RawSong) { - // Song title - rawSong.name = cursor.getString(titleIndex) - // Size (in bytes) - rawSong.size = cursor.getLong(sizeIndex) - // Duration (in milliseconds) - rawSong.durationMs = cursor.getLong(durationIndex) - // MediaStore only exposes the year value of a file. This is actually worse than it - // seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments. - // This is one of the major weaknesses of using MediaStore, hence the redundancy layers. - rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from) - // A non-existent album name should theoretically be the name of the folder it contained - // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it the - // file is not actually in the root internal storage directory. We can't do anything to - // fix this, really. - rawSong.albumName = cursor.getString(albumIndex) - // Android does not make a non-existent artist tag null, it instead fills it in - // as , which makes absolutely no sense given how other columns default - // to null if they are not present. If this column is such, null it so that - // it's easier to handle later. - val artist = cursor.getString(artistIndex) - if (artist != MediaStore.UNKNOWN_STRING) { - rawSong.artistNames = listOf(artist) + abstract class Query( + protected val cursor: Cursor, + private val genreNamesMap: Map + ) : MediaStoreExtractor.Query { + private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID) + private val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) + private val displayNameIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) + private val mimeTypeIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE) + private val sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE) + private val dateAddedIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_ADDED) + private val dateModifiedIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATE_MODIFIED) + private val durationIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION) + private val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR) + private val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM) + private val albumIdIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID) + private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST) + private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST) + + final override val projectedTotal = cursor.count + final override fun moveToNext() = cursor.moveToNext() + final override fun close() = cursor.close() + + override fun populateFileInfo(rawSong: RawSong) { + rawSong.mediaStoreId = cursor.getLong(idIndex) + rawSong.dateAdded = cursor.getLong(dateAddedIndex) + rawSong.dateModified = cursor.getLong(dateModifiedIndex) + // Try to use the DISPLAY_NAME column to obtain a (probably sane) file name + // from the android system. + rawSong.fileName = cursor.getStringOrNull(displayNameIndex) + rawSong.extensionMimeType = cursor.getString(mimeTypeIndex) + rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex) + } + + override fun populateTags(rawSong: RawSong) { + // Song title + rawSong.name = cursor.getString(titleIndex) + // Size (in bytes) + rawSong.size = cursor.getLong(sizeIndex) + // Duration (in milliseconds) + rawSong.durationMs = cursor.getLong(durationIndex) + // MediaStore only exposes the year value of a file. This is actually worse than it + // seems, as it means that it will not read ID3v2 TDRC tags or Vorbis DATE comments. + // This is one of the major weaknesses of using MediaStore, hence the redundancy layers. + rawSong.date = cursor.getStringOrNull(yearIndex)?.let(Date::from) + // A non-existent album name should theoretically be the name of the folder it contained + // in, but in practice it is more often "0" (as in /storage/emulated/0), even when it + // the + // file is not actually in the root internal storage directory. We can't do anything to + // fix this, really. + rawSong.albumName = cursor.getString(albumIndex) + // Android does not make a non-existent artist tag null, it instead fills it in + // as , which makes absolutely no sense given how other columns default + // to null if they are not present. If this column is such, null it so that + // it's easier to handle later. + val artist = cursor.getString(artistIndex) + if (artist != MediaStore.UNKNOWN_STRING) { + rawSong.artistNames = listOf(artist) + } + // The album artist column is nullable and never has placeholder values. + cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) } + // Get the genre value we had to query for in initialization + genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) } } - // The album artist column is nullable and never has placeholder values. - cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) } - // Get the genre value we had to query for in initialization - genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) } } companion object { @@ -364,18 +348,11 @@ private abstract class RealMediaStoreExtractor(private val context: Context) : M // Note: The separation between version-specific backends may not be the cleanest. To preserve // speed, we only want to add redundancy on known issues, not with possible issues. -private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtractor(context) { - private var trackIndex = -1 - private var dataIndex = -1 - - override suspend fun query(): Cursor { - val cursor = super.query() - // Set up cursor indices for later use. - trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) - dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) - return cursor - } +// Note: The separation between version-specific backends may not be the cleanest. To preserve +// speed, we only want to add redundancy on known issues, not with possible issues. +private class Api21MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : + RealMediaStoreExtractor(context, musicSettings) { override val projection: Array get() = super.projection + @@ -398,40 +375,56 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract return true } - override fun populateFileData(cursor: Cursor, rawSong: RawSong) { - super.populateFileData(cursor, rawSong) + override fun wrapQuery( + cursor: Cursor, + genreNamesMap: Map, + ): MediaStoreExtractor.Query = + Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) - val data = cursor.getString(dataIndex) + private class Query( + cursor: Cursor, + genreNamesMap: Map, + storageManager: StorageManager + ) : RealMediaStoreExtractor.Query(cursor, genreNamesMap) { + // Set up cursor indices for later use. + private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) + private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA) + private val volumes = storageManager.storageVolumesCompat - // On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume - // that this only applies to below API 29, as beyond API 29, this column not being - // present would completely break the scoped storage system. Fill it in with DATA - // if it's not available. - if (rawSong.fileName == null) { - rawSong.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null } - } + override fun populateFileInfo(rawSong: RawSong) { + super.populateFileInfo(rawSong) - // Find the volume that transforms the DATA column into a relative path. This is - // the Directory we will use. - val rawPath = data.substringBeforeLast(File.separatorChar) - for (volume in volumes) { - val volumePath = volume.directoryCompat ?: continue - val strippedPath = rawPath.removePrefix(volumePath) - if (strippedPath != rawPath) { - rawSong.directory = Directory.from(volume, strippedPath) - break + val data = cursor.getString(dataIndex) + // On some OEM devices below API 29, DISPLAY_NAME may not be present. I assume + // that this only applies to below API 29, as beyond API 29, this column not being + // present would completely break the scoped storage system. Fill it in with DATA + // if it's not available. + if (rawSong.fileName == null) { + rawSong.fileName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null } + } + + // Find the volume that transforms the DATA column into a relative path. This is + // the Directory we will use. + val rawPath = data.substringBeforeLast(File.separatorChar) + for (volume in volumes) { + val volumePath = volume.directoryCompat ?: continue + val strippedPath = rawPath.removePrefix(volumePath) + if (strippedPath != rawPath) { + rawSong.directory = Directory.from(volume, strippedPath) + break + } } } - } - override fun populateMetadata(cursor: Cursor, rawSong: RawSong) { - super.populateMetadata(cursor, rawSong) - // See unpackTrackNo/unpackDiscNo for an explanation - // of how this column is set up. - val rawTrack = cursor.getIntOrNull(trackIndex) - if (rawTrack != null) { - rawTrack.unpackTrackNo()?.let { rawSong.track = it } - rawTrack.unpackDiscNo()?.let { rawSong.disc = it } + override fun populateTags(rawSong: RawSong) { + super.populateTags(rawSong) + // See unpackTrackNo/unpackDiscNo for an explanation + // of how this column is set up. + val rawTrack = cursor.getIntOrNull(trackIndex) + if (rawTrack != null) { + rawTrack.unpackTrackNo()?.let { rawSong.track = it } + rawTrack.unpackDiscNo()?.let { rawSong.disc = it } + } } } } @@ -442,20 +435,10 @@ private class Api21MediaStoreExtractor(context: Context) : RealMediaStoreExtract * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private open class BaseApi29MediaStoreExtractor(context: Context) : - RealMediaStoreExtractor(context) { - private var volumeIndex = -1 - private var relativePathIndex = -1 - - override suspend fun query(): Cursor { - val cursor = super.query() - // Set up cursor indices for later use. - volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME) - relativePathIndex = - cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH) - return cursor - } - +private abstract class BaseApi29MediaStoreExtractor( + context: Context, + musicSettings: MusicSettings +) : RealMediaStoreExtractor(context, musicSettings) { override val projection: Array get() = super.projection + @@ -484,15 +467,27 @@ private open class BaseApi29MediaStoreExtractor(context: Context) : return true } - override fun populateFileData(cursor: Cursor, rawSong: RawSong) { - super.populateFileData(cursor, rawSong) - // Find the StorageVolume whose MediaStore name corresponds to this song. - // This is combined with the plain relative path column to create the directory. - val volumeName = cursor.getString(volumeIndex) - val relativePath = cursor.getString(relativePathIndex) - val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } - if (volume != null) { - rawSong.directory = Directory.from(volume, relativePath) + abstract class Query( + cursor: Cursor, + genreNamesMap: Map, + storageManager: StorageManager + ) : RealMediaStoreExtractor.Query(cursor, genreNamesMap) { + private val volumeIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME) + private val relativePathIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH) + private val volumes = storageManager.storageVolumesCompat + + final override fun populateFileInfo(rawSong: RawSong) { + super.populateFileInfo(rawSong) + // Find the StorageVolume whose MediaStore name corresponds to this song. + // This is combined with the plain relative path column to create the directory. + val volumeName = cursor.getString(volumeIndex) + val relativePath = cursor.getString(relativePathIndex) + val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } + if (volume != null) { + rawSong.directory = Directory.from(volume, relativePath) + } } } } @@ -505,29 +500,34 @@ private open class BaseApi29MediaStoreExtractor(context: Context) : * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.Q) -private open class Api29MediaStoreExtractor(context: Context) : - BaseApi29MediaStoreExtractor(context) { - private var trackIndex = -1 - - override suspend fun query(): Cursor { - val cursor = super.query() - // Set up cursor indices for later use. - trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) - return cursor - } +private class Api29MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : + BaseApi29MediaStoreExtractor(context, musicSettings) { override val projection: Array get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) - override fun populateMetadata(cursor: Cursor, rawSong: RawSong) { - super.populateMetadata(cursor, rawSong) - // This extractor is volume-aware, but does not support the modern track columns. - // Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation - // of how this column is set up. - val rawTrack = cursor.getIntOrNull(trackIndex) - if (rawTrack != null) { - rawTrack.unpackTrackNo()?.let { rawSong.track = it } - rawTrack.unpackDiscNo()?.let { rawSong.disc = it } + override fun wrapQuery( + cursor: Cursor, + genreNamesMap: Map + ): MediaStoreExtractor.Query = + Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) + + private class Query( + cursor: Cursor, + genreNamesMap: Map, + storageManager: StorageManager + ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) { + private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) + override fun populateTags(rawSong: RawSong) { + super.populateTags(rawSong) + // This extractor is volume-aware, but does not support the modern track columns. + // Use the old column instead. See unpackTrackNo/unpackDiscNo for an explanation + // of how this column is set up. + val rawTrack = cursor.getIntOrNull(trackIndex) + if (rawTrack != null) { + rawTrack.unpackTrackNo()?.let { rawSong.track = it } + rawTrack.unpackDiscNo()?.let { rawSong.disc = it } + } } } } @@ -539,18 +539,8 @@ private open class Api29MediaStoreExtractor(context: Context) : * @author Alexander Capehart (OxygenCobalt) */ @RequiresApi(Build.VERSION_CODES.R) -private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreExtractor(context) { - private var trackIndex: Int = -1 - private var discIndex: Int = -1 - - override suspend fun query(): Cursor { - val cursor = super.query() - // Set up cursor indices for later use. - trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) - discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER) - return cursor - } - +private class Api30MediaStoreExtractor(context: Context, musicSettings: MusicSettings) : + BaseApi29MediaStoreExtractor(context, musicSettings) { override val projection: Array get() = super.projection + @@ -560,14 +550,33 @@ private class Api30MediaStoreExtractor(context: Context) : BaseApi29MediaStoreEx MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER, MediaStore.Audio.AudioColumns.DISC_NUMBER) - override fun populateMetadata(cursor: Cursor, rawSong: RawSong) { - super.populateMetadata(cursor, rawSong) - // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in - // the tag itself, which is to say that it is formatted as NN/TT tracks, where - // N is the number and T is the total. Parse the number while ignoring the - // total, as we have no use for it. - cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { rawSong.track = it } - cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it } + override fun wrapQuery( + cursor: Cursor, + genreNamesMap: Map + ): MediaStoreExtractor.Query = + Query(cursor, genreNamesMap, context.getSystemServiceCompat(StorageManager::class)) + + private class Query( + cursor: Cursor, + genreNamesMap: Map, + storageManager: StorageManager + ) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, storageManager) { + private val trackIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER) + private val discIndex = + cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER) + + override fun populateTags(rawSong: RawSong) { + super.populateTags(rawSong) + // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in + // the tag itself, which is to say that it is formatted as NN/TT tracks, where + // N is the number and T is the total. Parse the number while ignoring the + // total, as we have no use for it. + cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { + rawSong.track = it + } + cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it } + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt new file mode 100644 index 000000000..6869788d1 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/storage/StorageModule.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2023 Auxio Project + * + * 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.storage + +import android.content.Context +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import org.oxycblt.auxio.music.MusicSettings + +@Module +@InstallIn(SingletonComponent::class) +class StorageModule { + @Provides + fun mediaStoreExtractor(@ApplicationContext context: Context, musicSettings: MusicSettings) = + MediaStoreExtractor.from(context, musicSettings) +} 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 index 0cde9e906..dbbf70ea4 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/system/Indexer.kt @@ -23,6 +23,7 @@ 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 @@ -35,8 +36,10 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.music.* import org.oxycblt.auxio.music.extractor.* -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.model.Library +import org.oxycblt.auxio.music.model.RawSong +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 @@ -211,16 +214,17 @@ interface Indexer { } else { Manifest.permission.READ_EXTERNAL_STORAGE } - - /** - * Create a new instance. - * @return A newly-created implementation of [Indexer]. - */ - fun new(): Indexer = RealIndexer() } } -private class RealIndexer : Indexer { +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 @@ -332,19 +336,15 @@ private class RealIndexer : Indexer { // how long a media database query will take. emitIndexing(Indexer.Indexing.Indeterminate) - val metadataCacheRepository = MetadataCacheRepository.from(context) - val mediaStoreExtractor = MediaStoreExtractor.from(context) - val metadataExtractor = MetadataExtractor(context) - // Do the initial query of the cache and media databases in parallel. val mediaStoreQueryJob = scope.async { mediaStoreExtractor.query() } val cache = if (withCache) { - metadataCacheRepository.readCache() + cacheRepository.readCache() } else { null } - val total = mediaStoreQueryJob.await().count + 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 @@ -352,14 +352,16 @@ private class RealIndexer : Indexer { val completeSongs = Channel(Channel.UNLIMITED) val incompleteSongs = Channel(Channel.UNLIMITED) val mediaStoreJob = - scope.async { mediaStoreExtractor.consume(cache, incompleteSongs, completeSongs) } - val metadataJob = scope.async { metadataExtractor.consume(incompleteSongs, completeSongs) } + 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, total)) + emitIndexing(Indexer.Indexing.Songs(rawSongs.size, query.projectedTotal)) } mediaStoreJob.await() metadataJob.await() @@ -367,10 +369,9 @@ private class RealIndexer : Indexer { // Successfully loaded the library, now save the cache and create the library in // parallel. emitIndexing(Indexer.Indexing.Indeterminate) - val libraryJob = - scope.async(Dispatchers.Main) { Library.from(rawSongs, MusicSettings.from(context)) } + val libraryJob = scope.async(Dispatchers.Main) { Library.from(rawSongs, musicSettings) } if (cache == null || cache.invalidated) { - metadataCacheRepository.writeCache(rawSongs) + cacheRepository.writeCache(rawSongs) } return libraryJob.await() } 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 6dc7a3fa2..800f069ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/picker/PickerViewModel.kt @@ -23,7 +23,7 @@ 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.music.model.Library import org.oxycblt.auxio.util.unlikelyToBeNull /** diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt index d887d5f96..627fd3426 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceDatabase.kt @@ -27,6 +27,7 @@ import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.Room import androidx.room.RoomDatabase +import androidx.room.TypeConverter import androidx.room.TypeConverters import org.oxycblt.auxio.music.Music import org.oxycblt.auxio.playback.state.RepeatMode @@ -39,7 +40,7 @@ import org.oxycblt.auxio.playback.state.RepeatMode entities = [PlaybackState::class, QueueHeapItem::class, QueueMappingItem::class], version = 27, exportSchema = false) -@TypeConverters(PersistenceConverters::class) +@TypeConverters(PersistenceDatabase.Converters::class) abstract class PersistenceDatabase : RoomDatabase() { /** * Get the current [PlaybackStateDao]. @@ -53,6 +54,14 @@ abstract class PersistenceDatabase : RoomDatabase() { */ abstract fun queueDao(): QueueDao + object Converters { + /** @see [Music.UID.toString] */ + @TypeConverter fun fromMusicUID(uid: Music.UID?) = uid?.toString() + + /** @see [Music.UID.fromString] */ + @TypeConverter fun toMusicUid(string: String?) = string?.let(Music.UID::fromString) + } + companion object { @Volatile private var INSTANCE: PersistenceDatabase? = null @@ -145,10 +154,6 @@ interface QueueDao { suspend fun insertMapping(mapping: List) } -/** - * A raw representation of the persisted playback state. - * @author Alexander Capehart - */ @Entity(tableName = PlaybackState.TABLE_NAME) data class PlaybackState( @PrimaryKey val id: Int, @@ -163,10 +168,6 @@ data class PlaybackState( } } -/** - * A raw representation of the an individual item in the persisted queue's heap. - * @author Alexander Capehart - */ @Entity(tableName = QueueHeapItem.TABLE_NAME) data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) { companion object { @@ -174,10 +175,6 @@ data class QueueHeapItem(@PrimaryKey val id: Int, val uid: Music.UID) { } } -/** - * A raw representation of the heap indices at a particular position in the persisted queue. - * @author Alexander Capehart - */ @Entity(tableName = QueueMappingItem.TABLE_NAME) data class QueueMappingItem( @PrimaryKey val id: Int, diff --git a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt index 032267515..1839eaa25 100644 --- a/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt +++ b/app/src/main/java/org/oxycblt/auxio/playback/persist/PersistenceRepository.kt @@ -19,7 +19,7 @@ package org.oxycblt.auxio.playback.persist import android.content.Context import org.oxycblt.auxio.music.MusicParent -import org.oxycblt.auxio.music.library.Library +import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.playback.queue.Queue import org.oxycblt.auxio.playback.state.PlaybackStateManager import org.oxycblt.auxio.util.logD 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 dfc5cc197..7ad6ebc08 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,7 @@ 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.music.model.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.playback.persist.PersistenceRepository import org.oxycblt.auxio.playback.replaygain.ReplayGainAudioProcessor 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 f858b1722..6fdf615fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt +++ b/app/src/main/java/org/oxycblt/auxio/search/SearchViewModel.kt @@ -30,9 +30,9 @@ import kotlinx.coroutines.yield import org.oxycblt.auxio.R import org.oxycblt.auxio.list.BasicHeader import org.oxycblt.auxio.list.Item -import org.oxycblt.auxio.music.* -import org.oxycblt.auxio.music.library.Library import org.oxycblt.auxio.list.Sort +import org.oxycblt.auxio.music.* +import org.oxycblt.auxio.music.model.Library import org.oxycblt.auxio.playback.PlaybackSettings import org.oxycblt.auxio.util.logD diff --git a/app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt b/app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt similarity index 99% rename from app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt rename to app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt index 285522015..fdfa8a659 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/library/RawMusicTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/model/RawMusicTest.kt @@ -15,7 +15,7 @@ * along with this program. If not, see . */ -package org.oxycblt.auxio.music.library +package org.oxycblt.auxio.music.model import java.util.* import org.junit.Assert.assertEquals