diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt index 5a9ebcfeb..eb977a1fe 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DocumentPathFactory.kt @@ -18,8 +18,11 @@ package org.oxycblt.auxio.music.fs +import android.content.ContentUris +import android.content.Context import android.net.Uri import android.provider.DocumentsContract +import dagger.hilt.android.qualifiers.ApplicationContext import java.io.File import javax.inject.Inject @@ -62,16 +65,38 @@ interface DocumentPathFactory { fun fromDocumentId(path: String): Path? } -class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: VolumeManager) : - DocumentPathFactory { +class DocumentPathFactoryImpl +@Inject +constructor( + @ApplicationContext private val context: Context, + private val volumeManager: VolumeManager, + private val mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory +) : DocumentPathFactory { override fun unpackDocumentUri(uri: Uri): Path? { - // Abuse the document contract and extract the encoded path from the URI. - // I've seen some implementations that just use getDocumentId. That no longer seems - // to work on Android 14 onwards. But spoofing our own document URI and then decoding - // it does for some reason. - val docUri = DocumentsContract.buildDocumentUri(uri.authority, uri.pathSegments[1]) - val docId = DocumentsContract.getDocumentId(docUri) - return fromDocumentId(docId) + val id = DocumentsContract.getDocumentId(uri) + val numericId = id.toLongOrNull() + return if (numericId != null) { + // The document URI is special and points to an entry only accessible via + // ContentResolver. In this case, we have to manually query MediaStore. + for (prefix in POSSIBLE_CONTENT_URI_PREFIXES) { + 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? { @@ -113,5 +138,10 @@ class DocumentPathFactoryImpl @Inject constructor(private val volumeManager: Vol private companion object { 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")) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt index 072164323..46a200562 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/FsModule.kt @@ -37,8 +37,15 @@ class FsModule { VolumeManagerImpl(context.getSystemServiceCompat(StorageManager::class)) @Provides - fun mediaStoreExtractor(@ApplicationContext context: Context, volumeManager: VolumeManager) = - MediaStoreExtractor.from(context, volumeManager) + fun mediaStoreExtractor( + @ApplicationContext context: Context, + mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory + ) = MediaStoreExtractor.from(context, mediaStorePathInterpreterFactory) + + @Provides + fun mediaStorePathInterpreterFactory( + volumeManager: VolumeManager + ): MediaStorePathInterpreter.Factory = MediaStorePathInterpreter.Factory.from(volumeManager) @Provides fun contentResolver(@ApplicationContext context: Context): ContentResolver = diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt index 618ad9b2a..90b5e2051 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStoreExtractor.kt @@ -93,32 +93,24 @@ interface MediaStoreExtractor { * @param volumeManager [VolumeManager] required. * @return A new [MediaStoreExtractor] that will work best on the device's API level. */ - fun from(context: Context, volumeManager: VolumeManager): MediaStoreExtractor { - val pathInterpreter = - 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 -> - DataPathInterpreter.Factory(volumeManager) - else -> VolumePathInterpreter.Factory(volumeManager) - } - - val volumeInterpreter = + fun from( + context: Context, + pathInterpreterFactory: MediaStorePathInterpreter.Factory + ): MediaStoreExtractor { + val tagInterpreterFactory = when { Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30TagInterpreter.Factory() else -> Api21TagInterpreter.Factory() } - return MediaStoreExtractorImpl(context, pathInterpreter, volumeInterpreter) + return MediaStoreExtractorImpl(context, pathInterpreterFactory, tagInterpreterFactory) } } } private class MediaStoreExtractorImpl( private val context: Context, - private val pathInterpreterFactory: PathInterpreter.Factory, + private val mediaStorePathInterpreterFactory: MediaStorePathInterpreter.Factory, private val tagInterpreterFactory: TagInterpreter.Factory ) : MediaStoreExtractor { override suspend fun query( @@ -127,7 +119,9 @@ private class MediaStoreExtractorImpl( val start = System.currentTimeMillis() val projection = - BASE_PROJECTION + pathInterpreterFactory.projection + tagInterpreterFactory.projection + BASE_PROJECTION + + mediaStorePathInterpreterFactory.projection + + tagInterpreterFactory.projection var uniSelector = BASE_SELECTOR var uniArgs = listOf() @@ -139,7 +133,8 @@ private class MediaStoreExtractorImpl( // Set up the projection to follow the music directory configuration. if (constraints.musicDirs.dirs.isNotEmpty()) { - val pathSelector = pathInterpreterFactory.createSelector(constraints.musicDirs.dirs) + val pathSelector = + mediaStorePathInterpreterFactory.createSelector(constraints.musicDirs.dirs) if (pathSelector != null) { logD("Must select for directories") uniSelector += " AND " @@ -203,7 +198,7 @@ private class MediaStoreExtractorImpl( logD("Finished initialization in ${System.currentTimeMillis() - start}ms") return QueryImpl( cursor, - pathInterpreterFactory.wrap(cursor), + mediaStorePathInterpreterFactory.wrap(cursor), tagInterpreterFactory.wrap(cursor), genreNamesMap) } @@ -234,7 +229,7 @@ private class MediaStoreExtractorImpl( class QueryImpl( private val cursor: Cursor, - private val pathInterpreter: PathInterpreter, + private val mediaStorePathInterpreter: MediaStorePathInterpreter, private val tagInterpreter: TagInterpreter, private val genreNamesMap: Map ) : MediaStoreExtractor.Query { @@ -268,7 +263,8 @@ private class MediaStoreExtractorImpl( rawSong.dateModified = cursor.getLong(dateModifiedIndex) rawSong.extensionMimeType = cursor.getString(mimeTypeIndex) rawSong.albumMediaStoreId = cursor.getLong(albumIdIndex) - return pathInterpreter.populate(rawSong) + rawSong.path = mediaStorePathInterpreter.extract() ?: return false + return true } 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 - - /** - * 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): 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) - } -} - -/** - * 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 - get() = - arrayOf( - MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DATA) - - override fun createSelector(paths: List): PathInterpreter.Factory.Selector? { - val args = mutableListOf() - 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 - 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): PathInterpreter.Factory.Selector? { - val args = mutableListOf() - 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. * diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStorePathInterpreter.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStorePathInterpreter.kt new file mode 100644 index 000000000..1f821aaf4 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/MediaStorePathInterpreter.kt @@ -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 . + */ + +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 + + /** + * 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): 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) + + 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 + get() = + arrayOf( + MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DATA) + + override fun createSelector( + paths: List + ): MediaStorePathInterpreter.Factory.Selector? { + val args = mutableListOf() + 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 + 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 + ): MediaStorePathInterpreter.Factory.Selector? { + val args = mutableListOf() + 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) + } +}