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:
parent
7673fa4a40
commit
f3a7813f5e
14 changed files with 300 additions and 301 deletions
|
@ -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() {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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. */
|
||||||
|
|
|
@ -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)
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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]}")
|
private fun updateDirectories(dirs: List<ExcludedDirectory>) {
|
||||||
return null
|
excludedAdapter.data.submitList(dirs)
|
||||||
}
|
requireBinding().excludedEmpty.isVisible = dirs.isEmpty()
|
||||||
|
|
||||||
private fun getRootPath(): String {
|
|
||||||
return Environment.getExternalStorageDirectory().absolutePath
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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"
|
||||||
|
|
|
@ -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>()
|
||||||
|
|
|
@ -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>
|
||||||
|
|
|
@ -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>
|
||||||
|
|
Loading…
Reference in a new issue