music: refactor mediastoreextractor internals
Split the version-specific components into "Interpreters" that are then composed into MediaStoreExtractor. This is both a nicer design and also allows me to resolve an evil Huawei bug that prevents me from using the new path fields. Resolves #592
This commit is contained in:
parent
6956ca5915
commit
c7f8b3ca6d
1 changed files with 262 additions and 308 deletions
|
@ -22,7 +22,6 @@ import android.content.Context
|
||||||
import android.database.Cursor
|
import android.database.Cursor
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.provider.MediaStore
|
import android.provider.MediaStore
|
||||||
import androidx.annotation.RequiresApi
|
|
||||||
import androidx.core.database.getIntOrNull
|
import androidx.core.database.getIntOrNull
|
||||||
import androidx.core.database.getStringOrNull
|
import androidx.core.database.getStringOrNull
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
@ -95,69 +94,77 @@ interface MediaStoreExtractor {
|
||||||
* @param volumeManager [VolumeManager] required.
|
* @param volumeManager [VolumeManager] required.
|
||||||
* @return A new [MediaStoreExtractor] that will work best on the device's API level.
|
* @return A new [MediaStoreExtractor] that will work best on the device's API level.
|
||||||
*/
|
*/
|
||||||
fun from(context: Context, volumeManager: VolumeManager): MediaStoreExtractor =
|
fun from(context: Context, volumeManager: VolumeManager): MediaStoreExtractor {
|
||||||
when {
|
val pathInterpreter =
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ->
|
when {
|
||||||
Api30MediaStoreExtractor(context, volumeManager)
|
// Huawei violates the API docs and prevents you from accessing the new path
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q ->
|
// fields without first granting access to them through SAF. Fall back to DATA
|
||||||
Api29MediaStoreExtractor(context, volumeManager)
|
// instead.
|
||||||
else -> Api21MediaStoreExtractor(context, volumeManager)
|
Build.MANUFACTURER.equals("huawei", ignoreCase = true) ||
|
||||||
}
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ->
|
||||||
|
DataPathInterpreter.Factory(volumeManager)
|
||||||
|
else -> VolumePathInterpreter.Factory(volumeManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
val volumeInterpreter =
|
||||||
|
when {
|
||||||
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30TagInterpreter.Factory()
|
||||||
|
else -> Api21TagInterpreter.Factory()
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaStoreExtractorImpl(context, pathInterpreter, volumeInterpreter)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private abstract class BaseMediaStoreExtractor(protected val context: Context) :
|
private class MediaStoreExtractorImpl(
|
||||||
MediaStoreExtractor {
|
private val context: Context,
|
||||||
final override suspend fun query(
|
private val pathInterpreterFactory: PathInterpreterFactory,
|
||||||
|
private val tagInterpreterFactory: TagInterpreterFactory
|
||||||
|
) : MediaStoreExtractor {
|
||||||
|
override suspend fun query(
|
||||||
constraints: MediaStoreExtractor.Constraints
|
constraints: MediaStoreExtractor.Constraints
|
||||||
): MediaStoreExtractor.Query {
|
): MediaStoreExtractor.Query {
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
|
|
||||||
val args = mutableListOf<String>()
|
val projection =
|
||||||
var selector = BASE_SELECTOR
|
BASE_PROJECTION + pathInterpreterFactory.projection + tagInterpreterFactory.projection
|
||||||
|
var uniSelector = BASE_SELECTOR
|
||||||
|
var uniArgs = listOf<String>()
|
||||||
|
|
||||||
// Filter out audio that is not music, if enabled.
|
// Filter out audio that is not music, if enabled.
|
||||||
if (constraints.excludeNonMusic) {
|
if (constraints.excludeNonMusic) {
|
||||||
logD("Excluding non-music")
|
logD("Excluding non-music")
|
||||||
selector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
|
uniSelector += " AND ${MediaStore.Audio.AudioColumns.IS_MUSIC}=1"
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set up the projection to follow the music directory configuration.
|
// Set up the projection to follow the music directory configuration.
|
||||||
if (constraints.musicDirs.dirs.isNotEmpty()) {
|
if (constraints.musicDirs.dirs.isNotEmpty()) {
|
||||||
selector += " AND "
|
val pathSelector = pathInterpreterFactory.createSelector(constraints.musicDirs.dirs)
|
||||||
if (!constraints.musicDirs.shouldInclude) {
|
if (pathSelector != null) {
|
||||||
logD("Excluding directories in selector")
|
logD("Must select for directories")
|
||||||
// Without a NOT, the query will be restricted to the specified paths, resulting
|
uniSelector += " AND "
|
||||||
// in the "Include" mode. With a NOT, the specified paths will not be included,
|
if (!constraints.musicDirs.shouldInclude) {
|
||||||
// resulting in the "Exclude" mode.
|
logD("Excluding directories in selector")
|
||||||
selector += "NOT "
|
// Without a NOT, the query will be restricted to the specified paths, resulting
|
||||||
}
|
// in the "Include" mode. With a NOT, the specified paths will not be included,
|
||||||
selector += " ("
|
// resulting in the "Exclude" mode.
|
||||||
|
uniSelector += "NOT "
|
||||||
// Specifying the paths to filter is version-specific, delegate to the concrete
|
|
||||||
// implementations.
|
|
||||||
for (i in constraints.musicDirs.dirs.indices) {
|
|
||||||
if (addDirToSelector(constraints.musicDirs.dirs[i], args)) {
|
|
||||||
selector +=
|
|
||||||
if (i < constraints.musicDirs.dirs.lastIndex) {
|
|
||||||
"$dirSelectorTemplate OR "
|
|
||||||
} else {
|
|
||||||
dirSelectorTemplate
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
uniSelector += " (${pathSelector.template})"
|
||||||
|
uniArgs = pathSelector.args
|
||||||
}
|
}
|
||||||
|
|
||||||
selector += ')'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Now we can actually query MediaStore.
|
// Now we can actually query MediaStore.
|
||||||
logD("Starting song query [proj=${projection.toList()}, selector=$selector, args=$args]")
|
logD(
|
||||||
|
"Starting song query [proj=${projection.toList()}, selector=$uniSelector, args=$uniArgs]")
|
||||||
val cursor =
|
val cursor =
|
||||||
context.contentResolverSafe.safeQuery(
|
context.contentResolverSafe.safeQuery(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||||
projection,
|
projection,
|
||||||
selector,
|
uniSelector,
|
||||||
args.toTypedArray())
|
uniArgs.toTypedArray())
|
||||||
logD("Successfully queried for ${cursor.count} songs")
|
logD("Successfully queried for ${cursor.count} songs")
|
||||||
|
|
||||||
val genreNamesMap = mutableMapOf<Long, String>()
|
val genreNamesMap = mutableMapOf<Long, String>()
|
||||||
|
@ -195,10 +202,14 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) :
|
||||||
|
|
||||||
logD("Read ${genreNamesMap.values.distinct().size} genres from MediaStore")
|
logD("Read ${genreNamesMap.values.distinct().size} genres from MediaStore")
|
||||||
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
||||||
return wrapQuery(cursor, genreNamesMap)
|
return QueryImpl(
|
||||||
|
cursor,
|
||||||
|
pathInterpreterFactory.wrap(cursor),
|
||||||
|
tagInterpreterFactory.wrap(cursor),
|
||||||
|
genreNamesMap)
|
||||||
}
|
}
|
||||||
|
|
||||||
final override suspend fun consume(
|
override suspend fun consume(
|
||||||
query: MediaStoreExtractor.Query,
|
query: MediaStoreExtractor.Query,
|
||||||
cache: Cache?,
|
cache: Cache?,
|
||||||
incompleteSongs: Channel<RawSong>,
|
incompleteSongs: Channel<RawSong>,
|
||||||
|
@ -220,54 +231,10 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) :
|
||||||
query.close()
|
query.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class QueryImpl(
|
||||||
* The database columns available to all android versions supported by Auxio. Concrete
|
private val cursor: Cursor,
|
||||||
* implementations can extend this projection to add version-specific columns.
|
private val pathInterpreter: PathInterpreter,
|
||||||
*/
|
private val tagInterpreter: TagInterpreter,
|
||||||
protected open val projection: Array<String>
|
|
||||||
get() =
|
|
||||||
arrayOf(
|
|
||||||
// These columns are guaranteed to work on all versions of android
|
|
||||||
MediaStore.Audio.AudioColumns._ID,
|
|
||||||
MediaStore.Audio.AudioColumns.DATE_ADDED,
|
|
||||||
MediaStore.Audio.AudioColumns.DATE_MODIFIED,
|
|
||||||
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
|
||||||
MediaStore.Audio.AudioColumns.SIZE,
|
|
||||||
MediaStore.Audio.AudioColumns.DURATION,
|
|
||||||
MediaStore.Audio.AudioColumns.MIME_TYPE,
|
|
||||||
MediaStore.Audio.AudioColumns.TITLE,
|
|
||||||
MediaStore.Audio.AudioColumns.YEAR,
|
|
||||||
MediaStore.Audio.AudioColumns.ALBUM,
|
|
||||||
MediaStore.Audio.AudioColumns.ALBUM_ID,
|
|
||||||
MediaStore.Audio.AudioColumns.ARTIST,
|
|
||||||
AUDIO_COLUMN_ALBUM_ARTIST)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The companion template to add to the projection's selector whenever arguments are added by
|
|
||||||
* [addDirToSelector].
|
|
||||||
*
|
|
||||||
* @see addDirToSelector
|
|
||||||
*/
|
|
||||||
protected abstract val dirSelectorTemplate: String
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Add a [SystemPath] to the given list of projection selector arguments.
|
|
||||||
*
|
|
||||||
* @param path The [SystemPath] to add.
|
|
||||||
* @param args The destination list to append selector arguments to that are analogous to the
|
|
||||||
* given [SystemPath].
|
|
||||||
* @return true if the [SystemPath] was added, false otherwise.
|
|
||||||
* @see dirSelectorTemplate
|
|
||||||
*/
|
|
||||||
protected abstract fun addDirToSelector(path: Path, args: MutableList<String>): Boolean
|
|
||||||
|
|
||||||
protected abstract fun wrapQuery(
|
|
||||||
cursor: Cursor,
|
|
||||||
genreNamesMap: Map<Long, String>
|
|
||||||
): MediaStoreExtractor.Query
|
|
||||||
|
|
||||||
abstract class Query(
|
|
||||||
protected val cursor: Cursor,
|
|
||||||
private val genreNamesMap: Map<Long, String>
|
private val genreNamesMap: Map<Long, String>
|
||||||
) : MediaStoreExtractor.Query {
|
) : MediaStoreExtractor.Query {
|
||||||
private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
private val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
|
||||||
|
@ -290,11 +257,11 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) :
|
||||||
private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
|
private val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
|
||||||
private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
|
private val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
|
||||||
|
|
||||||
final override val projectedTotal = cursor.count
|
override val projectedTotal = cursor.count
|
||||||
|
|
||||||
final override fun moveToNext() = cursor.moveToNext()
|
override fun moveToNext() = cursor.moveToNext()
|
||||||
|
|
||||||
final override fun close() = cursor.close()
|
override fun close() = cursor.close()
|
||||||
|
|
||||||
override fun populateFileInfo(rawSong: RawSong) {
|
override fun populateFileInfo(rawSong: RawSong) {
|
||||||
rawSong.mediaStoreId = cursor.getLong(idIndex)
|
rawSong.mediaStoreId = cursor.getLong(idIndex)
|
||||||
|
@ -305,6 +272,7 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) :
|
||||||
rawSong.fileName = cursor.getStringOrNull(displayNameIndex)
|
rawSong.fileName = cursor.getStringOrNull(displayNameIndex)
|
||||||
rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
|
rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
|
||||||
rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
|
rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
|
||||||
|
pathInterpreter.populate(rawSong)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun populateTags(rawSong: RawSong) {
|
override fun populateTags(rawSong: RawSong) {
|
||||||
|
@ -335,16 +303,12 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) :
|
||||||
cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) }
|
cursor.getStringOrNull(albumArtistIndex)?.let { rawSong.albumArtistNames = listOf(it) }
|
||||||
// Get the genre value we had to query for in initialization
|
// Get the genre value we had to query for in initialization
|
||||||
genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) }
|
genreNamesMap[rawSong.mediaStoreId]?.let { rawSong.genreNames = listOf(it) }
|
||||||
|
// Get version/device-specific tags
|
||||||
|
tagInterpreter.populate(rawSong)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
|
||||||
* The base selector that works across all versions of android. Does not exclude
|
|
||||||
* directories.
|
|
||||||
*/
|
|
||||||
private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The album artist of a song. This column has existed since at least API 21, but until API
|
* The album artist of a song. This column has existed since at least API 21, but until API
|
||||||
* 30 it was an undocumented extension for Google Play Music. This column will work on all
|
* 30 it was an undocumented extension for Google Play Music. This column will work on all
|
||||||
|
@ -358,254 +322,244 @@ private abstract class BaseMediaStoreExtractor(protected val context: Context) :
|
||||||
* until API 29. This will work on all versions that Auxio supports.
|
* until API 29. This will work on all versions that Auxio supports.
|
||||||
*/
|
*/
|
||||||
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
|
@Suppress("InlinedApi") private const val VOLUME_EXTERNAL = MediaStore.VOLUME_EXTERNAL
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The base selector that works across all versions of android. Does not exclude
|
||||||
|
* directories.
|
||||||
|
*/
|
||||||
|
private const val BASE_SELECTOR = "NOT ${MediaStore.Audio.Media.SIZE}=0"
|
||||||
|
|
||||||
|
/** The base projection that works across all versions of android. */
|
||||||
|
private val BASE_PROJECTION =
|
||||||
|
arrayOf(
|
||||||
|
// These columns are guaranteed to work on all versions of android
|
||||||
|
MediaStore.Audio.AudioColumns._ID,
|
||||||
|
MediaStore.Audio.AudioColumns.DATE_ADDED,
|
||||||
|
MediaStore.Audio.AudioColumns.DATE_MODIFIED,
|
||||||
|
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.Audio.AudioColumns.SIZE,
|
||||||
|
MediaStore.Audio.AudioColumns.DURATION,
|
||||||
|
MediaStore.Audio.AudioColumns.MIME_TYPE,
|
||||||
|
MediaStore.Audio.AudioColumns.TITLE,
|
||||||
|
MediaStore.Audio.AudioColumns.YEAR,
|
||||||
|
MediaStore.Audio.AudioColumns.ALBUM,
|
||||||
|
MediaStore.Audio.AudioColumns.ALBUM_ID,
|
||||||
|
MediaStore.Audio.AudioColumns.ARTIST,
|
||||||
|
AUDIO_COLUMN_ALBUM_ARTIST)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: The separation between version-specific backends may not be the cleanest. To preserve
|
interface Interpreter {
|
||||||
// speed, we only want to add redundancy on known issues, not with possible issues.
|
fun populate(rawSong: RawSong)
|
||||||
|
}
|
||||||
|
|
||||||
private class Api21MediaStoreExtractor(context: Context, private val volumeManager: VolumeManager) :
|
interface InterpreterFactory {
|
||||||
BaseMediaStoreExtractor(context) {
|
val projection: Array<String>
|
||||||
override val projection: Array<String>
|
|
||||||
get() =
|
|
||||||
super.projection +
|
|
||||||
arrayOf(
|
|
||||||
MediaStore.Audio.AudioColumns.TRACK,
|
|
||||||
// Below API 29, we are restricted to the absolute path (Called DATA by
|
|
||||||
// MediaStore) when working with audio files.
|
|
||||||
MediaStore.Audio.AudioColumns.DATA)
|
|
||||||
|
|
||||||
// The selector should be configured to convert the given directories instances to their
|
fun wrap(cursor: Cursor): Interpreter
|
||||||
// absolute paths and then compare them to DATA.
|
}
|
||||||
|
|
||||||
override val dirSelectorTemplate: String
|
interface PathInterpreterFactory : InterpreterFactory {
|
||||||
get() = "${MediaStore.Audio.Media.DATA} LIKE ?"
|
override fun wrap(cursor: Cursor): PathInterpreter
|
||||||
|
|
||||||
override fun addDirToSelector(path: Path, args: MutableList<String>): Boolean {
|
fun createSelector(paths: List<Path>): Selector?
|
||||||
// "%" signifies to accept any DATA value that begins with the Directory's path,
|
|
||||||
// thus recursively filtering all files in the directory.
|
data class Selector(val template: String, val args: List<String>)
|
||||||
args.add("${path.volume.components ?: return false}${path.components}%")
|
}
|
||||||
return true
|
|
||||||
|
interface TagInterpreterFactory : InterpreterFactory {
|
||||||
|
override fun wrap(cursor: Cursor): TagInterpreter
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed interface PathInterpreter : Interpreter
|
||||||
|
|
||||||
|
class DataPathInterpreter(private val cursor: Cursor, private val volumeManager: VolumeManager) :
|
||||||
|
PathInterpreter {
|
||||||
|
private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
|
||||||
|
private val volumes = volumeManager.getVolumes()
|
||||||
|
|
||||||
|
override fun populate(rawSong: RawSong) {
|
||||||
|
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.components ?: continue).toString()
|
||||||
|
val strippedPath = rawPath.removePrefix(volumePath)
|
||||||
|
if (strippedPath != rawPath) {
|
||||||
|
rawSong.directory = Path(volume, Components.parseUnix(strippedPath))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun wrapQuery(
|
class Factory(private val volumeManager: VolumeManager) : PathInterpreterFactory {
|
||||||
cursor: Cursor,
|
override val projection: Array<String>
|
||||||
genreNamesMap: Map<Long, String>,
|
get() = arrayOf(MediaStore.Audio.AudioColumns.DATA)
|
||||||
): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager)
|
|
||||||
|
|
||||||
private class Query(
|
override fun createSelector(paths: List<Path>): PathInterpreterFactory.Selector? {
|
||||||
cursor: Cursor,
|
val args = mutableListOf<String>()
|
||||||
genreNamesMap: Map<Long, String>,
|
var template = ""
|
||||||
volumeManager: VolumeManager
|
for (i in paths.indices) {
|
||||||
) : BaseMediaStoreExtractor.Query(cursor, genreNamesMap) {
|
val path = paths[i]
|
||||||
// Set up cursor indices for later use.
|
val volume = path.volume.components ?: continue
|
||||||
private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
template +=
|
||||||
private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
|
if (i == 0) {
|
||||||
private val volumes = volumeManager.getVolumes()
|
"${MediaStore.Audio.AudioColumns.DATA} LIKE ?"
|
||||||
|
} else {
|
||||||
override fun populateFileInfo(rawSong: RawSong) {
|
" OR ${MediaStore.Audio.AudioColumns.DATA} LIKE ?"
|
||||||
super.populateFileInfo(rawSong)
|
}
|
||||||
|
args.add("${volume}${path.components}%")
|
||||||
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
|
if (template.isEmpty()) {
|
||||||
// the Directory we will use.
|
return null
|
||||||
val rawPath = data.substringBeforeLast(File.separatorChar)
|
|
||||||
for (volume in volumes) {
|
|
||||||
val volumePath = (volume.components ?: continue).toString()
|
|
||||||
val strippedPath = rawPath.removePrefix(volumePath)
|
|
||||||
if (strippedPath != rawPath) {
|
|
||||||
rawSong.directory = Path(volume, Components.parseUnix(strippedPath))
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return PathInterpreterFactory.Selector(template, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun populateTags(rawSong: RawSong) {
|
override fun wrap(cursor: Cursor): PathInterpreter =
|
||||||
super.populateTags(rawSong)
|
DataPathInterpreter(cursor, volumeManager)
|
||||||
// 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 }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class VolumePathInterpreter(private val cursor: Cursor, private val volumeManager: VolumeManager) :
|
||||||
* A [BaseMediaStoreExtractor] that implements common behavior supported from API 29 onwards.
|
PathInterpreter {
|
||||||
*
|
private val volumeIndex =
|
||||||
* @param context [Context] required to query the media database.
|
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
private val relativePathIndex =
|
||||||
*/
|
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
private val volumes = volumeManager.getVolumes()
|
||||||
private abstract class BaseApi29MediaStoreExtractor(context: Context) :
|
|
||||||
BaseMediaStoreExtractor(context) {
|
override fun populate(rawSong: RawSong) {
|
||||||
override val projection: Array<String>
|
// Find the StorageVolume whose MediaStore name corresponds to this song.
|
||||||
get() =
|
// This is combined with the plain relative path column to create the directory.
|
||||||
super.projection +
|
val volumeName = cursor.getString(volumeIndex)
|
||||||
|
val relativePath = cursor.getString(relativePathIndex)
|
||||||
|
val volume = volumes.find { it.mediaStoreName == volumeName }
|
||||||
|
if (volume != null) {
|
||||||
|
rawSong.directory = Path(volume, Components.parseUnix(relativePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class Factory(private val volumeManager: VolumeManager) : PathInterpreterFactory {
|
||||||
|
override val projection: Array<String>
|
||||||
|
get() =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
// After API 29, we now have access to the volume name and relative
|
// After API 29, we now have access to the volume name and relative
|
||||||
// path, which simplifies working with Paths significantly.
|
// path, which simplifies working with Paths significantly.
|
||||||
MediaStore.Audio.AudioColumns.VOLUME_NAME,
|
MediaStore.Audio.AudioColumns.VOLUME_NAME,
|
||||||
MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
||||||
|
|
||||||
// The selector should be configured to compare both the volume name and relative path
|
// The selector should be configured to compare both the volume name and relative path
|
||||||
// of the given directories, albeit with some conversion to the analogous MediaStore
|
// of the given directories, albeit with some conversion to the analogous MediaStore
|
||||||
// column values.
|
// column values.
|
||||||
|
|
||||||
override val dirSelectorTemplate: String
|
override fun createSelector(paths: List<Path>): PathInterpreterFactory.Selector? {
|
||||||
get() =
|
val args = mutableListOf<String>()
|
||||||
"(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
var template = ""
|
||||||
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
for (i in paths.indices) {
|
||||||
|
val path = paths[i]
|
||||||
override fun addDirToSelector(path: Path, args: MutableList<String>): Boolean {
|
template =
|
||||||
// MediaStore uses a different naming scheme for it's volume column convert this
|
if (i == 0) {
|
||||||
// directory's volume to it.
|
"(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
||||||
args.add(path.volume.mediaStoreName ?: return false)
|
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
||||||
// "%" signifies to accept any DATA value that begins with the Directory's path,
|
} else {
|
||||||
// thus recursively filtering all files in the directory.
|
" OR (${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
||||||
args.add("${path.components}%")
|
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
||||||
return true
|
}
|
||||||
}
|
// MediaStore uses a different naming scheme for it's volume column. Convert this
|
||||||
|
// directory's volume to it.
|
||||||
abstract class Query(
|
args.add(path.volume.mediaStoreName ?: return null)
|
||||||
cursor: Cursor,
|
// "%" signifies to accept any DATA value that begins with the Directory's path,
|
||||||
genreNamesMap: Map<Long, String>,
|
// thus recursively filtering all files in the directory.
|
||||||
private val volumeManager: VolumeManager
|
args.add("${path.components}%")
|
||||||
) : BaseMediaStoreExtractor.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 = volumeManager.getVolumes()
|
|
||||||
|
|
||||||
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.mediaStoreName == volumeName }
|
|
||||||
if (volume != null) {
|
|
||||||
rawSong.directory = Path(volume, Components.parseUnix(relativePath))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (template.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return PathInterpreterFactory.Selector(template, args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun wrap(cursor: Cursor): PathInterpreter =
|
||||||
|
VolumePathInterpreter(cursor, volumeManager)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
sealed interface TagInterpreter : Interpreter
|
||||||
* A [BaseMediaStoreExtractor] that completes the music loading process in a way compatible with at
|
|
||||||
* API 29.
|
|
||||||
*
|
|
||||||
* @param context [Context] required to query the media database.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
@RequiresApi(Build.VERSION_CODES.Q)
|
|
||||||
private class Api29MediaStoreExtractor(context: Context, private val volumeManager: VolumeManager) :
|
|
||||||
BaseApi29MediaStoreExtractor(context) {
|
|
||||||
|
|
||||||
override val projection: Array<String>
|
class Api21TagInterpreter(private val cursor: Cursor) : TagInterpreter {
|
||||||
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
|
private val trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||||
|
|
||||||
override fun wrapQuery(
|
override fun populate(rawSong: RawSong) {
|
||||||
cursor: Cursor,
|
// See unpackTrackNo/unpackDiscNo for an explanation
|
||||||
genreNamesMap: Map<Long, String>
|
// of how this column is set up.
|
||||||
): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager)
|
val rawTrack = cursor.getIntOrNull(trackIndex)
|
||||||
|
if (rawTrack != null) {
|
||||||
private class Query(
|
rawTrack.unpackTrackNo()?.let { rawSong.track = it }
|
||||||
cursor: Cursor,
|
rawTrack.unpackDiscNo()?.let { rawSong.disc = it }
|
||||||
genreNamesMap: Map<Long, String>,
|
|
||||||
volumeManager: VolumeManager
|
|
||||||
) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, volumeManager) {
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class Factory : TagInterpreterFactory {
|
||||||
|
override val projection: Array<String>
|
||||||
|
get() = arrayOf(MediaStore.Audio.AudioColumns.TRACK)
|
||||||
|
|
||||||
|
override fun wrap(cursor: Cursor): TagInterpreter = Api21TagInterpreter(cursor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
|
||||||
|
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
||||||
|
* disc number is the 4th+ digit.
|
||||||
|
*
|
||||||
|
* @return The track number extracted from the combined integer value, or null if the value was
|
||||||
|
* zero.
|
||||||
|
*/
|
||||||
|
private fun Int.unpackTrackNo() = transformPositionField(mod(1000), null)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
|
||||||
|
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
||||||
|
* disc number is the 4th+ digit.
|
||||||
|
*
|
||||||
|
* @return The disc number extracted from the combined integer field, or null if the value was zero.
|
||||||
|
*/
|
||||||
|
private fun Int.unpackDiscNo() = transformPositionField(div(1000), null)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
class Api30TagInterpreter(private val cursor: Cursor) : TagInterpreter {
|
||||||
* A [BaseMediaStoreExtractor] that completes the music loading process in a way compatible from API
|
private val trackIndex =
|
||||||
* 30 onwards.
|
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
|
||||||
*
|
private val discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
||||||
* @param context [Context] required to query the media database.
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
override fun populate(rawSong: RawSong) {
|
||||||
*/
|
// Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
|
||||||
@RequiresApi(Build.VERSION_CODES.R)
|
// the tag itself, which is to say that it is formatted as NN/TT tracks, where
|
||||||
private class Api30MediaStoreExtractor(context: Context, private val volumeManager: VolumeManager) :
|
// N is the number and T is the total. Parse the number while ignoring the
|
||||||
BaseApi29MediaStoreExtractor(context) {
|
// total, as we have no use for it.
|
||||||
override val projection: Array<String>
|
cursor.getStringOrNull(trackIndex)?.parseId3v2PositionField()?.let { rawSong.track = it }
|
||||||
get() =
|
cursor.getStringOrNull(discIndex)?.parseId3v2PositionField()?.let { rawSong.disc = it }
|
||||||
super.projection +
|
}
|
||||||
|
|
||||||
|
class Factory : TagInterpreterFactory {
|
||||||
|
override val projection: Array<String>
|
||||||
|
get() =
|
||||||
arrayOf(
|
arrayOf(
|
||||||
// API 30 grant us access to the superior CD_TRACK_NUMBER and DISC_NUMBER
|
|
||||||
// fields, which take the place of TRACK.
|
|
||||||
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
|
MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER,
|
||||||
MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
MediaStore.Audio.AudioColumns.DISC_NUMBER)
|
||||||
|
|
||||||
override fun wrapQuery(
|
override fun wrap(cursor: Cursor): TagInterpreter = Api30TagInterpreter(cursor)
|
||||||
cursor: Cursor,
|
|
||||||
genreNamesMap: Map<Long, String>
|
|
||||||
): MediaStoreExtractor.Query = Query(cursor, genreNamesMap, volumeManager)
|
|
||||||
|
|
||||||
private class Query(
|
|
||||||
cursor: Cursor,
|
|
||||||
genreNamesMap: Map<Long, String>,
|
|
||||||
volumeManager: VolumeManager
|
|
||||||
) : BaseApi29MediaStoreExtractor.Query(cursor, genreNamesMap, volumeManager) {
|
|
||||||
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 }
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Unpack the track number from a combined track + disc [Int] field. These fields appear within
|
|
||||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
|
||||||
* disc number is the 4th+ digit.
|
|
||||||
*
|
|
||||||
* @return The track number extracted from the combined integer value, or null if the value was
|
|
||||||
* zero.
|
|
||||||
*/
|
|
||||||
private fun Int.unpackTrackNo() = transformPositionField(mod(1000), null)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unpack the disc number from a combined track + disc [Int] field. These fields appear within
|
|
||||||
* MediaStore's TRACK column, and combine the track and disc value into a single field where the
|
|
||||||
* disc number is the 4th+ digit.
|
|
||||||
*
|
|
||||||
* @return The disc number extracted from the combined integer field, or null if the value was zero.
|
|
||||||
*/
|
|
||||||
private fun Int.unpackDiscNo() = transformPositionField(div(1000), null)
|
|
||||||
|
|
Loading…
Reference in a new issue