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.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.auxio.music.backend.Api21MediaStoreBackend 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.Api30MediaStoreBackend
import org.oxycblt.auxio.music.backend.ExoPlayerBackend import org.oxycblt.auxio.music.backend.ExoPlayerBackend
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
@ -175,6 +176,7 @@ class Indexer {
val mediaStoreBackend = val mediaStoreBackend =
when { when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend() Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend()
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q -> Api29MediaStoreBackend()
else -> Api21MediaStoreBackend() else -> Api21MediaStoreBackend()
} }
@ -362,6 +364,11 @@ class Indexer {
* canceled for one reason or another. * canceled for one reason or another.
*/ */
fun onIndexerStateChanged(state: State?) 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() {} fun onRequestReindex() {}
} }

View file

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

View file

@ -114,8 +114,8 @@ private fun String.parseId3v1Genre(): String? =
} }
private fun String.parseId3v2Genre(): String? { private fun String.parseId3v2Genre(): String? {
val groups = GENRE_RE.matchEntire(this)?.groups ?: return null val groups = (GENRE_RE.matchEntire(this) ?: return null).groups
val genres = mutableListOf<String>() val genres = mutableSetOf<String>()
// ID3v2 genres are far more complex and require string grokking to properly implement. // 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 // 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. */ /** 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 // add a completed song to the list. To prevent a crash in that case, we use the
// concurrent counterpart to a typical mutable list. // concurrent counterpart to a typical mutable list.
val songs = ConcurrentLinkedQueue<Song>() val songs = ConcurrentLinkedQueue<Song>()
val total = cursor.count
while (cursor.moveToNext()) { while (cursor.moveToNext()) {
// Note: This call to buildAudio does not populate the genre field. This is // 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) { AudioCallback(audio) {
runningTasks[index] = null runningTasks[index] = null
songs.add(it) 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 // Normal JVM dispatcher will suffice here, as there is no IO work
// going on (and there is no cost from switching contexts with executors) // 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, private val onComplete: (Song) -> Unit,
) : FutureCallback<TrackGroupArray> { ) : FutureCallback<TrackGroupArray> {
override fun onSuccess(result: 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) { if (metadata != null) {
completeAudio(audio, metadata) completeAudio(audio, metadata)

View file

@ -20,6 +20,7 @@ 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.provider.MediaStore import android.provider.MediaStore
import androidx.annotation.RequiresApi import androidx.annotation.RequiresApi
import androidx.core.database.getIntOrNull 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.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.excluded.ExcludedDatabase import org.oxycblt.auxio.music.excluded.ExcludedDirectory
import org.oxycblt.auxio.music.id3GenreName import org.oxycblt.auxio.music.id3GenreName
import org.oxycblt.auxio.music.no import org.oxycblt.auxio.music.no
import org.oxycblt.auxio.music.queryCursor import org.oxycblt.auxio.music.queryCursor
import org.oxycblt.auxio.music.useQuery import org.oxycblt.auxio.music.useQuery
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.util.contentResolverSafe 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 * 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 private var dataIndex = -1
override fun query(context: Context): Cursor { override fun query(context: Context): Cursor {
val excludedDatabase = ExcludedDatabase.getInstance(context) val settingsManager = SettingsManager.getInstance()
var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1" val selector = buildExcludedSelector(settingsManager.excludedDirs)
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
}
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,
args.toTypedArray())) { "Content resolver failure: No Cursor returned" } selector.args.toTypedArray())) { "Content resolver failure: No Cursor returned" }
} }
override fun loadSongs( override fun loadSongs(
@ -137,7 +130,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
cursor: Cursor, cursor: Cursor,
emitLoading: (Indexer.Loading) -> Unit emitLoading: (Indexer.Loading) -> Unit
): Collection<Song> { ): 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 // 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 // with genre loading and querying the media database itself. As a result, a progress bar
// is not really that applicable. // 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 // 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 // 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. // every genre and assign it's name to audios that match it's child ID.
context.contentResolverSafe.useQuery( context.contentResolverSafe.useQuery(
MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI, MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor -> arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME)) { genreCursor ->
@ -188,6 +180,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
open val projection: Array<String> open val projection: Array<String>
get() = BASE_PROJECTION 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 * 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 * 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 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
@ -323,6 +319,12 @@ abstract class MediaStoreBackend : Indexer.Backend {
MediaStore.Audio.AudioColumns.ARTIST, MediaStore.Audio.AudioColumns.ARTIST,
AUDIO_COLUMN_ALBUM_ARTIST, AUDIO_COLUMN_ALBUM_ARTIST,
MediaStore.Audio.AudioColumns.DATA) 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> override val projection: Array<String>
get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) 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 { override fun buildAudio(context: Context, cursor: Cursor): Audio {
val audio = super.buildAudio(context, cursor) 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 * A [MediaStoreBackend] that completes the music loading process in a way compatible with at least
* API 30. * API 30.
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.R) @RequiresApi(Build.VERSION_CODES.R)
class Api30MediaStoreBackend : MediaStoreBackend() { class Api30MediaStoreBackend : Api29MediaStoreBackend() {
private var trackIndex: Int = -1 private var trackIndex: Int = -1
private var discIndex: Int = -1 private var discIndex: Int = -1

View file

@ -30,21 +30,21 @@ import org.oxycblt.auxio.util.textSafe
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ExcludedAdapter(listener: Listener) : class ExcludedAdapter(listener: Listener) :
MonoAdapter<String, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) { MonoAdapter<ExcludedDirectory, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) {
override val data = PrimitiveBackingData<String>(this) override val data = PrimitiveBackingData<ExcludedDirectory>(this)
override val creator = ExcludedViewHolder.CREATOR override val creator = ExcludedViewHolder.CREATOR
interface Listener { interface Listener {
fun onRemovePath(path: String) fun onRemoveDirectory(dir: ExcludedDirectory)
} }
} }
/** The viewholder for [ExcludedAdapter]. Not intended for use in other adapters. */ /** The viewholder for [ExcludedAdapter]. Not intended for use in other adapters. */
class ExcludedViewHolder private constructor(private val binding: ItemExcludedDirBinding) : class ExcludedViewHolder private constructor(private val binding: ItemExcludedDirBinding) :
BindingViewHolder<String, ExcludedAdapter.Listener>(binding.root) { BindingViewHolder<ExcludedDirectory, ExcludedAdapter.Listener>(binding.root) {
override fun bind(item: String, listener: ExcludedAdapter.Listener) { override fun bind(item: ExcludedDirectory, listener: ExcludedAdapter.Listener) {
binding.excludedPath.textSafe = item binding.excludedPath.textSafe = item.toString()
binding.excludedClear.setOnClickListener { listener.onRemovePath(item) } binding.excludedClear.setOnClickListener { listener.onRemoveDirectory(item) }
} }
companion object { 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.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Environment
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
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
import androidx.fragment.app.viewModels import kotlinx.coroutines.delay
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.DialogExcludedBinding import org.oxycblt.auxio.databinding.DialogExcludedBinding
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
import org.oxycblt.auxio.util.hardRestart import org.oxycblt.auxio.util.hardRestart
import org.oxycblt.auxio.util.launch import org.oxycblt.auxio.util.launch
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logW
import org.oxycblt.auxio.util.showToast import org.oxycblt.auxio.util.showToast
/** /**
@ -44,9 +43,7 @@ import org.oxycblt.auxio.util.showToast
*/ */
class ExcludedDialog : class ExcludedDialog :
ViewBindingDialogFragment<DialogExcludedBinding>(), ExcludedAdapter.Listener { ViewBindingDialogFragment<DialogExcludedBinding>(), ExcludedAdapter.Listener {
private val excludedModel: ExcludedViewModel by viewModels { private val settingsManager = SettingsManager.getInstance()
ExcludedViewModel.Factory(requireContext())
}
private val playbackModel: PlaybackViewModel by activityViewModels() private val playbackModel: PlaybackViewModel by activityViewModels()
private val excludedAdapter = ExcludedAdapter(this) private val excludedAdapter = ExcludedAdapter(this)
@ -54,7 +51,7 @@ class ExcludedDialog :
override fun onCreateBinding(inflater: LayoutInflater) = DialogExcludedBinding.inflate(inflater) override fun onCreateBinding(inflater: LayoutInflater) = DialogExcludedBinding.inflate(inflater)
override fun onConfigDialog(builder: AlertDialog.Builder) { 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 builder
.setTitle(R.string.set_excluded) .setTitle(R.string.set_excluded)
.setNeutralButton(R.string.lbl_add, null) .setNeutralButton(R.string.lbl_add, null)
@ -66,22 +63,20 @@ class ExcludedDialog :
val launcher = val launcher =
registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath) registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), ::addDocTreePath)
binding.excludedRecycler.adapter = excludedAdapter
// Now that the dialog exists, we get the view manually when the dialog is shown // 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 // 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 // click the "Add"/"Save" buttons. This prevents the dialog from disappearing in the former
// and the app from crashing in the latter. // 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 { dialog.getButton(AlertDialog.BUTTON_NEUTRAL)?.setOnClickListener {
logD("Opening launcher") logD("Opening launcher")
launcher.launch(null) launcher.launch(null)
} }
dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setOnClickListener {
if (excludedModel.isModified) { if (settingsManager.excludedDirs != excludedAdapter.data.currentList) {
logD("Committing changes") logD("Committing changes")
saveAndRestart() saveAndRestart()
} else { } 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) { override fun onDestroyBinding(binding: DialogExcludedBinding) {
@ -103,65 +110,56 @@ class ExcludedDialog :
binding.excludedRecycler.adapter = null binding.excludedRecycler.adapter = null
} }
override fun onRemovePath(path: String) { override fun onRemoveDirectory(dir: ExcludedDirectory) {
excludedModel.removePath(path) updateDirectories(excludedAdapter.data.currentList.toMutableList().also { it.remove(dir) })
}
private fun updatePaths(paths: MutableList<String>) {
excludedAdapter.data.submitList(paths)
requireBinding().excludedEmpty.isVisible = paths.isEmpty()
} }
private fun addDocTreePath(uri: Uri?) { private fun addDocTreePath(uri: Uri?) {
// A null URI means that the user left the file picker without picking a directory
if (uri == null) { 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)") logD("No URI given (user closed the dialog)")
return return
} }
val path = parseDocTreePath(uri) val dir = parseExcludedUri(uri)
if (dir != null) {
if (path != null) { updateDirectories(excludedAdapter.data.currentList.toMutableList().also { it.add(dir) })
excludedModel.addPath(path)
} else { } else {
requireContext().showToast(R.string.err_bad_dir) 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 // 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))
// Turn it into a semi-usable path // 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 // ExcludedDirectory handles the rest
// Unless I change the system to use the drive/directory system, that is. But there's no return ExcludedDirectory.fromString(treeUri)
// 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
} }
private fun getRootPath(): String { private fun updateDirectories(dirs: List<ExcludedDirectory>) {
return Environment.getExternalStorageDirectory().absolutePath excludedAdapter.data.submitList(dirs)
requireBinding().excludedEmpty.isVisible = dirs.isEmpty()
} }
private fun saveAndRestart() { 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() } playbackModel.savePlaybackState(requireContext()) { requireContext().hardRestart() }
} }
} }
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"
} }
} }

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 package org.oxycblt.auxio.settings
import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.os.Build import android.os.Build
import android.os.Environment
import androidx.core.content.edit import androidx.core.content.edit
import org.oxycblt.auxio.music.excluded.ExcludedDirectory
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.queryAll
// A couple of utils for migrating from old settings values to the new formats // 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)) 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. */ /** Cache of the old keys used in Auxio. */
private object OldKeys { private object OldKeys {
const val KEY_ACCENT2 = "KEY_ACCENT2" const val KEY_ACCENT2 = "KEY_ACCENT2"

View file

@ -23,12 +23,14 @@ 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.excluded.ExcludedDirectory
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
import org.oxycblt.auxio.playback.state.PlaybackMode import org.oxycblt.auxio.playback.state.PlaybackMode
import org.oxycblt.auxio.ui.DisplayMode import org.oxycblt.auxio.ui.DisplayMode
import org.oxycblt.auxio.ui.Sort import org.oxycblt.auxio.ui.Sort
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.unlikelyToBeNull import org.oxycblt.auxio.util.unlikelyToBeNull
/** /**
@ -40,10 +42,6 @@ class SettingsManager private constructor(context: Context) :
private val inner = PreferenceManager.getDefaultSharedPreferences(context) private val inner = PreferenceManager.getDefaultSharedPreferences(context)
init {
inner.registerOnSharedPreferenceChangeListener(this)
}
// --- VALUES --- // --- VALUES ---
/** The current theme */ /** The current theme */
@ -139,6 +137,18 @@ 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 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 */ /** The current filter mode of the search tab */
var searchFilterMode: DisplayMode? var searchFilterMode: DisplayMode?
get() = DisplayMode.fromInt(inner.getInt(KEY_SEARCH_FILTER_MODE, Int.MIN_VALUE)) 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 --- // --- CALLBACKS ---
private val callbacks = mutableListOf<Callback>() private val callbacks = mutableListOf<Callback>()

View file

@ -1,6 +1,5 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<style name="Theme.Widget" parent="@android:style/Theme.DeviceDefault.DayNight"> <style name="Theme.Widget" parent="@android:style/Theme.DeviceDefault.DayNight">
<item name="colorSurface">@color/widget_surface</item> <item name="colorSurface">@color/widget_surface</item>
<item name="colorPrimary">?android:attr/colorAccent</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:statusBarColor">@color/chrome_translucent</item>
<item name="android:navigationBarColor">@color/chrome_translucent</item> <item name="android:navigationBarColor">@color/chrome_translucent</item>
</style> </style>
<!-- Android 12 configuration -->
<style name="Theme.Auxio.V31" parent="Theme.Auxio.V27" />
<!-- Base theme --> <!-- Base theme -->
<style name="Theme.Auxio.App" parent="Theme.Auxio.V31"> <style name="Theme.Auxio.App" parent="Theme.Auxio.V27">
<!-- Values --> <!-- Values -->
<item name="colorAccent">?attr/colorSecondary</item> <item name="colorAccent">?attr/colorSecondary</item>
<item name="colorOutline">@color/overlay_stroke</item> <item name="colorOutline">@color/overlay_stroke</item>