music: support paths in documents

Apprently these only report their IDs, frustratingly.
This commit is contained in:
Alexander Capehart 2024-01-01 21:37:45 -07:00
parent 32432b18b6
commit e500286b8b
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 301 additions and 222 deletions

View file

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

View file

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

View file

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

View file

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