musikr: break apart storageutil

This commit is contained in:
Alexander Capehart 2024-12-09 07:17:40 -07:00
parent ced2adb2c6
commit efceefc221
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
9 changed files with 206 additions and 246 deletions

View file

@ -27,7 +27,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.auxio.music.MusicRepository import org.oxycblt.auxio.music.MusicRepository
import org.oxycblt.auxio.music.MusicSettings import org.oxycblt.auxio.music.MusicSettings
import org.oxycblt.musikr.fs.contentResolverSafe import org.oxycblt.musikr.fs.util.contentResolverSafe
import timber.log.Timber as L import timber.log.Timber as L
/** /**

View file

@ -31,6 +31,8 @@ import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flatMapMerge
import kotlinx.coroutines.flow.flattenMerge import kotlinx.coroutines.flow.flattenMerge
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import org.oxycblt.musikr.fs.util.contentResolverSafe
import org.oxycblt.musikr.fs.util.useQuery
interface DeviceFiles { interface DeviceFiles {
fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile> fun explore(locations: Flow<MusicLocation>): Flow<DeviceFile>

View file

@ -26,6 +26,7 @@ import dagger.Provides
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import org.oxycblt.musikr.fs.util.contentResolverSafe
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)

View file

@ -25,6 +25,7 @@ import android.provider.DocumentsContract
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import javax.inject.Inject import javax.inject.Inject
import org.oxycblt.musikr.fs.path.DocumentPathFactory import org.oxycblt.musikr.fs.path.DocumentPathFactory
import org.oxycblt.musikr.fs.util.contentResolverSafe
class MusicLocation internal constructor(val uri: Uri, val path: Path) { class MusicLocation internal constructor(val uri: Uri, val path: Path) {
override fun equals(other: Any?) = override fun equals(other: Any?) =

View file

@ -1,242 +0,0 @@
/*
* Copyright (c) 2022 Auxio Project
* StorageUtil.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.musikr.fs
import android.annotation.SuppressLint
import android.content.ContentResolver
import android.content.ContentUris
import android.content.Context
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.provider.MediaStore
import java.lang.reflect.Method
import org.oxycblt.auxio.util.lazyReflectedMethod
// --- MEDIASTORE UTILITIES ---
/**
* Get a content resolver that will not mangle MediaStore queries on certain devices. See
* https://github.com/OxygenCobalt/Auxio/issues/50 for more info.
*/
val Context.contentResolverSafe: ContentResolver
get() = applicationContext.contentResolver
/**
* A shortcut for querying the [ContentResolver] database.
*
* @param uri The [Uri] of content to retrieve.
* @param projection A list of SQL columns to query from the database.
* @param selector A SQL selection statement to filter results. Spaces where arguments should be
* filled in are represented with a "?".
* @param args The arguments used for the selector.
* @return A [Cursor] of the queried values, organized by the column projection.
* @throws IllegalStateException If the [ContentResolver] did not return the queried [Cursor].
* @see ContentResolver.query
*/
fun ContentResolver.safeQuery(
uri: Uri,
projection: Array<out String>,
selector: String? = null,
args: Array<String>? = null
) = requireNotNull(query(uri, projection, selector, args, null)) { "ContentResolver query failed" }
/**
* A shortcut for [safeQuery] with [use] applied, automatically cleaning up the [Cursor]'s resources
* when no longer used.
*
* @param uri The [Uri] of content to retrieve.
* @param projection A list of SQL columns to query from the database.
* @param selector A SQL selection statement to filter results. Spaces where arguments should be
* filled in are represented with a "?".
* @param args The arguments used for the selector.
* @param block The block of code to run with the queried [Cursor]. Will not be ran if the [Cursor]
* is empty.
* @throws IllegalStateException If the [ContentResolver] did not return the queried [Cursor].
* @see ContentResolver.query
*/
inline fun <reified R> ContentResolver.useQuery(
uri: Uri,
projection: Array<out String>,
selector: String? = null,
args: Array<String>? = null,
block: (Cursor) -> R
) = safeQuery(uri, projection, selector, args).use(block)
inline fun <reified R> ContentResolver.mapQuery(
uri: Uri,
projection: Array<out String>,
selector: String? = null,
args: Array<String>? = null,
crossinline transform: Cursor.() -> R
) =
useQuery(uri, projection, selector, args) {
sequence<R> {
while (it.moveToNext()) {
yield(it.transform())
}
}
}
/** Album art [MediaStore] database is not a built-in constant, have to define it ourselves. */
private val externalCoversUri = Uri.parse("content://media/external/audio/albumart")
/**
* Convert a [MediaStore] Song ID into a [Uri] to it's audio file.
*
* @return An external storage audio file [Uri]. May not exist.
* @see ContentUris.withAppendedId
* @see MediaStore.Audio.Media.EXTERNAL_CONTENT_URI
*/
fun Long.toAudioUri() =
ContentUris.withAppendedId(MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, this)
/**
* Convert a [MediaStore] Album ID into a [Uri] to it's system-provided album cover. This cover will
* be fast to load, but will be lower quality.
*
* @return An external storage image [Uri]. May not exist.
* @see ContentUris.withAppendedId
*/
fun Long.toSongCoverUri(): Uri =
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI.buildUpon().run {
appendPath(this@toSongCoverUri.toString())
appendPath("albumart")
build()
}
fun Long.toAlbumCoverUri(): Uri = ContentUris.withAppendedId(externalCoversUri, this)
// --- STORAGEMANAGER UTILITIES ---
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles
/**
* Provides the analogous method to [StorageVolume.getDirectory] method that is usable from API 21
* to API 23, in which the [StorageVolume] API was hidden and differed greatly.
*
* @see StorageVolume.getDirectory
*/
@Suppress("NewApi")
private val svApi21GetPathMethod: Method by lazyReflectedMethod(StorageVolume::class, "getPath")
/**
* The list of [StorageVolume]s currently recognized by [StorageManager], in a version-compatible
* manner.
*
* @see StorageManager.getStorageVolumes
*/
val StorageManager.storageVolumesCompat: List<StorageVolume>
get() = storageVolumes.toList()
/**
* The the absolute path to this [StorageVolume]'s directory within the file-system, in a
* version-compatible manner. Will be null if the [StorageVolume] cannot be read.
*
* @see StorageVolume.getDirectory
*/
val StorageVolume.directoryCompat: String?
@SuppressLint("NewApi")
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
directory?.absolutePath
} else {
// Replicate API: Analogous method if mounted, null if not
when (stateCompat) {
Environment.MEDIA_MOUNTED,
Environment.MEDIA_MOUNTED_READ_ONLY -> svApi21GetPathMethod.invoke(this) as String
else -> null
}
}
/**
* Get the human-readable description of this volume, such as "Internal Shared Storage".
*
* @param context [Context] required to obtain human-readable string resources.
* @return A human-readable name for this volume.
*/
@SuppressLint("NewApi")
fun StorageVolume.getDescriptionCompat(context: Context): String = getDescription(context)
/**
* If this [StorageVolume] is considered the "Primary" volume where the Android System is kept. May
* still be a removable volume.
*
* @see StorageVolume.isPrimary
*/
val StorageVolume.isPrimaryCompat: Boolean
@SuppressLint("NewApi") get() = isPrimary
/**
* If this storage is "emulated", i.e intrinsic to the device, obtained in a version compatible
* manner.
*
* @see StorageVolume.isEmulated
*/
val StorageVolume.isEmulatedCompat: Boolean
@SuppressLint("NewApi") get() = isEmulated
/**
* If this [StorageVolume] represents the "Internal Shared Storage" volume, also known as "primary"
* to [MediaStore] and Document [Uri]s, obtained in a version compatible manner.
*/
val StorageVolume.isInternalCompat: Boolean
// Must contain the android system AND be an emulated drive, as non-emulated system
// volumes use their UUID instead of primary in MediaStore/Document URIs.
get() = isPrimaryCompat && isEmulatedCompat
/**
* The unique identifier for this [StorageVolume], obtained in a version compatible manner. Can be
* null.
*
* @see StorageVolume.getUuid
*/
val StorageVolume.uuidCompat: String?
@SuppressLint("NewApi") get() = uuid
/**
* The current state of this [StorageVolume], such as "mounted" or "read-only", obtained in a
* version compatible manner.
*
* @see StorageVolume.getState
*/
val StorageVolume.stateCompat: String
@SuppressLint("NewApi") get() = state
/**
* Returns the name of this volume that can be used to interact with [MediaStore], in a version
* compatible manner. Will be null if the volume is not scanned by [MediaStore].
*
* @see StorageVolume.getMediaStoreVolumeName
*/
val StorageVolume.mediaStoreVolumeNameCompat: String?
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
mediaStoreVolumeName
} else {
// Replicate API: primary_external if primary storage, lowercase uuid otherwise
if (isPrimaryCompat) {
// "primary_external" is used in all versions that Auxio supports, is safe to use.
@Suppress("NewApi") MediaStore.VOLUME_EXTERNAL_PRIMARY
} else {
uuidCompat?.lowercase()
}
}

View file

@ -28,8 +28,8 @@ import javax.inject.Inject
import org.oxycblt.musikr.fs.Components import org.oxycblt.musikr.fs.Components
import org.oxycblt.musikr.fs.Path import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.fs.Volume import org.oxycblt.musikr.fs.Volume
import org.oxycblt.musikr.fs.contentResolverSafe import org.oxycblt.musikr.fs.util.contentResolverSafe
import org.oxycblt.musikr.fs.useQuery import org.oxycblt.musikr.fs.util.useQuery
/** /**
* A factory for parsing the reverse-engineered format of the URIs obtained from document picker. * A factory for parsing the reverse-engineered format of the URIs obtained from document picker.

View file

@ -0,0 +1,126 @@
package org.oxycblt.musikr.fs.path
import android.annotation.SuppressLint
import android.content.Context
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager
import android.os.storage.StorageVolume
import android.provider.MediaStore
import org.oxycblt.auxio.util.lazyReflectedMethod
import java.lang.reflect.Method
// Largely derived from Material Files: https://github.com/zhanghai/MaterialFiles
/**
* Provides the analogous method to [StorageVolume.getDirectory] method that is usable from API 21
* to API 23, in which the [StorageVolume] API was hidden and differed greatly.
*
* @see StorageVolume.getDirectory
*/
@Suppress("NewApi")
private val svApi21GetPathMethod: Method by lazyReflectedMethod(StorageVolume::class, "getPath")
/**
* The list of [StorageVolume]s currently recognized by [StorageManager], in a version-compatible
* manner.
*
* @see StorageManager.getStorageVolumes
*/
val StorageManager.storageVolumesCompat: List<StorageVolume>
get() = storageVolumes.toList()
/**
* The the absolute path to this [StorageVolume]'s directory within the file-system, in a
* version-compatible manner. Will be null if the [StorageVolume] cannot be read.
*
* @see StorageVolume.getDirectory
*/
val StorageVolume.directoryCompat: String?
@SuppressLint("NewApi")
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
directory?.absolutePath
} else {
// Replicate API: Analogous method if mounted, null if not
when (stateCompat) {
Environment.MEDIA_MOUNTED,
Environment.MEDIA_MOUNTED_READ_ONLY -> svApi21GetPathMethod.invoke(this) as String
else -> null
}
}
/**
* Get the human-readable description of this volume, such as "Internal Shared Storage".
*
* @param context [Context] required to obtain human-readable string resources.
* @return A human-readable name for this volume.
*/
@SuppressLint("NewApi")
fun StorageVolume.getDescriptionCompat(context: Context): String = getDescription(context)
/**
* If this [StorageVolume] is considered the "Primary" volume where the Android System is kept. May
* still be a removable volume.
*
* @see StorageVolume.isPrimary
*/
val StorageVolume.isPrimaryCompat: Boolean
@SuppressLint("NewApi") get() = isPrimary
/**
* If this storage is "emulated", i.e intrinsic to the device, obtained in a version compatible
* manner.
*
* @see StorageVolume.isEmulated
*/
val StorageVolume.isEmulatedCompat: Boolean
@SuppressLint("NewApi") get() = isEmulated
/**
* If this [StorageVolume] represents the "Internal Shared Storage" volume, also known as "primary"
* to [MediaStore] and Document [Uri]s, obtained in a version compatible manner.
*/
val StorageVolume.isInternalCompat: Boolean
// Must contain the android system AND be an emulated drive, as non-emulated system
// volumes use their UUID instead of primary in MediaStore/Document URIs.
get() = isPrimaryCompat && isEmulatedCompat
/**
* The unique identifier for this [StorageVolume], obtained in a version compatible manner. Can be
* null.
*
* @see StorageVolume.getUuid
*/
val StorageVolume.uuidCompat: String?
@SuppressLint("NewApi") get() = uuid
/**
* The current state of this [StorageVolume], such as "mounted" or "read-only", obtained in a
* version compatible manner.
*
* @see StorageVolume.getState
*/
val StorageVolume.stateCompat: String
@SuppressLint("NewApi") get() = state
/**
* Returns the name of this volume that can be used to interact with [MediaStore], in a version
* compatible manner. Will be null if the volume is not scanned by [MediaStore].
*
* @see StorageVolume.getMediaStoreVolumeName
*/
val StorageVolume.mediaStoreVolumeNameCompat: String?
get() =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
mediaStoreVolumeName
} else {
// Replicate API: primary_external if primary storage, lowercase uuid otherwise
if (isPrimaryCompat) {
// "primary_external" is used in all versions that Auxio supports, is safe to use.
@Suppress("NewApi") MediaStore.VOLUME_EXTERNAL_PRIMARY
} else {
uuidCompat?.lowercase()
}
}

View file

@ -0,0 +1,72 @@
/*
* Copyright (c) 2022 Auxio Project
* QueryUtil.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.musikr.fs.util
import android.content.ContentResolver
import android.content.Context
import android.database.Cursor
import android.net.Uri
/**
* Get a content resolver that will not mangle MediaStore queries on certain devices. See
* https://github.com/OxygenCobalt/Auxio/issues/50 for more info.
*/
val Context.contentResolverSafe: ContentResolver
get() = applicationContext.contentResolver
/**
* A shortcut for querying the [ContentResolver] database.
*
* @param uri The [Uri] of content to retrieve.
* @param projection A list of SQL columns to query from the database.
* @param selector A SQL selection statement to filter results. Spaces where arguments should be
* filled in are represented with a "?".
* @param args The arguments used for the selector.
* @return A [Cursor] of the queried values, organized by the column projection.
* @throws IllegalStateException If the [ContentResolver] did not return the queried [Cursor].
* @see ContentResolver.query
*/
fun ContentResolver.safeQuery(
uri: Uri,
projection: Array<out String>,
selector: String? = null,
args: Array<String>? = null
) = requireNotNull(query(uri, projection, selector, args, null)) { "ContentResolver query failed" }
/**
* A shortcut for [safeQuery] with [use] applied, automatically cleaning up the [Cursor]'s resources
* when no longer used.
*
* @param uri The [Uri] of content to retrieve.
* @param projection A list of SQL columns to query from the database.
* @param selector A SQL selection statement to filter results. Spaces where arguments should be
* filled in are represented with a "?".
* @param args The arguments used for the selector.
* @param block The block of code to run with the queried [Cursor]. Will not be ran if the [Cursor]
* is empty.
* @throws IllegalStateException If the [ContentResolver] did not return the queried [Cursor].
* @see ContentResolver.query
*/
inline fun <reified R> ContentResolver.useQuery(
uri: Uri,
projection: Array<out String>,
selector: String? = null,
args: Array<String>? = null,
block: (Cursor) -> R
) = safeQuery(uri, projection, selector, args).use(block)

View file

@ -25,7 +25,7 @@ import javax.inject.Inject
import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.Playlist
import org.oxycblt.musikr.fs.Components import org.oxycblt.musikr.fs.Components
import org.oxycblt.musikr.fs.Path import org.oxycblt.musikr.fs.Path
import org.oxycblt.musikr.fs.contentResolverSafe import org.oxycblt.musikr.fs.util.contentResolverSafe
import org.oxycblt.musikr.fs.path.DocumentPathFactory import org.oxycblt.musikr.fs.path.DocumentPathFactory
import org.oxycblt.musikr.playlist.m3u.M3U import org.oxycblt.musikr.playlist.m3u.M3U
import timber.log.Timber as L import timber.log.Timber as L