diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt index 7ead34966..025cc3ba3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt @@ -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() {} } diff --git a/app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt b/app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt index e83b0ef06..02dff6c19 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/IndexerService.kt @@ -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") diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt index 03430b34a..e7f57fb76 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicUtil.kt @@ -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() + val groups = (GENRE_RE.matchEntire(this) ?: return null).groups + val genres = mutableSetOf() // 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. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/backend/ExoPlayerBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/backend/ExoPlayerBackend.kt index 08ca676af..82e77f671 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/backend/ExoPlayerBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/backend/ExoPlayerBackend.kt @@ -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() + 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 { 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) diff --git a/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt index f72584207..6402976a0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt @@ -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() - - // 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 { - // 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 get() = BASE_PROJECTION + abstract fun buildExcludedSelector(dirs: List): 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) + /** * 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 get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK) + override fun buildExcludedSelector(dirs: List): Selector { + val base = Environment.getExternalStorageDirectory().absolutePath + var selector = BASE_SELECTOR + val args = mutableListOf() + + // 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 + get() = + super.projection + + arrayOf( + MediaStore.Audio.AudioColumns.VOLUME_NAME, + MediaStore.Audio.AudioColumns.RELATIVE_PATH) + + override fun buildExcludedSelector(dirs: List): Selector { + var selector = BASE_SELECTOR + val args = mutableListOf() + + // 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 diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedAdapter.kt index 47cf4e190..9b9580ed5 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedAdapter.kt @@ -30,21 +30,21 @@ import org.oxycblt.auxio.util.textSafe * @author OxygenCobalt */ class ExcludedAdapter(listener: Listener) : - MonoAdapter(listener) { - override val data = PrimitiveBackingData(this) + MonoAdapter(listener) { + override val data = PrimitiveBackingData(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(binding.root) { - override fun bind(item: String, listener: ExcludedAdapter.Listener) { - binding.excludedPath.textSafe = item - binding.excludedClear.setOnClickListener { listener.onRemovePath(item) } + BindingViewHolder(binding.root) { + override fun bind(item: ExcludedDirectory, listener: ExcludedAdapter.Listener) { + binding.excludedPath.textSafe = item.toString() + binding.excludedClear.setOnClickListener { listener.onRemoveDirectory(item) } } companion object { diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt deleted file mode 100644 index 0cf4b6d9c..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDatabase.kt +++ /dev/null @@ -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 . - */ - -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) { - 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 { - requireBackgroundThread() - - val paths = mutableListOf() - 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 - } - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt index 8fd8770bf..a9442273c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt @@ -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(), 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) { - 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) { + 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" } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectory.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectory.kt new file mode 100644 index 000000000..85985f92d --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectory.kt @@ -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 . + */ + +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) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedViewModel.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedViewModel.kt deleted file mode 100644 index 6dd795762..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedViewModel.kt +++ /dev/null @@ -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 . - */ - -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()) - val paths: StateFlow> - 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 create(modelClass: Class): T { - check(modelClass.isAssignableFrom(ExcludedViewModel::class.java)) { - "ExcludedViewModel.Factory does not support this class" - } - - @Suppress("UNCHECKED_CAST") - return ExcludedViewModel(ExcludedDatabase.getInstance(context)) as T - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt index 9c6781e67..af14ea7b5 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt @@ -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 { + 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 { + val paths = mutableListOf() + 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" diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt index a7d82ec00..113248e64 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt @@ -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 + 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() diff --git a/app/src/main/res/values-v31/styles_core.xml b/app/src/main/res/values-v31/styles_core.xml index 6c0c38965..19fb67673 100644 --- a/app/src/main/res/values-v31/styles_core.xml +++ b/app/src/main/res/values-v31/styles_core.xml @@ -1,6 +1,5 @@ - - -