music: add support for excluding other volumes

Add support for excluding directories on other volumes, at least from
Android Q onwards.

Previously, Auxio only supported excluding the primary volume. This was
mostly out of laziness, as the excluded directory implementation was
shamelessly copied from Phonograph. This commit completely refactors
the excluded directory system, dumpstering the old database (which was
overkill anyway) for a new system based on SharedPreferences that is
actually capable of handling external volumes.

Now, limitations regarding external volumes still apply below Android
Q, as the VOLUME_NAME field does not exist on those versions. However,
this should resolve at least one major complaint regarding the
excluded directory system. Now theres just all of the other complaints.

Resolves #134.
This commit is contained in:
OxygenCobalt 2022-06-05 11:31:56 -06:00
parent 7673fa4a40
commit f3a7813f5e
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
14 changed files with 300 additions and 301 deletions

View file

@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.backend.Api21MediaStoreBackend
import org.oxycblt.auxio.music.backend.Api29MediaStoreBackend
import org.oxycblt.auxio.music.backend.Api30MediaStoreBackend
import org.oxycblt.auxio.music.backend.ExoPlayerBackend
import org.oxycblt.auxio.ui.Sort
@ -175,6 +176,7 @@ class Indexer {
val mediaStoreBackend =
when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend()
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreBackend()
else -> Api21MediaStoreBackend()
}
@ -362,6 +364,11 @@ class Indexer {
* canceled for one reason or another.
*/
fun onIndexerStateChanged(state: State?)
/**
* Called when some piece of code that cannot index music requests a reindex. Callbacks that
* can index music should begin reindexing at this call.
*/
fun onRequestReindex() {}
}

View file

@ -62,6 +62,7 @@ class IndexerService : Service(), Indexer.Callback {
notification = IndexerNotification(this)
// FIXME: Do not re-index if Indexer has already completed
indexer.addCallback(this)
if (musicStore.library == null) {
logD("No library present, loading music now")

View file

@ -114,8 +114,8 @@ private fun String.parseId3v1Genre(): String? =
}
private fun String.parseId3v2Genre(): String? {
val groups = GENRE_RE.matchEntire(this)?.groups ?: return null
val genres = mutableListOf<String>()
val groups = (GENRE_RE.matchEntire(this) ?: return null).groups
val genres = mutableSetOf<String>()
// ID3v2 genres are far more complex and require string grokking to properly implement.
// You can read the spec for it here: https://id3.org/id3v2.3.0#TCON
@ -142,7 +142,7 @@ private fun String.parseId3v2Genre(): String? {
}
}
return genres.distinctBy { it }.joinToString(separator = ", ").ifEmpty { null }
return genres.joinToString(separator = ", ").ifEmpty { null }
}
/** Regex that implements matching for ID3v2's genre format. */

View file

@ -68,6 +68,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
// add a completed song to the list. To prevent a crash in that case, we use the
// concurrent counterpart to a typical mutable list.
val songs = ConcurrentLinkedQueue<Song>()
val total = cursor.count
while (cursor.moveToNext()) {
// Note: This call to buildAudio does not populate the genre field. This is
@ -90,7 +91,7 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
AudioCallback(audio) {
runningTasks[index] = null
songs.add(it)
emitLoading(Indexer.Loading.Songs(songs.size, cursor.count))
emitLoading(Indexer.Loading.Songs(songs.size, total))
},
// Normal JVM dispatcher will suffice here, as there is no IO work
// going on (and there is no cost from switching contexts with executors)
@ -115,7 +116,8 @@ class ExoPlayerBackend(private val inner: MediaStoreBackend) : Indexer.Backend {
private val onComplete: (Song) -> Unit,
) : FutureCallback<TrackGroupArray> {
override fun onSuccess(result: TrackGroupArray) {
val metadata = result[0].getFormat(0).metadata
val format = result[0].getFormat(0)
val metadata = format.metadata
if (metadata != null) {
completeAudio(audio, metadata)

View file

@ -20,6 +20,7 @@ package org.oxycblt.auxio.music.backend
import android.content.Context
import android.database.Cursor
import android.os.Build
import android.os.Environment
import android.provider.MediaStore
import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull
@ -28,12 +29,14 @@ import org.oxycblt.auxio.music.Indexer
import org.oxycblt.auxio.music.Song
import org.oxycblt.auxio.music.albumCoverUri
import org.oxycblt.auxio.music.audioUri
import org.oxycblt.auxio.music.excluded.ExcludedDatabase
import org.oxycblt.auxio.music.excluded.ExcludedDirectory
import org.oxycblt.auxio.music.id3GenreName
import org.oxycblt.auxio.music.no
import org.oxycblt.auxio.music.queryCursor
import org.oxycblt.auxio.music.useQuery
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.logW
/*
* This file acts as the base for most the black magic required to get a remotely sensible music
@ -111,25 +114,15 @@ abstract class MediaStoreBackend : Indexer.Backend {
private var dataIndex = -1
override fun query(context: Context): Cursor {
val excludedDatabase = ExcludedDatabase.getInstance(context)
var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1"
val args = mutableListOf<String>()
// Apply the excluded directories by filtering out specific DATA values.
// DATA was deprecated in Android 10, but it was un-deprecated in Android 12L,
// so it's probably okay to use it. The only reason we would want to use
// another method is for external partitions support, but there is no demand for that.
for (path in excludedDatabase.readPaths()) {
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "$path%" // Append % so that the selector properly detects children
}
val settingsManager = SettingsManager.getInstance()
val selector = buildExcludedSelector(settingsManager.excludedDirs)
return requireNotNull(
context.contentResolverSafe.queryCursor(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
projection,
selector,
args.toTypedArray())) { "Content resolver failure: No Cursor returned" }
selector.selector,
selector.args.toTypedArray())) { "Content resolver failure: No Cursor returned" }
}
override fun loadSongs(
@ -137,7 +130,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
cursor: Cursor,
emitLoading: (Indexer.Loading) -> Unit
): Collection<Song> {
// Note: We do not actually update the callback with an Indexing state, this is because
// Note: We do not actually update the callback with a current/total value, this is because
// loading music from MediaStore tends to be quite fast, with the only bottlenecks being
// with genre loading and querying the media database itself. As a result, a progress bar
// is not really that applicable.
@ -149,7 +142,6 @@ abstract class MediaStoreBackend : Indexer.Backend {
// The audio is not actually complete at this point, as we cannot obtain a genre
// through a song query. Instead, we have to do the hack where we iterate through
// every genre and assign it's name to audios that match it's child ID.
context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
@ -188,6 +180,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
open val projection: Array<String>
get() = BASE_PROJECTION
abstract fun buildExcludedSelector(dirs: List<ExcludedDirectory>): Selector
/**
* Build an [Audio] based on the current cursor values. Each implementation should try to obtain
* an upstream [Audio] first, and then populate it with version-specific fields outlined in
@ -253,6 +247,8 @@ 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
@ -323,6 +319,12 @@ abstract class MediaStoreBackend : Indexer.Backend {
MediaStore.Audio.AudioColumns.ARTIST,
AUDIO_COLUMN_ALBUM_ARTIST,
MediaStore.Audio.AudioColumns.DATA)
/**
* The base selector that works across all versions of android. Does not exclude
* directories.
*/
@JvmStatic protected val BASE_SELECTOR = "${MediaStore.Audio.Media.IS_MUSIC}=1"
}
}
@ -336,6 +338,25 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK)
override fun buildExcludedSelector(dirs: List<ExcludedDirectory>): Selector {
val base = Environment.getExternalStorageDirectory().absolutePath
var selector = BASE_SELECTOR
val args = mutableListOf<String>()
// Apply the excluded directories by filtering out specific DATA values.
for (dir in dirs) {
if (dir.volume is ExcludedDirectory.Volume.Secondary) {
logW("Cannot exclude directories on secondary drives")
continue
}
selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
args += "${base}/${dir.relativePath}%"
}
return Selector(selector, args)
}
override fun buildAudio(context: Context, cursor: Cursor): Audio {
val audio = super.buildAudio(context, cursor)
@ -362,13 +383,54 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
}
}
/**
* A [MediaStoreBackend] that completes the music loading process in a way compatible with at least
* API 29.
* @author OxygenCobalt
*/
@RequiresApi(Build.VERSION_CODES.Q)
open class Api29MediaStoreBackend : MediaStoreBackend() {
override val projection: Array<String>
get() =
super.projection +
arrayOf(
MediaStore.Audio.AudioColumns.VOLUME_NAME,
MediaStore.Audio.AudioColumns.RELATIVE_PATH)
override fun buildExcludedSelector(dirs: List<ExcludedDirectory>): Selector {
var selector = BASE_SELECTOR
val args = mutableListOf<String>()
// 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) {
selector +=
" 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 ExcludedDirectory.Volume.Primary -> MediaStore.VOLUME_EXTERNAL_PRIMARY
is ExcludedDirectory.Volume.Secondary -> dir.volume.name.lowercase()
}
args += "${dir.relativePath}%"
}
return Selector(selector, args)
}
}
/**
* A [MediaStoreBackend] that completes the music loading process in a way compatible with at least
* API 30.
* @author OxygenCobalt
*/
@RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreBackend : MediaStoreBackend() {
class Api30MediaStoreBackend : Api29MediaStoreBackend() {
private var trackIndex: Int = -1
private var discIndex: Int = -1

View file

@ -30,21 +30,21 @@ import org.oxycblt.auxio.util.textSafe
* @author OxygenCobalt
*/
class ExcludedAdapter(listener: Listener) :
MonoAdapter<String, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) {
override val data = PrimitiveBackingData<String>(this)
MonoAdapter<ExcludedDirectory, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) {
override val data = PrimitiveBackingData<ExcludedDirectory>(this)
override val creator = ExcludedViewHolder.CREATOR
interface Listener {
fun onRemovePath(path: String)
fun onRemoveDirectory(dir: ExcludedDirectory)
}
}
/** The viewholder for [ExcludedAdapter]. Not intended for use in other adapters. */
class ExcludedViewHolder private constructor(private val binding: ItemExcludedDirBinding) :
BindingViewHolder<String, ExcludedAdapter.Listener>(binding.root) {
override fun bind(item: String, listener: ExcludedAdapter.Listener) {
binding.excludedPath.textSafe = item
binding.excludedClear.setOnClickListener { listener.onRemovePath(item) }
BindingViewHolder<ExcludedDirectory, ExcludedAdapter.Listener>(binding.root) {
override fun bind(item: ExcludedDirectory, listener: ExcludedAdapter.Listener) {
binding.excludedPath.textSafe = item.toString()
binding.excludedClear.setOnClickListener { listener.onRemoveDirectory(item) }
}
companion object {

View file

@ -1,109 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.excluded
import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import androidx.core.database.sqlite.transaction
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.queryAll
import org.oxycblt.auxio.util.requireBackgroundThread
/**
* Database for storing excluded directories. Note that the paths stored here will not work with
* MediaStore unless you append a "%" at the end. Yes. I know Room exists. But that would needlessly
* bloat my app and has crippling bugs.
* @author OxygenCobalt
*
* TODO: Migrate this to SharedPreferences?
*/
class ExcludedDatabase(context: Context) : SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_NAME ($COLUMN_PATH TEXT NOT NULL)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS $TABLE_NAME")
onCreate(db)
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgrade(db, newVersion, oldVersion)
}
/** Write a list of [paths] to the database. */
fun writePaths(paths: List<String>) {
requireBackgroundThread()
writableDatabase.transaction {
delete(TABLE_NAME, null, null)
logD("Deleted paths db")
for (path in paths) {
insert(TABLE_NAME, null, ContentValues(1).apply { put(COLUMN_PATH, path) })
}
logD("Successfully wrote ${paths.size} paths to db")
}
}
/** Get the current list of paths from the database. */
fun readPaths(): List<String> {
requireBackgroundThread()
val paths = mutableListOf<String>()
readableDatabase.queryAll(TABLE_NAME) { cursor ->
while (cursor.moveToNext()) {
paths.add(cursor.getString(0))
}
}
logD("Successfully read ${paths.size} paths from db")
return paths
}
companion object {
// Blacklist is still used here for compatibility reasons, please don't get
// your pants in a twist about it.
const val DB_VERSION = 1
const val DB_NAME = "auxio_blacklist_database.db"
const val TABLE_NAME = "blacklist_dirs_table"
const val COLUMN_PATH = "COLUMN_PATH"
@Volatile private var INSTANCE: ExcludedDatabase? = null
/** Get/Instantiate the single instance of [ExcludedDatabase]. */
fun getInstance(context: Context): ExcludedDatabase {
val currentInstance = INSTANCE
if (currentInstance != null) {
return currentInstance
}
synchronized(this) {
val newInstance = ExcludedDatabase(context.applicationContext)
INSTANCE = newInstance
return newInstance
}
}
}
}

View file

@ -19,23 +19,22 @@ package org.oxycblt.auxio.music.excluded
import android.net.Uri
import android.os.Bundle
import android.os.Environment
import android.provider.DocumentsContract
import android.view.LayoutInflater
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels
import kotlinx.coroutines.delay
import org.oxycblt.auxio.BuildConfig
import org.oxycblt.auxio.R
import org.oxycblt.auxio.databinding.DialogExcludedBinding
import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.showToast
/**
@ -44,9 +43,7 @@ import org.oxycblt.auxio.util.showToast
*/
class ExcludedDialog :
ViewBindingDialogFragment<DialogExcludedBinding>(), ExcludedAdapter.Listener {
private val excludedModel: ExcludedViewModel by viewModels {
ExcludedViewModel.Factory(requireContext())
}
private val settingsManager = SettingsManager.getInstance()
private val playbackModel: PlaybackViewModel by activityViewModels()
private val excludedAdapter = ExcludedAdapter(this)
@ -54,7 +51,7 @@ class ExcludedDialog :
override fun onCreateBinding(inflater: LayoutInflater) = DialogExcludedBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) {
// Don't set the click listener here, we do some custom black magic in onCreateView instead.
// Don't set the click listener here, we do some custom magic in onCreateView instead.
builder
.setTitle(R.string.set_excluded)
.setNeutralButton(R.string.lbl_add, null)
@ -66,22 +63,20 @@ class ExcludedDialog :
val launcher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
binding.excludedRecycler.adapter = excludedAdapter
// Now that the dialog exists, we get the view manually when the dialog is shown
// and override its click listener so that the dialog does not auto-dismiss when we
// click the "Add"/"Save" buttons. This prevents the dialog from disappearing in the former
// and the app from crashing in the latter.
val dialog = requireDialog() as AlertDialog
requireDialog().setOnShowListener {
val dialog = it as AlertDialog
dialog.setOnShowListener {
dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
logD("Opening launcher")
launcher.launch(null)
}
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
if (excludedModel.isModified) {
if (settingsManager.excludedDirs != excludedAdapter.data.currentList) {
logD("Committing changes")
saveAndRestart()
} else {
@ -91,11 +86,23 @@ class ExcludedDialog :
}
}
// --- VIEWMODEL SETUP ---
binding.excludedRecycler.adapter = excludedAdapter
launch { excludedModel.paths.collect(::updatePaths) }
if (savedInstanceState != null) {
val pendingDirs = savedInstanceState.getStringArrayList(KEY_PENDING_DIRS)
if (pendingDirs != null) {
updateDirectories(pendingDirs.mapNotNull(ExcludedDirectory::fromString))
return
}
}
logD("Dialog created")
updateDirectories(settingsManager.excludedDirs)
}
override fun onSaveInstanceState(outState: Bundle) {
super.onSaveInstanceState(outState)
outState.putStringArrayList(
KEY_PENDING_DIRS, ArrayList(excludedAdapter.data.currentList.map { it.toString() }))
}
override fun onDestroyBinding(binding: DialogExcludedBinding) {
@ -103,65 +110,56 @@ class ExcludedDialog :
binding.excludedRecycler.adapter = null
}
override fun onRemovePath(path: String) {
excludedModel.removePath(path)
}
private fun updatePaths(paths: MutableList<String>) {
excludedAdapter.data.submitList(paths)
requireBinding().excludedEmpty.isVisible = paths.isEmpty()
override fun onRemoveDirectory(dir: ExcludedDirectory) {
updateDirectories(excludedAdapter.data.currentList.toMutableList().also { it.remove(dir) })
}
private fun addDocTreePath(uri: Uri?) {
// A null URI means that the user left the file picker without picking a directory
if (uri == null) {
// A null URI means that the user left the file picker without picking a directory
logD("No URI given (user closed the dialog)")
return
}
val path = parseDocTreePath(uri)
if (path != null) {
excludedModel.addPath(path)
val dir = parseExcludedUri(uri)
if (dir != null) {
updateDirectories(excludedAdapter.data.currentList.toMutableList().also { it.add(dir) })
} else {
requireContext().showToast(R.string.err_bad_dir)
}
}
private fun parseDocTreePath(uri: Uri): String? {
private fun parseExcludedUri(uri: Uri): ExcludedDirectory? {
// Turn the raw URI into a document tree URI
val docUri =
DocumentsContract.buildDocumentUriUsingTree(
uri, DocumentsContract.getTreeDocumentId(uri))
// Turn it into a semi-usable path
val typeAndPath = DocumentsContract.getTreeDocumentId(docUri).split(":")
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
// Only the main drive is supported, since that's all we can get from MediaColumns.DATA
// Unless I change the system to use the drive/directory system, that is. But there's no
// demand for that.
// TODO: You are going to split the queries into pre-Q and post-Q versions, so perhaps
// you should try to add external partition support again.
if (typeAndPath[0] == "primary") {
return getRootPath() + "/" + typeAndPath.last()
}
logW("Unsupported volume ${typeAndPath[0]}")
return null
// ExcludedDirectory handles the rest
return ExcludedDirectory.fromString(treeUri)
}
private fun getRootPath(): String {
return Environment.getExternalStorageDirectory().absolutePath
private fun updateDirectories(dirs: List<ExcludedDirectory>) {
excludedAdapter.data.submitList(dirs)
requireBinding().excludedEmpty.isVisible = dirs.isEmpty()
}
private fun saveAndRestart() {
excludedModel.save {
settingsManager.excludedDirs = excludedAdapter.data.currentList
// TODO: Dumb stopgap measure until automatic rescanning, REMOVE THIS BEFORE
// MAKING ANY RELEASE!!!!!!
launch {
delay(1000)
playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() }
}
}
companion object {
const val TAG = BuildConfig.APPLICATION_ID + ".tag.EXCLUDED"
const val KEY_PENDING_DIRS = BuildConfig.APPLICATION_ID + ".key.PENDING_DIRS"
}
}

View file

@ -0,0 +1,61 @@
/*
* Copyright (c) 2022 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.excluded
import android.os.Build
import org.oxycblt.auxio.util.logW
data class ExcludedDirectory(val volume: Volume, val relativePath: String) {
override fun toString(): String = "${volume}:$relativePath"
sealed class Volume {
object Primary : Volume() {
override fun toString() = VOLUME_PRIMARY_NAME
}
data class Secondary(val name: String) : Volume() {
override fun toString() = name
}
companion object {
private const val VOLUME_PRIMARY_NAME = "primary"
fun fromString(volume: String) =
when (volume) {
VOLUME_PRIMARY_NAME -> Primary
else -> Secondary(volume)
}
}
}
companion object {
private const val VOLUME_SEPARATOR = ':'
fun fromString(dir: String): ExcludedDirectory? {
val split = dir.split(VOLUME_SEPARATOR, limit = 2)
val volume = Volume.fromString(split.getOrNull(0) ?: return null)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && volume is Volume.Secondary) {
logW("Cannot use secondary volumes below API 29")
return null
}
val relativePath = split.getOrNull(1) ?: return null
return ExcludedDirectory(volume, relativePath)
}
}
}

View file

@ -1,110 +0,0 @@
/*
* Copyright (c) 2021 Auxio Project
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.music.excluded
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
* ViewModel that acts as a wrapper around [ExcludedDatabase], allowing for the addition/removal of
* paths. Use [Factory] to instantiate this.
* @author OxygenCobalt
*
* TODO: Unify with MusicViewModel
*/
class ExcludedViewModel(private val excludedDatabase: ExcludedDatabase) : ViewModel() {
private val _paths = MutableStateFlow(mutableListOf<String>())
val paths: StateFlow<MutableList<String>>
get() = _paths
var isModified: Boolean = false
private set
init {
loadDatabasePaths()
}
/**
* Add a path to this ViewModel. It will not write the path to the database unless [save] is
* called.
*/
fun addPath(path: String) {
val paths = unlikelyToBeNull(_paths.value)
if (!paths.contains(path)) {
paths.add(path)
_paths.value = _paths.value
isModified = true
}
}
/**
* Remove a path from this ViewModel, it will not remove this path from the database unless
* [save] is called.
*/
fun removePath(path: String) {
unlikelyToBeNull(_paths.value).remove(path)
_paths.value = _paths.value
isModified = true
}
/** Save the pending paths to the database. [onDone] will be called on completion. */
fun save(onDone: () -> Unit) {
viewModelScope.launch(Dispatchers.IO) {
val start = System.currentTimeMillis()
excludedDatabase.writePaths(unlikelyToBeNull(_paths.value))
isModified = false
onDone()
this@ExcludedViewModel.logD(
"Path save completed successfully in ${System.currentTimeMillis() - start}ms")
}
}
/** Load the paths stored in the database to this ViewModel, will erase any pending changes. */
private fun loadDatabasePaths() {
viewModelScope.launch(Dispatchers.IO) {
val start = System.currentTimeMillis()
isModified = false
val dbPaths = excludedDatabase.readPaths()
withContext(Dispatchers.Main) { _paths.value = dbPaths.toMutableList() }
this@ExcludedViewModel.logD(
"Path load completed successfully in ${System.currentTimeMillis() - start}ms")
}
}
class Factory(private val context: Context) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
check(modelClass.isAssignableFrom(ExcludedViewModel::class.java)) {
"ExcludedViewModel.Factory does not support this class"
}
@Suppress("UNCHECKED_CAST")
return ExcludedViewModel(ExcludedDatabase.getInstance(context)) as T
}
}
}

View file

@ -17,10 +17,17 @@
package org.oxycblt.auxio.settings
import android.content.Context
import android.content.SharedPreferences
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.os.Build
import android.os.Environment
import androidx.core.content.edit
import org.oxycblt.auxio.music.excluded.ExcludedDirectory
import org.oxycblt.auxio.ui.accent.Accent
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.queryAll
// A couple of utils for migrating from old settings values to the new formats
@ -69,6 +76,67 @@ fun handleAccentCompat(prefs: SharedPreferences): Accent {
return Accent.from(prefs.getInt(SettingsManager.KEY_ACCENT, 5))
}
/**
* Converts paths from the old excluded directory database to a list of modern [ExcludedDirectory]
* 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
* boundaries were not respected, and the data format limited us to grokking DATA.
*
* In 2.4.0, Auxio switched to a system based on SharedPreferences, also switching from a flat
* 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<ExcludedDirectory> {
val db = LegacyExcludedDatabase(context)
val primaryPrefix = Environment.getExternalStorageDirectory().absolutePath + '/'
return db.readPaths().map { path ->
val relativePath = path.removePrefix(primaryPrefix)
ExcludedDirectory(ExcludedDirectory.Volume.Primary, relativePath)
}
}
class LegacyExcludedDatabase(context: Context) :
SQLiteOpenHelper(context, DB_NAME, null, DB_VERSION) {
override fun onCreate(db: SQLiteDatabase) {
db.execSQL("CREATE TABLE IF NOT EXISTS $TABLE_NAME ($COLUMN_PATH TEXT NOT NULL)")
}
override fun onUpgrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
db.execSQL("DROP TABLE IF EXISTS $TABLE_NAME")
onCreate(db)
}
override fun onDowngrade(db: SQLiteDatabase, oldVersion: Int, newVersion: Int) {
onUpgrade(db, newVersion, oldVersion)
}
/** Get the current list of paths from the database. */
fun readPaths(): List<String> {
val paths = mutableListOf<String>()
readableDatabase.queryAll(TABLE_NAME) { cursor ->
while (cursor.moveToNext()) {
paths.add(cursor.getString(0))
}
}
logD("Successfully read ${paths.size} paths from db")
return paths
}
companion object {
// Blacklist is still used here for compatibility reasons, please don't get
// your pants in a twist about it.
const val DB_VERSION = 1
const val DB_NAME = "auxio_blacklist_database.db"
const val TABLE_NAME = "blacklist_dirs_table"
const val COLUMN_PATH = "COLUMN_PATH"
}
}
/** Cache of the old keys used in Auxio. */
private object OldKeys {
const val KEY_ACCENT2 = "KEY_ACCENT2"

View file

@ -23,12 +23,14 @@ 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.excluded.ExcludedDirectory
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort
import org.oxycblt.auxio.ui.accent.Accent
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.unlikelyToBeNull
/**
@ -40,10 +42,6 @@ class SettingsManager private constructor(context: Context) :
private val inner = PreferenceManager.getDefaultSharedPreferences(context)
init {
inner.registerOnSharedPreferenceChangeListener(this)
}
// --- VALUES ---
/** The current theme */
@ -139,6 +137,18 @@ class SettingsManager private constructor(context: Context) :
val pauseOnRepeat: Boolean
get() = inner.getBoolean(KEY_PAUSE_ON_REPEAT, false)
/** The list of directories excluded from indexing. */
var excludedDirs: List<ExcludedDirectory>
get() =
(inner.getStringSet(KEY_EXCLUDED, null) ?: emptySet()).mapNotNull(
ExcludedDirectory::fromString)
set(value) {
inner.edit {
putStringSet(KEY_EXCLUDED, value.map { it.toString() }.toSet())
apply()
}
}
/** The current filter mode of the search tab */
var searchFilterMode: DisplayMode?
get() = DisplayMode.fromInt(inner.getInt(KEY_SEARCH_FILTER_MODE, Int.MIN_VALUE))
@ -238,6 +248,18 @@ class SettingsManager private constructor(context: Context) :
}
}
init {
inner.registerOnSharedPreferenceChangeListener(this)
if (!inner.contains(KEY_EXCLUDED)) {
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.
excludedDirs = handleExcludedCompat(context)
}
}
// --- CALLBACKS ---
private val callbacks = mutableListOf<Callback>()

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Widget" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="colorSurface">@color/widget_surface</item>
<item name="colorPrimary">?android:attr/colorAccent</item>

View file

@ -7,11 +7,9 @@
<item name="android:statusBarColor">@color/chrome_translucent</item>
<item name="android:navigationBarColor">@color/chrome_translucent</item>
</style>
<!-- Android 12 configuration -->
<style name="Theme.Auxio.V31" parent="Theme.Auxio.V27" />
<!-- Base theme -->
<style name="Theme.Auxio.App" parent="Theme.Auxio.V31">
<style name="Theme.Auxio.App" parent="Theme.Auxio.V27">
<!-- Values -->
<item name="colorAccent">?attr/colorSecondary</item>
<item name="colorOutline">@color/overlay_stroke</item>