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.
This commit is contained in:
OxygenCobalt 2022-06-14 10:12:05 -06:00
parent ab1ff416e1
commit 6fc4d46de5
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
10 changed files with 265 additions and 256 deletions

View file

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

View file

@ -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<StorageVolume>
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<StorageVolume>)
.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<StorageVolume>
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<StorageVolume>)
.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()
}
}

View file

@ -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<String>()
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<String>
get() = BASE_PROJECTION
abstract fun buildMusicDirsSelector(dirs: MusicDirs): Selector
abstract val dirSelector: String
abstract fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): 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<String>)
/**
* 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<String>()
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<String>): 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<String>()
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<String>): 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))
}
}

View file

@ -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<Dir.Relative, MusicDirAdapter.Listener, MusicDirViewHolder>(listener) {
MonoAdapter<Directory, MusicDirAdapter.Listener, MusicDirViewHolder>(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<Dir.Relative>() {
private val _currentList = mutableListOf<Dir.Relative>()
val currentList: List<Dir.Relative> = _currentList
class ExcludedBackingData(private val adapter: MusicDirAdapter) : BackingData<Directory>() {
private val _currentList = mutableListOf<Directory>()
val currentList: List<Directory> = _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<Dir.Relative>) {
fun addAll(dirs: List<Directory>) {
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<Dir.Relative, MusicDirAdapter.Listener>(binding.root) {
override fun bind(item: Dir.Relative, listener: MusicDirAdapter.Listener) {
BindingViewHolder<Directory, MusicDirAdapter.Listener>(binding.root) {
override fun bind(item: Directory, listener: MusicDirAdapter.Listener) {
binding.dirPath.textSafe = item.resolveName(binding.context)
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(item) }
}

View file

@ -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<Dir.Relative>, 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<Directory>, val shouldInclude: Boolean)

View file

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

View file

@ -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<Dir.Relative> {
fun handleExcludedCompat(context: Context, storageManager: StorageManager): List<Directory> {
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)
}
}

View file

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

View file

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

View file

@ -14,7 +14,7 @@
<string name="cdc_wav">Microsoft WAVE</string>
<!-- Note: These are stopgap measures until we make the path code rely on components! -->
<string name="fmt_path">%1$s:%2$s</string>
<string name="fmt_path">%1$s/%2$s</string>
<string name="fmt_primary_path">Internal:%s</string>
<string name="fmt_secondary_path">SDCARD:%s</string>
</resources>