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( data class Song(
override val rawName: String, override val rawName: String,
/** The path of this song. */ /** The path of this song. */
val path: NeoPath, val path: Path,
/** The URI linking to this song's file. */ /** The URI linking to this song's file. */
val uri: Uri, val uri: Uri,
/** The mime type of this song. */ /** The mime type of this song. */

View file

@ -26,52 +26,118 @@ import android.os.storage.StorageVolume
import android.provider.MediaStore import android.provider.MediaStore
import android.webkit.MimeTypeMap import android.webkit.MimeTypeMap
import com.google.android.exoplayer2.util.MimeTypes import com.google.android.exoplayer2.util.MimeTypes
import java.io.File
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.logEOrThrow
/** data class Path(val name: String, val parent: Directory)
* 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 NeoPath(val name: String, val parent: NeoDir) data class Directory(val volume: StorageVolume, val relativePath: String) {
init {
data class NeoDir(val volume: StorageVolume, val relativePath: String) { if (relativePath.startsWith(File.separatorChar) ||
fun resolveName(context: Context) = relativePath.endsWith(File.separatorChar)) {
context.getString(R.string.fmt_path, volume.getDescriptionCompat(context), relativePath) logEOrThrow("Path was formatted with trailing separators")
} }
/**
* 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()
} }
fun resolveName(context: Context) = fun resolveName(context: Context) =
when (this) { context.getString(R.string.fmt_path, volume.getDescriptionCompat(context), relativePath)
is Relative ->
when (volume) { /** Converts this dir into an opaque document URI in the form of VOLUME:PATH. */
is Volume.Primary -> context.getString(R.string.fmt_primary_path, relativePath) fun toDocumentUri(): String? {
is Volume.Secondary -> // "primary" actually corresponds to the primary *emulated* storage. External storage
context.getString(R.string.fmt_secondary_path, relativePath) // 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 * 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. * 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.content.Context
import android.database.Cursor import android.database.Cursor
import android.os.Build import android.os.Build
import android.os.Environment
import android.os.storage.StorageManager import android.os.storage.StorageManager
import android.os.storage.StorageVolume import android.os.storage.StorageVolume
import android.provider.MediaStore import android.provider.MediaStore
@ -28,16 +27,14 @@ import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull import androidx.core.database.getIntOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import java.io.File 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.Indexer
import org.oxycblt.auxio.music.MimeType import org.oxycblt.auxio.music.MimeType
import org.oxycblt.auxio.music.NeoDir import org.oxycblt.auxio.music.Path
import org.oxycblt.auxio.music.NeoPath
import org.oxycblt.auxio.music.Song import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.albumCoverUri import org.oxycblt.auxio.music.albumCoverUri
import org.oxycblt.auxio.music.audioUri import org.oxycblt.auxio.music.audioUri
import org.oxycblt.auxio.music.directoryCompat import org.oxycblt.auxio.music.directoryCompat
import org.oxycblt.auxio.music.dirs.MusicDirs
import org.oxycblt.auxio.music.id3GenreName import org.oxycblt.auxio.music.id3GenreName
import org.oxycblt.auxio.music.mediaStoreVolumeNameCompat import org.oxycblt.auxio.music.mediaStoreVolumeNameCompat
import org.oxycblt.auxio.music.no 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.settings.SettingsManager
import org.oxycblt.auxio.util.contentResolverSafe import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.getSystemServiceSafe 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 * 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. * 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 * 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. * 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 settingsManager = SettingsManager.getInstance()
val storageManager = context.getSystemServiceSafe(StorageManager::class) val storageManager = context.getSystemServiceSafe(StorageManager::class)
_volumes.addAll(storageManager.storageVolumesCompat) _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( return requireNotNull(
context.contentResolverSafe.queryCursor( context.contentResolverSafe.queryCursor(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection, projection,
selector.selector, selector,
selector.args.toTypedArray())) { "Content resolver failure: No Cursor returned" } args.toTypedArray())) { "Content resolver failure: No Cursor returned" }
} }
override fun buildSongs( override fun buildSongs(
@ -199,7 +226,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
open val projection: Array<String> open val projection: Array<String>
get() = BASE_PROJECTION 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 * 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 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 * 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 * several steps of the music loading process until it is complete enough to be transformed into
* a song. * an immutable song.
*/ */
data class Audio( data class Audio(
var id: Long? = null, var id: Long? = null,
var title: String? = null, var title: String? = null,
var displayName: String? = null, var displayName: String? = null,
var dir: NeoDir? = null, var dir: Directory? = null,
var extensionMimeType: String? = null, var extensionMimeType: String? = null,
var formatMimeType: String? = null, var formatMimeType: String? = null,
var size: Long? = null, var size: Long? = null,
@ -294,11 +320,11 @@ abstract class MediaStoreBackend : Indexer.Backend {
) { ) {
fun toSong(): Song { fun toSong(): Song {
return 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. // every device provides these fields, but it seems likely that they do.
rawName = requireNotNull(title) { "Malformed audio: No title" }, rawName = requireNotNull(title) { "Malformed audio: No title" },
path = path =
NeoPath( Path(
name = requireNotNull(displayName) { "Malformed audio: No display name" }, name = requireNotNull(displayName) { "Malformed audio: No display name" },
parent = requireNotNull(dir) { "Malformed audio: No parent directory" }), parent = requireNotNull(dir) { "Malformed audio: No parent directory" }),
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri, uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
@ -379,29 +405,12 @@ open class Api21MediaStoreBackend : MediaStoreBackend() {
super.projection + super.projection +
arrayOf(MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATA) arrayOf(MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATA)
override fun buildMusicDirsSelector(dirs: MusicDirs): Selector { override val dirSelector: String
val base = Environment.getExternalStorageDirectory().absolutePath get() = "${MediaStore.Audio.Media.DATA} LIKE ?"
var selector = BASE_SELECTOR
val args = mutableListOf<String>()
// Apply directories by filtering out specific DATA values. override fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean {
for (dir in dirs.dirs) { args.add("${dir.volume.directoryCompat ?: return false}/${dir.relativePath}%")
if (dir.volume is Dir.Volume.Secondary) { return true
// 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 buildAudio(context: Context, cursor: Cursor): Audio { override fun buildAudio(context: Context, cursor: Cursor): Audio {
@ -435,11 +444,13 @@ open class Api21MediaStoreBackend : MediaStoreBackend() {
val rawPath = data.substringBeforeLast(File.separatorChar) 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) { for (volume in volumes) {
val volumePath = volume.directoryCompat ?: continue val volumePath = volume.directoryCompat ?: continue
val strippedPath = rawPath.removePrefix(volumePath + File.separatorChar) val strippedPath = rawPath.removePrefix(volumePath)
if (strippedPath != rawPath) { if (strippedPath != rawPath) {
audio.dir = NeoDir(volume, strippedPath + File.separatorChar) audio.dir = Directory(volume, strippedPath.removePrefix(File.separator))
break break
} }
} }
@ -466,35 +477,16 @@ open class Api29MediaStoreBackend : Api21MediaStoreBackend() {
MediaStore.Audio.AudioColumns.VOLUME_NAME, MediaStore.Audio.AudioColumns.VOLUME_NAME,
MediaStore.Audio.AudioColumns.RELATIVE_PATH) MediaStore.Audio.AudioColumns.RELATIVE_PATH)
override fun buildMusicDirsSelector(dirs: MusicDirs): Selector { override val dirSelector: String
var selector = BASE_SELECTOR get() =
val args = mutableListOf<String>() "(${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 override fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean {
// use to properly exclude folders on secondary devices such as SD cards. // Leverage the volume field when selecting our directories.
args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false)
for (dir in dirs.dirs) { args.add("${dir.relativePath}%")
selector += return true
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 buildAudio(context: Context, cursor: Cursor): Audio { override fun buildAudio(context: Context, cursor: Cursor): Audio {
@ -509,11 +501,14 @@ open class Api29MediaStoreBackend : Api21MediaStoreBackend() {
val volumeName = cursor.getStringOrNull(volumeIndex) val volumeName = cursor.getStringOrNull(volumeIndex)
val relativePath = cursor.getStringOrNull(relativePathIndex) 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) { if (volumeName != null && relativePath != null) {
// Iterating through the volume list is easier t
val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName } val volume = volumes.find { it.mediaStoreVolumeNameCompat == volumeName }
if (volume != null) { 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 android.content.Context
import org.oxycblt.auxio.databinding.ItemMusicDirBinding 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.BackingData
import org.oxycblt.auxio.ui.BindingViewHolder import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.MonoAdapter
@ -32,22 +32,22 @@ import org.oxycblt.auxio.util.textSafe
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class MusicDirAdapter(listener: Listener) : class MusicDirAdapter(listener: Listener) :
MonoAdapter<Dir.Relative, MusicDirAdapter.Listener, MusicDirViewHolder>(listener) { MonoAdapter<Directory, MusicDirAdapter.Listener, MusicDirViewHolder>(listener) {
override val data = ExcludedBackingData(this) override val data = ExcludedBackingData(this)
override val creator = MusicDirViewHolder.CREATOR override val creator = MusicDirViewHolder.CREATOR
interface Listener { interface Listener {
fun onRemoveDirectory(dir: Dir.Relative) fun onRemoveDirectory(dir: Directory)
} }
class ExcludedBackingData(private val adapter: MusicDirAdapter) : BackingData<Dir.Relative>() { class ExcludedBackingData(private val adapter: MusicDirAdapter) : BackingData<Directory>() {
private val _currentList = mutableListOf<Dir.Relative>() private val _currentList = mutableListOf<Directory>()
val currentList: List<Dir.Relative> = _currentList val currentList: List<Directory> = _currentList
override fun getItemCount(): Int = _currentList.size 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)) { if (_currentList.contains(dir)) {
return return
} }
@ -56,13 +56,13 @@ class MusicDirAdapter(listener: Listener) :
adapter.notifyItemInserted(_currentList.lastIndex) adapter.notifyItemInserted(_currentList.lastIndex)
} }
fun addAll(dirs: List<Dir.Relative>) { fun addAll(dirs: List<Directory>) {
val oldLastIndex = dirs.lastIndex val oldLastIndex = dirs.lastIndex
_currentList.addAll(dirs) _currentList.addAll(dirs)
adapter.notifyItemRangeInserted(oldLastIndex, dirs.size) adapter.notifyItemRangeInserted(oldLastIndex, dirs.size)
} }
fun remove(dir: Dir.Relative) { fun remove(dir: Directory) {
val idx = _currentList.indexOf(dir) val idx = _currentList.indexOf(dir)
_currentList.removeAt(idx) _currentList.removeAt(idx)
adapter.notifyItemRemoved(idx) adapter.notifyItemRemoved(idx)
@ -72,8 +72,8 @@ class MusicDirAdapter(listener: Listener) :
/** The viewholder for [MusicDirAdapter]. Not intended for use in other adapters. */ /** The viewholder for [MusicDirAdapter]. Not intended for use in other adapters. */
class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) : class MusicDirViewHolder private constructor(private val binding: ItemMusicDirBinding) :
BindingViewHolder<Dir.Relative, MusicDirAdapter.Listener>(binding.root) { BindingViewHolder<Directory, MusicDirAdapter.Listener>(binding.root) {
override fun bind(item: Dir.Relative, listener: MusicDirAdapter.Listener) { override fun bind(item: Directory, listener: MusicDirAdapter.Listener) {
binding.dirPath.textSafe = item.resolveName(binding.context) binding.dirPath.textSafe = item.resolveName(binding.context)
binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(item) } binding.dirDelete.setOnClickListener { listener.onRemoveDirectory(item) }
} }

View file

@ -17,49 +17,7 @@
package org.oxycblt.auxio.music.dirs package org.oxycblt.auxio.music.dirs
import android.os.Build import org.oxycblt.auxio.music.Directory
import java.io.File
import org.oxycblt.auxio.music.Dir
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
data class MusicDirs(val dirs: List<Dir.Relative>, val shouldInclude: Boolean) { /** Represents a the configuration for the "Folder Management" setting */
companion object { data class MusicDirs(val dirs: List<Directory>, val shouldInclude: Boolean)
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}"
}
}
}

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.music.dirs
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.storage.StorageManager
import android.provider.DocumentsContract import android.provider.DocumentsContract
import android.view.LayoutInflater import android.view.LayoutInflater
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@ -28,10 +29,11 @@ import androidx.fragment.app.activityViewModels
import org.oxycblt.auxio.BuildConfig import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogMusicDirsBinding 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.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.getSystemServiceSafe
import org.oxycblt.auxio.util.hardRestart import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
@ -47,6 +49,8 @@ class MusicDirsDialog :
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val dirAdapter = MusicDirAdapter(this) private val dirAdapter = MusicDirAdapter(this)
private var storageManager: StorageManager? = null
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
DialogMusicDirsBinding.inflate(inflater) DialogMusicDirsBinding.inflate(inflater)
@ -76,7 +80,7 @@ class MusicDirsDialog :
} }
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
val dirs = settingsManager.musicDirs val dirs = settingsManager.getMusicDirs(requireContext(), requireStorageManager())
if (dirs.dirs != dirAdapter.data.currentList || if (dirs.dirs != dirAdapter.data.currentList ||
dirs.shouldInclude != isInclude(requireBinding())) { dirs.shouldInclude != isInclude(requireBinding())) {
@ -94,7 +98,8 @@ class MusicDirsDialog :
itemAnimator = null itemAnimator = null
} }
var dirs = settingsManager.musicDirs val storageManager = requireStorageManager()
var dirs = settingsManager.getMusicDirs(requireContext(), storageManager)
if (savedInstanceState != null) { if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS) val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
@ -102,7 +107,7 @@ class MusicDirsDialog :
if (pendingDirs != null) { if (pendingDirs != null) {
dirs = dirs =
MusicDirs( MusicDirs(
pendingDirs.mapNotNull(MusicDirs::parseDir), pendingDirs.mapNotNull { Directory.fromDocumentUri(storageManager, it) },
savedInstanceState.getBoolean(KEY_PENDING_MODE)) savedInstanceState.getBoolean(KEY_PENDING_MODE))
} }
} }
@ -135,7 +140,7 @@ class MusicDirsDialog :
binding.dirsRecycler.adapter = null binding.dirsRecycler.adapter = null
} }
override fun onRemoveDirectory(dir: Dir.Relative) { override fun onRemoveDirectory(dir: Directory) {
dirAdapter.data.remove(dir) dirAdapter.data.remove(dir)
requireBinding().dirsEmpty.isVisible = dirAdapter.data.currentList.isEmpty() 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 // Turn the raw URI into a document tree URI
val docUri = val docUri =
DocumentsContract.buildDocumentUriUsingTree( DocumentsContract.buildDocumentUriUsingTree(
uri, DocumentsContract.getTreeDocumentId(uri)) uri, DocumentsContract.getTreeDocumentId(uri))
logD(uri)
// Turn it into a semi-usable path // Turn it into a semi-usable path
val treeUri = DocumentsContract.getTreeDocumentId(docUri) val treeUri = DocumentsContract.getTreeDocumentId(docUri)
// Parsing handles the rest // Parsing handles the rest
return MusicDirs.parseDir(treeUri) return Directory.fromDocumentUri(requireStorageManager(), treeUri)
} }
private fun updateMode() { private fun updateMode() {
@ -182,12 +189,22 @@ class MusicDirsDialog :
binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include binding.folderModeGroup.checkedButtonId == R.id.dirs_mode_include
private fun saveAndRestart() { private fun saveAndRestart() {
settingsManager.musicDirs = settingsManager.setMusicDirs(
MusicDirs(dirAdapter.data.currentList, isInclude(requireBinding())) MusicDirs(dirAdapter.data.currentList, isInclude(requireBinding())))
playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() } 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 { companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED" const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED"
const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS" 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.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper import android.database.sqlite.SQLiteOpenHelper
import android.os.Build import android.os.Build
import android.os.Environment import android.os.storage.StorageManager
import androidx.core.content.edit import androidx.core.content.edit
import java.io.File import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.music.Dir 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.ui.accent.Accent
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.queryAll 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] * Converts paths from the old excluded directory database to a list of modern [Dir] instances.
* instances.
* *
* Historically, Auxio used an excluded directory database shamelessly ripped from Phonograph. This * 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 * 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 * path-based excluded system to a volume-based excluded system at the same time. These are both
* rolled into this conversion. * rolled into this conversion.
*/ */
fun handleExcludedCompat(context: Context): List<Dir.Relative> { fun handleExcludedCompat(context: Context, storageManager: StorageManager): List<Directory> {
val db = LegacyExcludedDatabase(context) 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 -> return db.readPaths().map { path ->
val relativePath = path.removePrefix(primaryPrefix) val relativePath = path.removePrefix(primaryDirectory)
Dir.Relative(Dir.Volume.Primary, relativePath) Directory(primaryVolume, relativePath)
} }
} }

View file

@ -19,10 +19,12 @@ package org.oxycblt.auxio.settings
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.os.storage.StorageManager
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit import androidx.core.content.edit
import androidx.preference.PreferenceManager import androidx.preference.PreferenceManager
import org.oxycblt.auxio.home.tabs.Tab import org.oxycblt.auxio.home.tabs.Tab
import org.oxycblt.auxio.music.Directory
import org.oxycblt.auxio.music.dirs.MusicDirs import org.oxycblt.auxio.music.dirs.MusicDirs
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
@ -137,24 +139,34 @@ class SettingsManager private constructor(context: Context) :
val pauseOnRepeat: Boolean val pauseOnRepeat: Boolean
get() = inner.getBoolean(KEY_PAUSE_ON_REPEAT, false) get() = inner.getBoolean(KEY_PAUSE_ON_REPEAT, false)
/** The list of directories that music should be hidden/loaded from. */ /** Get the list of directories that music should be hidden/loaded from. */
var musicDirs: MusicDirs fun getMusicDirs(context: Context, storageManager: StorageManager): MusicDirs {
get() { if (!inner.contains(KEY_MUSIC_DIRS)) {
val dirs = logD("Attempting to migrate excluded directories")
(inner.getStringSet(KEY_MUSIC_DIRS, null) ?: emptySet()).mapNotNull( // We need to migrate this setting now while we have a context. Note that while
MusicDirs::parseDir) // this does do IO work, the old excluded directory database is so small as to make
// it negligible.
return MusicDirs(dirs, inner.getBoolean(KEY_SHOULD_INCLUDE, false)) 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 val dirs =
commit() (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 */ /** The current filter mode of the search tab */
var searchFilterMode: DisplayMode? var searchFilterMode: DisplayMode?
@ -257,14 +269,6 @@ class SettingsManager private constructor(context: Context) :
init { init {
inner.registerOnSharedPreferenceChangeListener(this) 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 --- // --- 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 */ /** Shortcut method for logging [msg] as an error to the console. Handles anonymous objects */
fun Any.logE(msg: String) = Log.e(autoTag, msg) 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 * 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. * 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> <string name="cdc_wav">Microsoft WAVE</string>
<!-- Note: These are stopgap measures until we make the path code rely on components! --> <!-- 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_primary_path">Internal:%s</string>
<string name="fmt_secondary_path">SDCARD:%s</string> <string name="fmt_secondary_path">SDCARD:%s</string>
</resources> </resources>