diff --git a/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt b/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt index 4fc9f1f9b..426172cb3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/service/SystemContentObserver.kt @@ -27,7 +27,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.auxio.music.MusicRepository 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 /** diff --git a/app/src/main/java/org/oxycblt/musikr/fs/DeviceFiles.kt b/app/src/main/java/org/oxycblt/musikr/fs/DeviceFiles.kt index 076e34f74..024e55281 100644 --- a/app/src/main/java/org/oxycblt/musikr/fs/DeviceFiles.kt +++ b/app/src/main/java/org/oxycblt/musikr/fs/DeviceFiles.kt @@ -31,6 +31,8 @@ import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.flattenMerge import kotlinx.coroutines.flow.flow +import org.oxycblt.musikr.fs.util.contentResolverSafe +import org.oxycblt.musikr.fs.util.useQuery interface DeviceFiles { fun explore(locations: Flow): Flow diff --git a/app/src/main/java/org/oxycblt/musikr/fs/FsModule.kt b/app/src/main/java/org/oxycblt/musikr/fs/FsModule.kt index 55da22555..74b4bc55b 100644 --- a/app/src/main/java/org/oxycblt/musikr/fs/FsModule.kt +++ b/app/src/main/java/org/oxycblt/musikr/fs/FsModule.kt @@ -26,6 +26,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent +import org.oxycblt.musikr.fs.util.contentResolverSafe @Module @InstallIn(SingletonComponent::class) diff --git a/app/src/main/java/org/oxycblt/musikr/fs/MusicLocation.kt b/app/src/main/java/org/oxycblt/musikr/fs/MusicLocation.kt index 4c5a03e61..2438626b7 100644 --- a/app/src/main/java/org/oxycblt/musikr/fs/MusicLocation.kt +++ b/app/src/main/java/org/oxycblt/musikr/fs/MusicLocation.kt @@ -25,6 +25,7 @@ import android.provider.DocumentsContract import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import org.oxycblt.musikr.fs.path.DocumentPathFactory +import org.oxycblt.musikr.fs.util.contentResolverSafe class MusicLocation internal constructor(val uri: Uri, val path: Path) { override fun equals(other: Any?) = diff --git a/app/src/main/java/org/oxycblt/musikr/fs/StorageUtil.kt b/app/src/main/java/org/oxycblt/musikr/fs/StorageUtil.kt deleted file mode 100644 index 52e0b7477..000000000 --- a/app/src/main/java/org/oxycblt/musikr/fs/StorageUtil.kt +++ /dev/null @@ -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 . - */ - -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, - selector: String? = null, - args: Array? = 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 ContentResolver.useQuery( - uri: Uri, - projection: Array, - selector: String? = null, - args: Array? = null, - block: (Cursor) -> R -) = safeQuery(uri, projection, selector, args).use(block) - -inline fun ContentResolver.mapQuery( - uri: Uri, - projection: Array, - selector: String? = null, - args: Array? = null, - crossinline transform: Cursor.() -> R -) = - useQuery(uri, projection, selector, args) { - sequence { - 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 - 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() - } - } diff --git a/app/src/main/java/org/oxycblt/musikr/fs/path/DocumentPathFactory.kt b/app/src/main/java/org/oxycblt/musikr/fs/path/DocumentPathFactory.kt index c85bbdbee..a17b869eb 100644 --- a/app/src/main/java/org/oxycblt/musikr/fs/path/DocumentPathFactory.kt +++ b/app/src/main/java/org/oxycblt/musikr/fs/path/DocumentPathFactory.kt @@ -28,8 +28,8 @@ import javax.inject.Inject import org.oxycblt.musikr.fs.Components import org.oxycblt.musikr.fs.Path import org.oxycblt.musikr.fs.Volume -import org.oxycblt.musikr.fs.contentResolverSafe -import org.oxycblt.musikr.fs.useQuery +import org.oxycblt.musikr.fs.util.contentResolverSafe +import org.oxycblt.musikr.fs.util.useQuery /** * A factory for parsing the reverse-engineered format of the URIs obtained from document picker. diff --git a/app/src/main/java/org/oxycblt/musikr/fs/path/VolumeCompat.kt b/app/src/main/java/org/oxycblt/musikr/fs/path/VolumeCompat.kt new file mode 100644 index 000000000..2c07405b3 --- /dev/null +++ b/app/src/main/java/org/oxycblt/musikr/fs/path/VolumeCompat.kt @@ -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 + 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() + } + } diff --git a/app/src/main/java/org/oxycblt/musikr/fs/util/QueryUtil.kt b/app/src/main/java/org/oxycblt/musikr/fs/util/QueryUtil.kt new file mode 100644 index 000000000..9f1732745 --- /dev/null +++ b/app/src/main/java/org/oxycblt/musikr/fs/util/QueryUtil.kt @@ -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 . + */ + +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, + selector: String? = null, + args: Array? = 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 ContentResolver.useQuery( + uri: Uri, + projection: Array, + selector: String? = null, + args: Array? = null, + block: (Cursor) -> R +) = safeQuery(uri, projection, selector, args).use(block) diff --git a/app/src/main/java/org/oxycblt/musikr/playlist/ExternalPlaylistManager.kt b/app/src/main/java/org/oxycblt/musikr/playlist/ExternalPlaylistManager.kt index e8c33f30d..a6a33bd75 100644 --- a/app/src/main/java/org/oxycblt/musikr/playlist/ExternalPlaylistManager.kt +++ b/app/src/main/java/org/oxycblt/musikr/playlist/ExternalPlaylistManager.kt @@ -25,7 +25,7 @@ import javax.inject.Inject import org.oxycblt.musikr.Playlist import org.oxycblt.musikr.fs.Components 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.playlist.m3u.M3U import timber.log.Timber as L