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(
|
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. */
|
||||||
|
|
|
@ -26,51 +26,117 @@ 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 {
|
||||||
|
if (relativePath.startsWith(File.separatorChar) ||
|
||||||
|
relativePath.endsWith(File.separatorChar)) {
|
||||||
|
logEOrThrow("Path was formatted with trailing separators")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
data class NeoDir(val volume: StorageVolume, val relativePath: String) {
|
|
||||||
fun resolveName(context: Context) =
|
fun resolveName(context: Context) =
|
||||||
context.getString(R.string.fmt_path, volume.getDescriptionCompat(context), 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Represents a directory from the android file-system. Intentionally designed to be
|
* A list of recognized volumes, retrieved in a compatible manner. Note that these volumes may be
|
||||||
* version-agnostic and follow modern storage recommendations.
|
* mounted or unmounted.
|
||||||
*/
|
*/
|
||||||
sealed class Dir {
|
val StorageManager.storageVolumesCompat: List<StorageVolume>
|
||||||
/**
|
get() =
|
||||||
* A directory with a volume.
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||||
*
|
storageVolumes.toList()
|
||||||
* This data structure is not version-specific:
|
} else {
|
||||||
* - With excluded directories, it is the only path that is used. On versions that do not
|
@Suppress("UNCHECKED_CAST")
|
||||||
* support path, [Volume.Primary] is used.
|
(StorageManager::class.java.getDeclaredMethod("getVolumeList").invoke(this)
|
||||||
* - On songs, this is version-specific. It will only appear on versions that support it.
|
as Array<StorageVolume>)
|
||||||
*/
|
.toList()
|
||||||
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) =
|
/** Returns the absolute path to a particular volume in a compatible manner. */
|
||||||
when (this) {
|
val StorageVolume.directoryCompat: String?
|
||||||
is Relative ->
|
@SuppressLint("NewApi")
|
||||||
when (volume) {
|
get() =
|
||||||
is Volume.Primary -> context.getString(R.string.fmt_primary_path, relativePath)
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
|
||||||
is Volume.Secondary ->
|
directory?.absolutePath
|
||||||
context.getString(R.string.fmt_secondary_path, relativePath)
|
} 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
|
||||||
|
@ -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.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 ? " +
|
||||||
|
|
||||||
// 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 ?)"
|
"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
|
override fun addDirToSelectorArgs(dir: Directory, args: MutableList<String>): Boolean {
|
||||||
// name stored in-app. I have no idea how well this holds up on other devices.
|
// Leverage the volume field when selecting our directories.
|
||||||
args +=
|
args.add(dir.volume.mediaStoreVolumeNameCompat ?: return false)
|
||||||
when (dir.volume) {
|
args.add("${dir.relativePath}%")
|
||||||
is Dir.Volume.Primary -> MediaStore.VOLUME_EXTERNAL_PRIMARY
|
return true
|
||||||
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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
@ -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"
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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,19 +139,29 @@ 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)) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
|
||||||
val dirs =
|
val dirs =
|
||||||
(inner.getStringSet(KEY_MUSIC_DIRS, null) ?: emptySet()).mapNotNull(
|
(inner.getStringSet(KEY_MUSIC_DIRS, null) ?: emptySet()).mapNotNull {
|
||||||
MusicDirs::parseDir)
|
Directory.fromDocumentUri(storageManager, it)
|
||||||
|
}
|
||||||
|
|
||||||
return MusicDirs(dirs, inner.getBoolean(KEY_SHOULD_INCLUDE, false))
|
return MusicDirs(dirs, inner.getBoolean(KEY_SHOULD_INCLUDE, false))
|
||||||
}
|
}
|
||||||
set(value) {
|
|
||||||
|
/** Set the list of directories that music should be hidden/loaded from. */
|
||||||
|
fun setMusicDirs(musicDirs: MusicDirs) {
|
||||||
inner.edit {
|
inner.edit {
|
||||||
putStringSet(KEY_MUSIC_DIRS, value.dirs.map(MusicDirs::toDir).toSet())
|
putStringSet(KEY_MUSIC_DIRS, musicDirs.dirs.map(Directory::toDocumentUri).toSet())
|
||||||
putBoolean(KEY_SHOULD_INCLUDE, value.shouldInclude)
|
putBoolean(KEY_SHOULD_INCLUDE, musicDirs.shouldInclude)
|
||||||
|
|
||||||
// TODO: This is a stopgap measure before automatic rescanning, remove
|
// TODO: This is a stopgap measure before automatic rescanning, remove
|
||||||
commit()
|
commit()
|
||||||
|
@ -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 ---
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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>
|
Loading…
Reference in a new issue