music: support paths in documents
Apprently these only report their IDs, frustratingly.
This commit is contained in:
parent
32432b18b6
commit
e500286b8b
4 changed files with 301 additions and 222 deletions
|
@ -18,8 +18,11 @@
|
||||||
|
|
||||||
package org.oxycblt.auxio.music.fs
|
package org.oxycblt.auxio.music.fs
|
||||||
|
|
||||||
|
import android.content.ContentUris
|
||||||
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import android.provider.DocumentsContract
|
import android.provider.DocumentsContract
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
@ -62,16 +65,38 @@ interface DocumentPathFactory {
|
||||||
fun fromDocumentId(path: String): Path?
|
fun fromDocumentId(path: String): Path?
|
||||||
}
|
}
|
||||||
|
|
||||||
class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) :
|
class DocumentPathFactoryImpl
|
||||||
DocumentPathFactory {
|
@Inject
|
||||||
|
constructor(
|
||||||
|
@ApplicationContext private val context: Context,
|
||||||
|
private val volumeManager: VolumeManager,
|
||||||
|
private val mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory
|
||||||
|
) : DocumentPathFactory {
|
||||||
override fun unpackDocumentUri(uri: Uri): Path? {
|
override fun unpackDocumentUri(uri: Uri): Path? {
|
||||||
// Abuse the document contract and extract the encoded path from the URI.
|
val id = DocumentsContract.getDocumentId(uri)
|
||||||
// I've seen some implementations that just use getDocumentId. That no longer seems
|
val numericId = id.toLongOrNull()
|
||||||
// to work on Android 14 onwards. But spoofing our own document URI and then decoding
|
return if (numericId != null) {
|
||||||
// it does for some reason.
|
// The document URI is special and points to an entry only accessible via
|
||||||
val docUri = DocumentsContract.buildDocumentUri(uri.authority, uri.pathSegments[1])
|
// ContentResolver. In this case, we have to manually query MediaStore.
|
||||||
val docId = DocumentsContract.getDocumentId(docUri)
|
for (prefix in POSSIBLE_CONTENT_URI_PREFIXES) {
|
||||||
return fromDocumentId(docId)
|
val contentUri = ContentUris.withAppendedId(prefix, numericId)
|
||||||
|
|
||||||
|
val path =
|
||||||
|
context.contentResolverSafe.useQuery(
|
||||||
|
contentUri, mediaStorePathInterpreterFactory.projection) {
|
||||||
|
it.moveToFirst()
|
||||||
|
mediaStorePathInterpreterFactory.wrap(it).extract()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path != null) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
null
|
||||||
|
} else {
|
||||||
|
fromDocumentId(id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun unpackDocumentTreeUri(uri: Uri): Path? {
|
override fun unpackDocumentTreeUri(uri: Uri): Path? {
|
||||||
|
@ -113,5 +138,10 @@ class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: Vol
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val DOCUMENT_URI_PRIMARY_NAME = "primary"
|
const val DOCUMENT_URI_PRIMARY_NAME = "primary"
|
||||||
|
|
||||||
|
private val POSSIBLE_CONTENT_URI_PREFIXES =
|
||||||
|
arrayOf(
|
||||||
|
Uri.parse("content://downloads/public_downloads"),
|
||||||
|
Uri.parse("content://downloads/my_downloads"))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -37,8 +37,15 @@ class FsModule {
|
||||||
VolumeManagerImpl(context.getSystemServiceCompat(StorageManager::class))
|
VolumeManagerImpl(context.getSystemServiceCompat(StorageManager::class))
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) =
|
fun mediaStoreExtractor(
|
||||||
MediaStoreExtractor.from(context, volumeManager)
|
@ApplicationContext context: Context,
|
||||||
|
mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory
|
||||||
|
) = MediaStoreExtractor.from(context, mediaStorePathInterpreterFactory)
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun mediaStorePathInterpreterFactory(
|
||||||
|
volumeManager: VolumeManager
|
||||||
|
): MediaStorePathInterpreter.Factory = MediaStorePathInterpreter.Factory.from(volumeManager)
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
|
fun contentResolver(@ApplicationContext context: Context): ContentResolver =
|
||||||
|
|
|
@ -93,32 +93,24 @@ 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(
|
||||||
val pathInterpreter =
|
context: Context,
|
||||||
when {
|
pathInterpreterFactory: MediaStorePathInterpreter.Factory
|
||||||
// Huawei violates the API docs and prevents you from accessing the new path
|
): MediaStoreExtractor {
|
||||||
// fields without first granting access to them through SAF. Fall back to DATA
|
val tagInterpreterFactory =
|
||||||
// instead.
|
|
||||||
Build.MANUFACTURER.equals("huawei", ignoreCase = true) ||
|
|
||||||
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ->
|
|
||||||
DataPathInterpreter.Factory(volumeManager)
|
|
||||||
else -> VolumePathInterpreter.Factory(volumeManager)
|
|
||||||
}
|
|
||||||
|
|
||||||
val volumeInterpreter =
|
|
||||||
when {
|
when {
|
||||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30TagInterpreter.Factory()
|
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30TagInterpreter.Factory()
|
||||||
else -> Api21TagInterpreter.Factory()
|
else -> Api21TagInterpreter.Factory()
|
||||||
}
|
}
|
||||||
|
|
||||||
return MediaStoreExtractorImpl(context, pathInterpreter, volumeInterpreter)
|
return MediaStoreExtractorImpl(context, pathInterpreterFactory, tagInterpreterFactory)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private class MediaStoreExtractorImpl(
|
private class MediaStoreExtractorImpl(
|
||||||
private val context: Context,
|
private val context: Context,
|
||||||
private val pathInterpreterFactory: PathInterpreter.Factory,
|
private val mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory,
|
||||||
private val tagInterpreterFactory: TagInterpreter.Factory
|
private val tagInterpreterFactory: TagInterpreter.Factory
|
||||||
) : MediaStoreExtractor {
|
) : MediaStoreExtractor {
|
||||||
override suspend fun query(
|
override suspend fun query(
|
||||||
|
@ -127,7 +119,9 @@ private class MediaStoreExtractorImpl(
|
||||||
val start = System.currentTimeMillis()
|
val start = System.currentTimeMillis()
|
||||||
|
|
||||||
val projection =
|
val projection =
|
||||||
BASE_PROJECTION + pathInterpreterFactory.projection + tagInterpreterFactory.projection
|
BASE_PROJECTION +
|
||||||
|
mediaStorePathInterpreterFactory.projection +
|
||||||
|
tagInterpreterFactory.projection
|
||||||
var uniSelector = BASE_SELECTOR
|
var uniSelector = BASE_SELECTOR
|
||||||
var uniArgs = listOf<String>()
|
var uniArgs = listOf<String>()
|
||||||
|
|
||||||
|
@ -139,7 +133,8 @@ private class MediaStoreExtractorImpl(
|
||||||
|
|
||||||
// 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()) {
|
||||||
val pathSelector = pathInterpreterFactory.createSelector(constraints.musicDirs.dirs)
|
val pathSelector =
|
||||||
|
mediaStorePathInterpreterFactory.createSelector(constraints.musicDirs.dirs)
|
||||||
if (pathSelector != null) {
|
if (pathSelector != null) {
|
||||||
logD("Must select for directories")
|
logD("Must select for directories")
|
||||||
uniSelector += " AND "
|
uniSelector += " AND "
|
||||||
|
@ -203,7 +198,7 @@ private class MediaStoreExtractorImpl(
|
||||||
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
logD("Finished initialization in ${System.currentTimeMillis() - start}ms")
|
||||||
return QueryImpl(
|
return QueryImpl(
|
||||||
cursor,
|
cursor,
|
||||||
pathInterpreterFactory.wrap(cursor),
|
mediaStorePathInterpreterFactory.wrap(cursor),
|
||||||
tagInterpreterFactory.wrap(cursor),
|
tagInterpreterFactory.wrap(cursor),
|
||||||
genreNamesMap)
|
genreNamesMap)
|
||||||
}
|
}
|
||||||
|
@ -234,7 +229,7 @@ private class MediaStoreExtractorImpl(
|
||||||
|
|
||||||
class QueryImpl(
|
class QueryImpl(
|
||||||
private val cursor: Cursor,
|
private val cursor: Cursor,
|
||||||
private val pathInterpreter: PathInterpreter,
|
private val mediaStorePathInterpreter: MediaStorePathInterpreter,
|
||||||
private val tagInterpreter: TagInterpreter,
|
private val tagInterpreter: TagInterpreter,
|
||||||
private val genreNamesMap: Map<Long, String>
|
private val genreNamesMap: Map<Long, String>
|
||||||
) : MediaStoreExtractor.Query {
|
) : MediaStoreExtractor.Query {
|
||||||
|
@ -268,7 +263,8 @@ private class MediaStoreExtractorImpl(
|
||||||
rawSong.dateModified = cursor.getLong(dateModifiedIndex)
|
rawSong.dateModified = cursor.getLong(dateModifiedIndex)
|
||||||
rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
|
rawSong.extensionMimeType = cursor.getString(mimeTypeIndex)
|
||||||
rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
|
rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex)
|
||||||
return pathInterpreter.populate(rawSong)
|
rawSong.path = mediaStorePathInterpreter.extract() ?: return false
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun populateTags(rawSong: RawSong) {
|
override fun populateTags(rawSong: RawSong) {
|
||||||
|
@ -345,197 +341,6 @@ private class MediaStoreExtractorImpl(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper around a [Cursor] that interprets path information on a per-API/manufacturer basis.
|
|
||||||
*
|
|
||||||
* @author Alexander Capehart (OxygenCobalt)
|
|
||||||
*/
|
|
||||||
private sealed interface PathInterpreter {
|
|
||||||
/**
|
|
||||||
* Populate the [RawSong] with version-specific path information.
|
|
||||||
*
|
|
||||||
* @param rawSong The [RawSong] to populate.
|
|
||||||
* @return True if the path was successfully populated, false otherwise.
|
|
||||||
*/
|
|
||||||
fun populate(rawSong: RawSong): Boolean
|
|
||||||
|
|
||||||
interface Factory {
|
|
||||||
/** The columns that must be added to a query to support this interpreter. */
|
|
||||||
val projection: Array<String>
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrap a [Cursor] with this interpreter. This cursor should be the result of a query
|
|
||||||
* containing the columns specified by [projection].
|
|
||||||
*
|
|
||||||
* @param cursor The [Cursor] to wrap.
|
|
||||||
* @return A new [PathInterpreter] that will work best on the device's API level.
|
|
||||||
*/
|
|
||||||
fun wrap(cursor: Cursor): PathInterpreter
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a selector that will filter the given paths. By default this will filter *to* the
|
|
||||||
* given paths, to exclude them, use a NOT.
|
|
||||||
*
|
|
||||||
* @param paths The paths to filter for.
|
|
||||||
* @return A selector that will filter to the given paths, or null if a selector could not
|
|
||||||
* be created from the paths.
|
|
||||||
*/
|
|
||||||
fun createSelector(paths: List<Path>): Selector?
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A selector that will filter to the given paths.
|
|
||||||
*
|
|
||||||
* @param template The template to use for the selector.
|
|
||||||
* @param args The arguments to use for the selector.
|
|
||||||
* @see Factory.createSelector
|
|
||||||
*/
|
|
||||||
data class Selector(val template: String, val args: List<String>)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper around a [Cursor] that interprets the DATA column as a path. Create an instance with
|
|
||||||
* [Factory].
|
|
||||||
*/
|
|
||||||
private class DataPathInterpreter
|
|
||||||
private constructor(private val cursor: Cursor, volumeManager: VolumeManager) : PathInterpreter {
|
|
||||||
private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
|
|
||||||
private val volumes = volumeManager.getVolumes()
|
|
||||||
|
|
||||||
override fun populate(rawSong: RawSong): Boolean {
|
|
||||||
val data = Components.parseUnix(cursor.getString(dataIndex))
|
|
||||||
|
|
||||||
// Find the volume that transforms the DATA column into a relative path. This is
|
|
||||||
// the Directory we will use.
|
|
||||||
for (volume in volumes) {
|
|
||||||
val volumePath = volume.components ?: continue
|
|
||||||
if (volumePath.contains(data)) {
|
|
||||||
rawSong.path = Path(volume, data.depth(volumePath.components.size))
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory for [DataPathInterpreter].
|
|
||||||
*
|
|
||||||
* @param volumeManager The [VolumeManager] to use for volume information.
|
|
||||||
*/
|
|
||||||
class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory {
|
|
||||||
override val projection: Array<String>
|
|
||||||
get() =
|
|
||||||
arrayOf(
|
|
||||||
MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DATA)
|
|
||||||
|
|
||||||
override fun createSelector(paths: List<Path>): PathInterpreter.Factory.Selector? {
|
|
||||||
val args = mutableListOf<String>()
|
|
||||||
var template = ""
|
|
||||||
for (i in paths.indices) {
|
|
||||||
val path = paths[i]
|
|
||||||
val volume = path.volume.components ?: continue
|
|
||||||
template +=
|
|
||||||
if (i == 0) {
|
|
||||||
"${MediaStore.Audio.AudioColumns.DATA} LIKE ?"
|
|
||||||
} else {
|
|
||||||
" OR ${MediaStore.Audio.AudioColumns.DATA} LIKE ?"
|
|
||||||
}
|
|
||||||
args.add("${volume}${path.components}%")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template.isEmpty()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return PathInterpreter.Factory.Selector(template, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun wrap(cursor: Cursor): PathInterpreter =
|
|
||||||
DataPathInterpreter(cursor, volumeManager)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper around a [Cursor] that interprets the VOLUME_NAME, RELATIVE_PATH, and DISPLAY_NAME
|
|
||||||
* columns as a path. Create an instance with [Factory].
|
|
||||||
*/
|
|
||||||
private class VolumePathInterpreter
|
|
||||||
private constructor(private val cursor: Cursor, volumeManager: VolumeManager) : PathInterpreter {
|
|
||||||
private val displayNameIndex =
|
|
||||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
|
||||||
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()
|
|
||||||
|
|
||||||
override fun populate(rawSong: RawSong): Boolean {
|
|
||||||
// Find the StorageVolume whose MediaStore name corresponds to it.
|
|
||||||
val volumeName = cursor.getString(volumeIndex)
|
|
||||||
val volume = volumes.find { it.mediaStoreName == volumeName } ?: return false
|
|
||||||
// Relative path does not include file name, must use DISPLAY_NAME and add it
|
|
||||||
// in manually.
|
|
||||||
val relativePath = cursor.getString(relativePathIndex)
|
|
||||||
val displayName = cursor.getString(displayNameIndex)
|
|
||||||
val components = Components.parseUnix(relativePath).child(displayName)
|
|
||||||
rawSong.path = Path(volume, components)
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Factory for [VolumePathInterpreter].
|
|
||||||
*
|
|
||||||
* @param volumeManager The [VolumeManager] to use for volume information.
|
|
||||||
*/
|
|
||||||
class Factory(private val volumeManager: VolumeManager) : PathInterpreter.Factory {
|
|
||||||
override val projection: Array<String>
|
|
||||||
get() =
|
|
||||||
arrayOf(
|
|
||||||
// After API 29, we now have access to the volume name and relative
|
|
||||||
// path, which hopefully are more standard and less likely to break
|
|
||||||
// compared to DATA.
|
|
||||||
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
|
||||||
MediaStore.Audio.AudioColumns.VOLUME_NAME,
|
|
||||||
MediaStore.Audio.AudioColumns.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
|
|
||||||
// column values.
|
|
||||||
|
|
||||||
override fun createSelector(paths: List<Path>): PathInterpreter.Factory.Selector? {
|
|
||||||
val args = mutableListOf<String>()
|
|
||||||
var template = ""
|
|
||||||
for (i in paths.indices) {
|
|
||||||
val path = paths[i]
|
|
||||||
template =
|
|
||||||
if (i == 0) {
|
|
||||||
"(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
|
||||||
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
|
||||||
} else {
|
|
||||||
" OR (${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
|
||||||
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
|
||||||
}
|
|
||||||
// MediaStore uses a different naming scheme for it's volume column. Convert this
|
|
||||||
// directory's volume to it.
|
|
||||||
args.add(path.volume.mediaStoreName ?: return null)
|
|
||||||
// "%" signifies to accept any DATA value that begins with the Directory's path,
|
|
||||||
// thus recursively filtering all files in the directory.
|
|
||||||
args.add("${path.components}%")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (template.isEmpty()) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
return PathInterpreter.Factory.Selector(template, args)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun wrap(cursor: Cursor): PathInterpreter =
|
|
||||||
VolumePathInterpreter(cursor, volumeManager)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around a [Cursor] that interprets certain tags on a per-API basis.
|
* Wrapper around a [Cursor] that interprets certain tags on a per-API basis.
|
||||||
*
|
*
|
||||||
|
|
|
@ -0,0 +1,237 @@
|
||||||
|
/*
|
||||||
|
* Copyright (c) 2024 Auxio Project
|
||||||
|
* MediaStorePathInterpreter.kt is part of Auxio.
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
package org.oxycblt.auxio.music.fs
|
||||||
|
|
||||||
|
import android.database.Cursor
|
||||||
|
import android.os.Build
|
||||||
|
import android.provider.MediaStore
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around a [Cursor] that interprets path information on a per-API/manufacturer basis.
|
||||||
|
*
|
||||||
|
* @author Alexander Capehart (OxygenCobalt)
|
||||||
|
*/
|
||||||
|
sealed interface MediaStorePathInterpreter {
|
||||||
|
/**
|
||||||
|
* Extract a [Path] from the wrapped [Cursor]. This should be called after the cursor has been
|
||||||
|
* moved to the row that should be interpreted.
|
||||||
|
*
|
||||||
|
* @return The [Path] instance, or null if the path could not be extracted.
|
||||||
|
*/
|
||||||
|
fun extract(): Path?
|
||||||
|
|
||||||
|
interface Factory {
|
||||||
|
/** The columns that must be added to a query to support this interpreter. */
|
||||||
|
val projection: Array<String>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrap a [Cursor] with this interpreter. This cursor should be the result of a query
|
||||||
|
* containing the columns specified by [projection].
|
||||||
|
*
|
||||||
|
* @param cursor The [Cursor] to wrap.
|
||||||
|
* @return A new [MediaStorePathInterpreter] that will work best on the device's API level.
|
||||||
|
*/
|
||||||
|
fun wrap(cursor: Cursor): MediaStorePathInterpreter
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a selector that will filter the given paths. By default this will filter *to* the
|
||||||
|
* given paths, to exclude them, use a NOT.
|
||||||
|
*
|
||||||
|
* @param paths The paths to filter for.
|
||||||
|
* @return A selector that will filter to the given paths, or null if a selector could not
|
||||||
|
* be created from the paths.
|
||||||
|
*/
|
||||||
|
fun createSelector(paths: List<Path>): Selector?
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A selector that will filter to the given paths.
|
||||||
|
*
|
||||||
|
* @param template The template to use for the selector.
|
||||||
|
* @param args The arguments to use for the selector.
|
||||||
|
* @see Factory.createSelector
|
||||||
|
*/
|
||||||
|
data class Selector(val template: String, val args: List<String>)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/**
|
||||||
|
* Create a [MediaStorePathInterpreter.Factory] that will work best on the device's API
|
||||||
|
* level.
|
||||||
|
*
|
||||||
|
* @param volumeManager The [VolumeManager] to use for volume information.
|
||||||
|
*/
|
||||||
|
fun from(volumeManager: VolumeManager) =
|
||||||
|
when {
|
||||||
|
// Huawei violates the API docs and prevents you from accessing the new path
|
||||||
|
// fields without first granting access to them through SAF. Fall back to DATA
|
||||||
|
// instead.
|
||||||
|
Build.MANUFACTURER.equals("huawei", ignoreCase = true) ||
|
||||||
|
Build.VERSION.SDK_INT < Build.VERSION_CODES.Q ->
|
||||||
|
DataMediaStorePathInterpreter.Factory(volumeManager)
|
||||||
|
else -> VolumeMediaStorePathInterpreter.Factory(volumeManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around a [Cursor] that interprets the DATA column as a path. Create an instance with
|
||||||
|
* [Factory].
|
||||||
|
*/
|
||||||
|
private class DataMediaStorePathInterpreter
|
||||||
|
private constructor(private val cursor: Cursor, volumeManager: VolumeManager) :
|
||||||
|
MediaStorePathInterpreter {
|
||||||
|
private val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
|
||||||
|
private val volumes = volumeManager.getVolumes()
|
||||||
|
|
||||||
|
override fun extract(): Path? {
|
||||||
|
val data = Components.parseUnix(cursor.getString(dataIndex))
|
||||||
|
|
||||||
|
// Find the volume that transforms the DATA column into a relative path. This is
|
||||||
|
// the Directory we will use.
|
||||||
|
for (volume in volumes) {
|
||||||
|
val volumePath = volume.components ?: continue
|
||||||
|
if (volumePath.contains(data)) {
|
||||||
|
return Path(volume, data.depth(volumePath.components.size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for [DataMediaStorePathInterpreter].
|
||||||
|
*
|
||||||
|
* @param volumeManager The [VolumeManager] to use for volume information.
|
||||||
|
*/
|
||||||
|
class Factory(private val volumeManager: VolumeManager) : MediaStorePathInterpreter.Factory {
|
||||||
|
override val projection: Array<String>
|
||||||
|
get() =
|
||||||
|
arrayOf(
|
||||||
|
MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DATA)
|
||||||
|
|
||||||
|
override fun createSelector(
|
||||||
|
paths: List<Path>
|
||||||
|
): MediaStorePathInterpreter.Factory.Selector? {
|
||||||
|
val args = mutableListOf<String>()
|
||||||
|
var template = ""
|
||||||
|
for (i in paths.indices) {
|
||||||
|
val path = paths[i]
|
||||||
|
val volume = path.volume.components ?: continue
|
||||||
|
template +=
|
||||||
|
if (i == 0) {
|
||||||
|
"${MediaStore.Audio.AudioColumns.DATA} LIKE ?"
|
||||||
|
} else {
|
||||||
|
" OR ${MediaStore.Audio.AudioColumns.DATA} LIKE ?"
|
||||||
|
}
|
||||||
|
args.add("${volume}${path.components}%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaStorePathInterpreter.Factory.Selector(template, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun wrap(cursor: Cursor): MediaStorePathInterpreter =
|
||||||
|
DataMediaStorePathInterpreter(cursor, volumeManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around a [Cursor] that interprets the VOLUME_NAME, RELATIVE_PATH, and DISPLAY_NAME
|
||||||
|
* columns as a path. Create an instance with [Factory].
|
||||||
|
*/
|
||||||
|
private class VolumeMediaStorePathInterpreter
|
||||||
|
private constructor(private val cursor: Cursor, volumeManager: VolumeManager) :
|
||||||
|
MediaStorePathInterpreter {
|
||||||
|
private val displayNameIndex =
|
||||||
|
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
||||||
|
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()
|
||||||
|
|
||||||
|
override fun extract(): Path? {
|
||||||
|
// Find the StorageVolume whose MediaStore name corresponds to it.
|
||||||
|
val volumeName = cursor.getString(volumeIndex)
|
||||||
|
val volume = volumes.find { it.mediaStoreName == volumeName } ?: return null
|
||||||
|
// Relative path does not include file name, must use DISPLAY_NAME and add it
|
||||||
|
// in manually.
|
||||||
|
val relativePath = cursor.getString(relativePathIndex)
|
||||||
|
val displayName = cursor.getString(displayNameIndex)
|
||||||
|
val components = Components.parseUnix(relativePath).child(displayName)
|
||||||
|
return Path(volume, components)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Factory for [VolumeMediaStorePathInterpreter].
|
||||||
|
*
|
||||||
|
* @param volumeManager The [VolumeManager] to use for volume information.
|
||||||
|
*/
|
||||||
|
class Factory(private val volumeManager: VolumeManager) : MediaStorePathInterpreter.Factory {
|
||||||
|
override val projection: Array<String>
|
||||||
|
get() =
|
||||||
|
arrayOf(
|
||||||
|
// After API 29, we now have access to the volume name and relative
|
||||||
|
// path, which hopefully are more standard and less likely to break
|
||||||
|
// compared to DATA.
|
||||||
|
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
||||||
|
MediaStore.Audio.AudioColumns.VOLUME_NAME,
|
||||||
|
MediaStore.Audio.AudioColumns.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
|
||||||
|
// column values.
|
||||||
|
|
||||||
|
override fun createSelector(
|
||||||
|
paths: List<Path>
|
||||||
|
): MediaStorePathInterpreter.Factory.Selector? {
|
||||||
|
val args = mutableListOf<String>()
|
||||||
|
var template = ""
|
||||||
|
for (i in paths.indices) {
|
||||||
|
val path = paths[i]
|
||||||
|
template =
|
||||||
|
if (i == 0) {
|
||||||
|
"(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
||||||
|
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
||||||
|
} else {
|
||||||
|
" OR (${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " +
|
||||||
|
"AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)"
|
||||||
|
}
|
||||||
|
// MediaStore uses a different naming scheme for it's volume column. Convert this
|
||||||
|
// directory's volume to it.
|
||||||
|
args.add(path.volume.mediaStoreName ?: return null)
|
||||||
|
// "%" signifies to accept any DATA value that begins with the Directory's path,
|
||||||
|
// thus recursively filtering all files in the directory.
|
||||||
|
args.add("${path.components}%")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (template.isEmpty()) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaStorePathInterpreter.Factory.Selector(template, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun wrap(cursor: Cursor): MediaStorePathInterpreter =
|
||||||
|
VolumeMediaStorePathInterpreter(cursor, volumeManager)
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in a new issue