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:
parent
ab1ff416e1
commit
6fc4d46de5
10 changed files with 265 additions and 256 deletions
|
@ -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. */
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) }
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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 ---
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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>
|
Loading…
Reference in a new issue