From 6fc4d46de5198970cbf20588d9d86bce1336cac3 Mon Sep 17 00:00:00 2001 From: OxygenCobalt Date: Tue, 14 Jun 2022 10:12:05 -0600 Subject: [PATCH] music: switch excluded system to storagevolume Switch the excluded directory system to StorageVolume. This finally removes dependence on the in-house Volume constructs, and also completely extends fully music folder selection support to all versions that Auxio supports. This is possibly one of the most invasive and risky reworks I have done with Auxio's music loader, but it's also somewhat exciting, as no other music player I know of handles Volumes like I do. --- .../java/org/oxycblt/auxio/music/Music.kt | 2 +- .../oxycblt/auxio/music/StorageFramework.kt | 189 ++++++++++-------- .../auxio/music/backend/MediaStoreBackend.kt | 137 ++++++------- .../auxio/music/dirs/MusicDirAdapter.kt | 24 +-- .../org/oxycblt/auxio/music/dirs/MusicDirs.kt | 48 +---- .../auxio/music/dirs/MusicDirsDialog.kt | 35 +++- .../oxycblt/auxio/settings/SettingsCompat.kt | 24 ++- .../oxycblt/auxio/settings/SettingsManager.kt | 48 +++-- .../java/org/oxycblt/auxio/util/LogUtil.kt | 12 ++ app/src/main/res/values/donottranslate.xml | 2 +- 10 files changed, 265 insertions(+), 256 deletions(-) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 5017bbf78..3038f2263 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -60,7 +60,7 @@ sealed class MusicParent : Music() { data class Song( override val rawName: String, /** The path of this song. */ - val path: NeoPath, + val path: Path, /** The URI linking to this song's file. */ val uri: Uri, /** The mime type of this song. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/StorageFramework.kt b/app/src/main/java/org/oxycblt/auxio/music/StorageFramework.kt index 79961732c..fa6b7f0b6 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/StorageFramework.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/StorageFramework.kt @@ -26,52 +26,118 @@ import android.os.storage.StorageVolume import android.provider.MediaStore import android.webkit.MimeTypeMap import com.google.android.exoplayer2.util.MimeTypes +import java.io.File import org.oxycblt.auxio.R +import org.oxycblt.auxio.util.logEOrThrow -/** - * Represents a path to a file from the android file-system. Intentionally designed to be - * version-agnostic and follow modern storage recommendations. - */ -data class Path(val name: String, val parent: Dir) +data class Path(val name: String, val parent: Directory) -data class NeoPath(val name: String, val parent: NeoDir) - -data class NeoDir(val volume: StorageVolume, val relativePath: String) { - fun resolveName(context: Context) = - context.getString(R.string.fmt_path, volume.getDescriptionCompat(context), relativePath) -} - -/** - * Represents a directory from the android file-system. Intentionally designed to be - * version-agnostic and follow modern storage recommendations. - */ -sealed class Dir { - /** - * A directory with a volume. - * - * This data structure is not version-specific: - * - With excluded directories, it is the only path that is used. On versions that do not - * support path, [Volume.Primary] is used. - * - On songs, this is version-specific. It will only appear on versions that support it. - */ - data class Relative(val volume: Volume, val relativePath: String) : Dir() - - sealed class Volume { - object Primary : Volume() - data class Secondary(val name: String) : Volume() +data class Directory(val volume: StorageVolume, val relativePath: String) { + init { + if (relativePath.startsWith(File.separatorChar) || + relativePath.endsWith(File.separatorChar)) { + logEOrThrow("Path was formatted with trailing separators") + } } fun resolveName(context: Context) = - when (this) { - is Relative -> - when (volume) { - is Volume.Primary -> context.getString(R.string.fmt_primary_path, relativePath) - is Volume.Secondary -> - context.getString(R.string.fmt_secondary_path, relativePath) - } + context.getString(R.string.fmt_path, volume.getDescriptionCompat(context), relativePath) + + /** Converts this dir into an opaque document URI in the form of VOLUME:PATH. */ + fun toDocumentUri(): String? { + // "primary" actually corresponds to the primary *emulated* storage. External storage + // can also be the primary storage, but is represented as a document ID using the UUID. + return if (volume.isPrimaryCompat && volume.isEmulatedCompat) { + "${DOCUMENT_URI_PRIMARY_NAME}:${relativePath}" + } else { + "${(volume.uuidCompat ?: return null).uppercase()}:${relativePath}" } + } + + companion object { + private const val DOCUMENT_URI_PRIMARY_NAME = "primary" + + /** + * Converts an opaque document uri in the form of VOLUME:PATH into a [Directory]. This is a + * flagrant violation of the API convention, but since we never really write to the URI I + * really doubt it matters. + */ + fun fromDocumentUri(storageManager: StorageManager, uri: String): Directory? { + val split = uri.split(File.pathSeparator, limit = 2) + + val volume = + when (split[0]) { + DOCUMENT_URI_PRIMARY_NAME -> storageManager.primaryStorageVolume + else -> storageManager.storageVolumesCompat.find { it.uuidCompat == split[0] } + } + + val relativePath = split.getOrNull(1) + + return Directory(volume ?: return null, relativePath ?: return null) + } + } } +/** + * A list of recognized volumes, retrieved in a compatible manner. Note that these volumes may be + * mounted or unmounted. + */ +val StorageManager.storageVolumesCompat: List + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + storageVolumes.toList() + } else { + @Suppress("UNCHECKED_CAST") + (StorageManager::class.java.getDeclaredMethod("getVolumeList").invoke(this) + as Array) + .toList() + } + +/** Returns the absolute path to a particular volume in a compatible manner. */ +val StorageVolume.directoryCompat: String? + @SuppressLint("NewApi") + get() = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + directory?.absolutePath + } else { + // Replicate getDirectory: getPath if mounted, null if not + when (stateCompat) { + Environment.MEDIA_MOUNTED, + Environment.MEDIA_MOUNTED_READ_ONLY -> + StorageVolume::class.java.getDeclaredMethod("getPath").invoke(this) as String + else -> null + } + } + +/** Get the readable description of the volume in a compatible manner. */ +@SuppressLint("NewApi") +fun StorageVolume.getDescriptionCompat(context: Context): String = getDescription(context) + +val StorageVolume.isPrimaryCompat: Boolean + @SuppressLint("NewApi") get() = isPrimary + +val StorageVolume.isEmulatedCompat: Boolean + @SuppressLint("NewApi") get() = isEmulated + +val StorageVolume.uuidCompat: String? + @SuppressLint("NewApi") get() = uuid + +val StorageVolume.stateCompat: String + @SuppressLint("NewApi") get() = state + +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) { + MediaStore.VOLUME_EXTERNAL_PRIMARY + } else { + uuidCompat?.lowercase() + } + } + /** * Represents a mime type as it is loaded by Auxio. [fromExtension] is based on the file extension * should always exist, while [fromFormat] is based on the file itself and may not be available. @@ -141,52 +207,3 @@ data class MimeType(val fromExtension: String, val fromFormat: String?) { } } } - -val StorageManager.storageVolumesCompat: List - get() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - storageVolumes.toList() - } else { - @Suppress("UNCHECKED_CAST") - (StorageManager::class.java.getDeclaredMethod("getVolumeList").invoke(this) - as Array) - .toList() - } - -val StorageVolume.directoryCompat: String? - @SuppressLint("NewApi") - get() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - directory?.absolutePath - } else { - when (stateCompat) { - Environment.MEDIA_MOUNTED, - Environment.MEDIA_MOUNTED_READ_ONLY -> - StorageVolume::class.java.getDeclaredMethod("getPath").invoke(this) as String - else -> null - } - } - -@SuppressLint("NewApi") -fun StorageVolume.getDescriptionCompat(context: Context): String = getDescription(context) - -val StorageVolume.isPrimaryCompat: Boolean - @SuppressLint("NewApi") get() = isPrimary - -val StorageVolume.uuidCompat: String? - @SuppressLint("NewApi") get() = uuid - -val StorageVolume.stateCompat: String - @SuppressLint("NewApi") get() = state - -val StorageVolume.mediaStoreVolumeNameCompat: String? - get() = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - mediaStoreVolumeName - } else { - if (isPrimaryCompat) { - MediaStore.VOLUME_EXTERNAL_PRIMARY - } else { - uuid?.lowercase() - } - } diff --git a/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt index 4c01d1e1f..d230c0da9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt @@ -20,7 +20,6 @@ package org.oxycblt.auxio.music.backend import android.content.Context import android.database.Cursor import android.os.Build -import android.os.Environment import android.os.storage.StorageManager import android.os.storage.StorageVolume import android.provider.MediaStore @@ -28,16 +27,14 @@ import androidx.annotation.RequiresApi import androidx.core.database.getIntOrNull import androidx.core.database.getStringOrNull import java.io.File -import org.oxycblt.auxio.music.Dir +import org.oxycblt.auxio.music.Directory import org.oxycblt.auxio.music.Indexer import org.oxycblt.auxio.music.MimeType -import org.oxycblt.auxio.music.NeoDir -import org.oxycblt.auxio.music.NeoPath +import org.oxycblt.auxio.music.Path import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.albumCoverUri import org.oxycblt.auxio.music.audioUri import org.oxycblt.auxio.music.directoryCompat -import org.oxycblt.auxio.music.dirs.MusicDirs import org.oxycblt.auxio.music.id3GenreName import org.oxycblt.auxio.music.mediaStoreVolumeNameCompat import org.oxycblt.auxio.music.no @@ -47,6 +44,7 @@ import org.oxycblt.auxio.music.useQuery import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.util.contentResolverSafe import org.oxycblt.auxio.util.getSystemServiceSafe +import org.oxycblt.auxio.util.logD /* * This file acts as the base for most the black magic required to get a remotely sensible music @@ -106,8 +104,6 @@ import org.oxycblt.auxio.util.getSystemServiceSafe * I wish I was born in the neolithic. */ -// TODO: Leverage StorageVolume to extend volume support to earlier versions - /** * Represents a [Indexer.Backend] that loads music from the media database ([MediaStore]). This is * not a fully-featured class by itself, and it's API-specific derivatives should be used instead. @@ -134,14 +130,45 @@ abstract class MediaStoreBackend : Indexer.Backend { val settingsManager = SettingsManager.getInstance() val storageManager = context.getSystemServiceSafe(StorageManager::class) _volumes.addAll(storageManager.storageVolumesCompat) - val selector = buildMusicDirsSelector(settingsManager.musicDirs) + val dirs = settingsManager.getMusicDirs(context, storageManager) + + val args = mutableListOf() + var selector = BASE_SELECTOR + + if (dirs.dirs.isNotEmpty()) { + // We have directories we need to exclude, extend the selector with new arguments + selector += if (dirs.shouldInclude) { + logD("Need to select folders (Include)") + " AND (" + } else { + logD("Need to select folders (Exclude)") + " AND NOT (" + } + + // Since selector arguments are contained within a single parentheses, we need to + // do a bunch of stuff. + for (i in dirs.dirs.indices) { + if (addDirToSelectorArgs(dirs.dirs[i], args)) { + selector += + if (i < dirs.dirs.lastIndex) { + "$dirSelector OR " + } else { + dirSelector + } + } + } + + selector += ')' + } + + logD("Starting query [selector: $selector, args: $args]") return requireNotNull( context.contentResolverSafe.queryCursor( MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, projection, - selector.selector, - selector.args.toTypedArray())) { "Content resolver failure: No Cursor returned" } + selector, + args.toTypedArray())) { "Content resolver failure: No Cursor returned" } } override fun buildSongs( @@ -199,7 +226,8 @@ abstract class MediaStoreBackend : Indexer.Backend { open val projection: Array get() = BASE_PROJECTION - abstract fun buildMusicDirsSelector(dirs: MusicDirs): Selector + abstract val dirSelector: String + abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList): Boolean /** * Build an [Audio] based on the current cursor values. Each implementation should try to obtain @@ -267,18 +295,16 @@ abstract class MediaStoreBackend : Indexer.Backend { return audio } - data class Selector(val selector: String, val args: List) - /** * Represents a song as it is represented by MediaStore. This is progressively mutated over * several steps of the music loading process until it is complete enough to be transformed into - * a song. + * an immutable song. */ data class Audio( var id: Long? = null, var title: String? = null, var displayName: String? = null, - var dir: NeoDir? = null, + var dir: Directory? = null, var extensionMimeType: String? = null, var formatMimeType: String? = null, var size: Long? = null, @@ -294,11 +320,11 @@ abstract class MediaStoreBackend : Indexer.Backend { ) { fun toSong(): Song { return Song( - // Assert that the fields that should exist are present. I can't confirm that + // Assert that the fields that should always exist are present. I can't confirm that // every device provides these fields, but it seems likely that they do. rawName = requireNotNull(title) { "Malformed audio: No title" }, path = - NeoPath( + Path( name = requireNotNull(displayName) { "Malformed audio: No display name" }, parent = requireNotNull(dir) { "Malformed audio: No parent directory" }), uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri, @@ -379,29 +405,12 @@ open class Api21MediaStoreBackend : MediaStoreBackend() { super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATA) - override fun buildMusicDirsSelector(dirs: MusicDirs): Selector { - val base = Environment.getExternalStorageDirectory().absolutePath - var selector = BASE_SELECTOR - val args = mutableListOf() + override val dirSelector: String + get() = "${MediaStore.Audio.Media.DATA} LIKE ?" - // Apply directories by filtering out specific DATA values. - for (dir in dirs.dirs) { - if (dir.volume is Dir.Volume.Secondary) { - // Should never happen. - throw IllegalStateException() - } - - selector += - if (dirs.shouldInclude) { - " AND ${MediaStore.Audio.Media.DATA} LIKE ?" - } else { - " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?" - } - - args += "${base}/${dir.relativePath}%" - } - - return Selector(selector, args) + override fun addDirToSelectorArgs(dir: Directory, args: MutableList): Boolean { + args.add("${dir.volume.directoryCompat ?: return false}/${dir.relativePath}%") + return true } override fun buildAudio(context: Context, cursor: Cursor): Audio { @@ -435,11 +444,13 @@ open class Api21MediaStoreBackend : MediaStoreBackend() { val rawPath = data.substringBeforeLast(File.separatorChar) + // Find the volume that transforms the DATA field into a relative path. This is + // the volume and relative path we will use. for (volume in volumes) { val volumePath = volume.directoryCompat ?: continue - val strippedPath = rawPath.removePrefix(volumePath + File.separatorChar) + val strippedPath = rawPath.removePrefix(volumePath) if (strippedPath != rawPath) { - audio.dir = NeoDir(volume, strippedPath + File.separatorChar) + audio.dir = Directory(volume, strippedPath.removePrefix(File.separator)) break } } @@ -466,35 +477,16 @@ open class Api29MediaStoreBackend : Api21MediaStoreBackend() { MediaStore.Audio.AudioColumns.VOLUME_NAME, MediaStore.Audio.AudioColumns.RELATIVE_PATH) - override fun buildMusicDirsSelector(dirs: MusicDirs): Selector { - var selector = BASE_SELECTOR - val args = mutableListOf() + override val dirSelector: String + get() = + "(${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " + + "AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)" - // Starting in Android Q, we finally have access to the volume name. This allows - // use to properly exclude folders on secondary devices such as SD cards. - - for (dir in dirs.dirs) { - selector += - if (dirs.shouldInclude) { - " AND (${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " + - "AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)" - } else { - " AND NOT (${MediaStore.Audio.AudioColumns.VOLUME_NAME} LIKE ? " + - "AND ${MediaStore.Audio.AudioColumns.RELATIVE_PATH} LIKE ?)" - } - - // Assume that volume names are always lowercase counterparts to the volume - // name stored in-app. I have no idea how well this holds up on other devices. - args += - when (dir.volume) { - is Dir.Volume.Primary -> MediaStore.VOLUME_EXTERNAL_PRIMARY - is Dir.Volume.Secondary -> dir.volume.name.lowercase() - } - - args += "${dir.relativePath}%" - } - - return Selector(selector, args) + override fun addDirToSelectorArgs(dir: Directory, args: MutableList): Boolean { + // Leverage the volume field when selecting our directories. + args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false) + args.add("${dir.relativePath}%") + return true } override fun buildAudio(context: Context, cursor: Cursor): Audio { @@ -509,11 +501,14 @@ open class Api29MediaStoreBackend : Api21MediaStoreBackend() { val volumeName = cursor.getStringOrNull(volumeIndex) val relativePath = cursor.getStringOrNull(relativePathIndex) + // We now have access to the volume name, so we try to leverage it instead. + // I have no idea how well this works in practice, so we still leverage + // the API 21 path grokking in the case that these fields are not sane. if (volumeName != null && relativePath != null) { + // Iterating through the volume list is easier t val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } - if (volume != null) { - audio.dir = NeoDir(volume, relativePath) + audio.dir = Directory(volume, relativePath.removeSuffix(File.separator)) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirAdapter.kt index 4a0e2f186..3b96c77f9 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirAdapter.kt @@ -19,7 +19,7 @@ package org.oxycblt.auxio.music.dirs import android.content.Context import org.oxycblt.auxio.databinding.ItemMusicDirBinding -import org.oxycblt.auxio.music.Dir +import org.oxycblt.auxio.music.Directory import org.oxycblt.auxio.ui.BackingData import org.oxycblt.auxio.ui.BindingViewHolder import org.oxycblt.auxio.ui.MonoAdapter @@ -32,22 +32,22 @@ import org.oxycblt.auxio.util.textSafe * @author OxygenCobalt */ class MusicDirAdapter(listener: Listener) : - MonoAdapter(listener) { + MonoAdapter(listener) { override val data = ExcludedBackingData(this) override val creator = MusicDirViewHolder.CREATOR interface Listener { - fun onRemoveDirectory(dir: Dir.Relative) + fun onRemoveDirectory(dir: Directory) } - class ExcludedBackingData(private val adapter: MusicDirAdapter) : BackingData() { - private val _currentList = mutableListOf() - val currentList: List = _currentList + class ExcludedBackingData(private val adapter: MusicDirAdapter) : BackingData() { + private val _currentList = mutableListOf() + val currentList: List = _currentList override fun getItemCount(): Int = _currentList.size - override fun getItem(position: Int): Dir.Relative = _currentList[position] + override fun getItem(position: Int): Directory = _currentList[position] - fun add(dir: Dir.Relative) { + fun add(dir: Directory) { if (_currentList.contains(dir)) { return } @@ -56,13 +56,13 @@ class MusicDirAdapter(listener: Listener) : adapter.notifyItemInserted(_currentList.lastIndex) } - fun addAll(dirs: List) { + fun addAll(dirs: List) { val oldLastIndex = dirs.lastIndex _currentList.addAll(dirs) adapter.notifyItemRangeInserted(oldLastIndex, dirs.size) } - fun remove(dir: Dir.Relative) { + fun remove(dir: Directory) { val idx = _currentList.indexOf(dir) _currentList.removeAt(idx) adapter.notifyItemRemoved(idx) @@ -72,8 +72,8 @@ class MusicDirAdapter(listener: Listener) : /** The viewholder for [MusicDirAdapter]. Not intended for use in other adapters. */ class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) : - BindingViewHolder(binding.root) { - override fun bind(item: Dir.Relative, listener: MusicDirAdapter.Listener) { + BindingViewHolder(binding.root) { + override fun bind(item: Directory, listener: MusicDirAdapter.Listener) { binding.dirPath.textSafe = item.resolveName(binding.context) binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(item) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirs.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirs.kt index 694749148..748bd43c1 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirs.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirs.kt @@ -17,49 +17,7 @@ package org.oxycblt.auxio.music.dirs -import android.os.Build -import java.io.File -import org.oxycblt.auxio.music.Dir -import org.oxycblt.auxio.util.logD -import org.oxycblt.auxio.util.logW +import org.oxycblt.auxio.music.Directory -data class MusicDirs(val dirs: List, val shouldInclude: Boolean) { - companion object { - private const val VOLUME_PRIMARY_NAME = "primary" - - fun parseDir(dir: String): Dir.Relative? { - logD("Parse from string $dir") - - val split = dir.split(File.pathSeparator, limit = 2) - - val volume = - when (split[0]) { - VOLUME_PRIMARY_NAME -> Dir.Volume.Primary - else -> - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - Dir.Volume.Secondary(split[0]) - } else { - // While Android Q provides a stable way of accessing volumes, we can't - // trust that DATA provides a stable volume scheme on older versions, so - // external volumes are not supported. - logW("Cannot use secondary volumes below Android 10") - return null - } - } - - val relativePath = split.getOrNull(1) ?: return null - - return Dir.Relative(volume, relativePath) - } - - fun toDir(dir: Dir.Relative): String { - val volume = - when (dir.volume) { - is Dir.Volume.Primary -> VOLUME_PRIMARY_NAME - is Dir.Volume.Secondary -> dir.volume.name - } - - return "${volume}:${dir.relativePath}" - } - } -} +/** Represents a the configuration for the "Folder Management" setting */ +data class MusicDirs(val dirs: List, val shouldInclude: Boolean) diff --git a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt index 22fc275b7..717d408f3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/dirs/MusicDirsDialog.kt @@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.dirs import android.net.Uri import android.os.Bundle +import android.os.storage.StorageManager import android.provider.DocumentsContract import android.view.LayoutInflater import androidx.activity.result.contract.ActivityResultContracts @@ -28,10 +29,11 @@ import androidx.fragment.app.activityViewModels import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.R import org.oxycblt.auxio.databinding.DialogMusicDirsBinding -import org.oxycblt.auxio.music.Dir +import org.oxycblt.auxio.music.Directory import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.ui.ViewBindingDialogFragment +import org.oxycblt.auxio.util.getSystemServiceSafe import org.oxycblt.auxio.util.hardRestart import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.showToast @@ -47,6 +49,8 @@ class MusicDirsDialog : private val playbackModel: PlaybackViewModel by activityViewModels() private val dirAdapter = MusicDirAdapter(this) + private var storageManager: StorageManager? = null + override fun onCreateBinding(inflater: LayoutInflater) = DialogMusicDirsBinding.inflate(inflater) @@ -76,7 +80,7 @@ class MusicDirsDialog : } dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { - val dirs = settingsManager.musicDirs + val dirs = settingsManager.getMusicDirs(requireContext(), requireStorageManager()) if (dirs.dirs != dirAdapter.data.currentList || dirs.shouldInclude != isInclude(requireBinding())) { @@ -94,7 +98,8 @@ class MusicDirsDialog : itemAnimator = null } - var dirs = settingsManager.musicDirs + val storageManager = requireStorageManager() + var dirs = settingsManager.getMusicDirs(requireContext(), storageManager) if (savedInstanceState != null) { val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) @@ -102,7 +107,7 @@ class MusicDirsDialog : if (pendingDirs != null) { dirs = MusicDirs( - pendingDirs.mapNotNull(MusicDirs::parseDir), + pendingDirs.mapNotNull { Directory.fromDocumentUri(storageManager, it) }, savedInstanceState.getBoolean(KEY_PENDING_MODE)) } } @@ -135,7 +140,7 @@ class MusicDirsDialog : binding.dirsRecycler.adapter = null } - override fun onRemoveDirectory(dir: Dir.Relative) { + override fun onRemoveDirectory(dir: Directory) { dirAdapter.data.remove(dir) requireBinding().dirsEmpty.isVisible = dirAdapter.data.currentList.isEmpty() } @@ -156,17 +161,19 @@ class MusicDirsDialog : } } - private fun parseExcludedUri(uri: Uri): Dir.Relative? { + private fun parseExcludedUri(uri: Uri): Directory? { // Turn the raw URI into a document tree URI val docUri = DocumentsContract.buildDocumentUriUsingTree( uri, DocumentsContract.getTreeDocumentId(uri)) + logD(uri) + // Turn it into a semi-usable path val treeUri = DocumentsContract.getTreeDocumentId(docUri) // Parsing handles the rest - return MusicDirs.parseDir(treeUri) + return Directory.fromDocumentUri(requireStorageManager(), treeUri) } private fun updateMode() { @@ -182,12 +189,22 @@ class MusicDirsDialog : binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include private fun saveAndRestart() { - settingsManager.musicDirs = - MusicDirs(dirAdapter.data.currentList, isInclude(requireBinding())) + settingsManager.setMusicDirs( + MusicDirs(dirAdapter.data.currentList, isInclude(requireBinding()))) playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() } } + private fun requireStorageManager(): StorageManager { + val mgr = storageManager + if (mgr != null) { + return mgr + } + val newMgr = requireContext().getSystemServiceSafe(StorageManager::class) + storageManager = newMgr + return newMgr + } + companion object { const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED" const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS" diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt index f679bee26..8cd60e954 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt @@ -22,10 +22,13 @@ import android.content.SharedPreferences import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteOpenHelper import android.os.Build -import android.os.Environment +import android.os.storage.StorageManager import androidx.core.content.edit -import java.io.File -import org.oxycblt.auxio.music.Dir +import org.oxycblt.auxio.music.Directory +import org.oxycblt.auxio.music.directoryCompat +import org.oxycblt.auxio.music.isEmulatedCompat +import org.oxycblt.auxio.music.isPrimaryCompat +import org.oxycblt.auxio.music.storageVolumesCompat import org.oxycblt.auxio.ui.accent.Accent import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.queryAll @@ -79,8 +82,7 @@ fun handleAccentCompat(prefs: SharedPreferences): Accent { } /** - * Converts paths from the old excluded directory database to a list of modern [Dir.Relative] - * instances. + * Converts paths from the old excluded directory database to a list of modern [Dir] instances. * * Historically, Auxio used an excluded directory database shamelessly ripped from Phonograph. This * was a dumb idea, as the choice of a full-blown database for a few paths was overkill, version @@ -90,12 +92,16 @@ fun handleAccentCompat(prefs: SharedPreferences): Accent { * path-based excluded system to a volume-based excluded system at the same time. These are both * rolled into this conversion. */ -fun handleExcludedCompat(context: Context): List { +fun handleExcludedCompat(context: Context, storageManager: StorageManager): List { val db = LegacyExcludedDatabase(context) - val primaryPrefix = Environment.getExternalStorageDirectory().absolutePath + File.separatorChar + // /storage/emulated/0 (the old path prefix) should correspond to primary *emulated* storage. + val primaryVolume = + storageManager.storageVolumesCompat.find { it.isPrimaryCompat && it.isEmulatedCompat } + ?: return emptyList() + val primaryDirectory = primaryVolume.directoryCompat ?: return emptyList() return db.readPaths().map { path -> - val relativePath = path.removePrefix(primaryPrefix) - Dir.Relative(Dir.Volume.Primary, relativePath) + val relativePath = path.removePrefix(primaryDirectory) + Directory(primaryVolume, relativePath) } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt index 92f04240f..ba18cbf36 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt @@ -19,10 +19,12 @@ package org.oxycblt.auxio.settings import android.content.Context import android.content.SharedPreferences +import android.os.storage.StorageManager import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit import androidx.preference.PreferenceManager import org.oxycblt.auxio.home.tabs.Tab +import org.oxycblt.auxio.music.Directory import org.oxycblt.auxio.music.dirs.MusicDirs import org.oxycblt.auxio.playback.replaygain.ReplayGainMode import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp @@ -137,24 +139,34 @@ class SettingsManager private constructor(context: Context) : val pauseOnRepeat: Boolean get() = inner.getBoolean(KEY_PAUSE_ON_REPEAT, false) - /** The list of directories that music should be hidden/loaded from. */ - var musicDirs: MusicDirs - get() { - val dirs = - (inner.getStringSet(KEY_MUSIC_DIRS, null) ?: emptySet()).mapNotNull( - MusicDirs::parseDir) - - return MusicDirs(dirs, inner.getBoolean(KEY_SHOULD_INCLUDE, false)) + /** Get the list of directories that music should be hidden/loaded from. */ + fun getMusicDirs(context: Context, storageManager: StorageManager): MusicDirs { + if (!inner.contains(KEY_MUSIC_DIRS)) { + logD("Attempting to migrate excluded directories") + // We need to migrate this setting now while we have a context. Note that while + // this does do IO work, the old excluded directory database is so small as to make + // it negligible. + setMusicDirs(MusicDirs(handleExcludedCompat(context, storageManager), false)) } - set(value) { - inner.edit { - putStringSet(KEY_MUSIC_DIRS, value.dirs.map(MusicDirs::toDir).toSet()) - putBoolean(KEY_SHOULD_INCLUDE, value.shouldInclude) - // TODO: This is a stopgap measure before automatic rescanning, remove - commit() + val dirs = + (inner.getStringSet(KEY_MUSIC_DIRS, null) ?: emptySet()).mapNotNull { + Directory.fromDocumentUri(storageManager, it) } + + return MusicDirs(dirs, inner.getBoolean(KEY_SHOULD_INCLUDE, false)) + } + + /** Set the list of directories that music should be hidden/loaded from. */ + fun setMusicDirs(musicDirs: MusicDirs) { + inner.edit { + putStringSet(KEY_MUSIC_DIRS, musicDirs.dirs.map(Directory::toDocumentUri).toSet()) + putBoolean(KEY_SHOULD_INCLUDE, musicDirs.shouldInclude) + + // TODO: This is a stopgap measure before automatic rescanning, remove + commit() } + } /** The current filter mode of the search tab */ var searchFilterMode: DisplayMode? @@ -257,14 +269,6 @@ class SettingsManager private constructor(context: Context) : init { inner.registerOnSharedPreferenceChangeListener(this) - - if (!inner.contains(KEY_MUSIC_DIRS)) { - logD("Attempting to migrate excluded directories") - // We need to migrate this setting now while we have a context. Note that while - // this does do IO work, the old excluded directory database is so small as to make - // it negligible. - musicDirs = MusicDirs(handleExcludedCompat(context), false) - } } // --- CALLBACKS --- diff --git a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt index 1ef6c001d..9e56eeac6 100644 --- a/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/util/LogUtil.kt @@ -46,6 +46,18 @@ fun Any.logW(msg: String) = Log.w(autoTag, msg) /** Shortcut method for logging [msg] as an error to the console. Handles anonymous objects */ fun Any.logE(msg: String) = Log.e(autoTag, msg) +/** + * Logs an error on release, but throws an exception in debug. This is useful for non-showstopper + * bugs that I would still prefer to be caught in debug mode. + */ +fun Any.logEOrThrow(msg: String) { + if (BuildConfig.DEBUG) { + error("${autoTag}: $msg") + } else { + logE(msg) + } +} + /** * Logs an error in production while still throwing it in debug mode. This is useful for * non-showstopper bugs that I would still prefer to be caught in debug mode. diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index 7056ada17..9f5b32102 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -14,7 +14,7 @@ Microsoft WAVE - %1$s:%2$s + %1$s/%2$s Internal:%s SDCARD:%s \ No newline at end of file