music: add support for file properties
Add support for file size, format, and parent directory values to the MediaStore backend. I hope that this handles API boundaries properly, especially regarding path parsing. As a side-note, I have learned of a way to extend external volume support to even earlier versions. Maybe.
This commit is contained in:
parent
3f85678d99
commit
7373451912
16 changed files with 265 additions and 128 deletions
|
@ -18,19 +18,12 @@
|
|||
package org.oxycblt.auxio.detail
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Color
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.graphics.drawable.RippleDrawable
|
||||
import android.os.Build
|
||||
import android.text.method.MovementMethod
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.annotation.AttrRes
|
||||
import androidx.core.graphics.drawable.DrawableCompat.setTint
|
||||
import com.google.android.material.shape.MaterialShapeDrawable
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.util.getAttrStateListSafe
|
||||
|
||||
class ReadOnlyTextInput : TextInputEditText {
|
||||
constructor(context: Context) : super(context)
|
||||
|
|
|
@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail
|
|||
|
||||
import android.os.Bundle
|
||||
import android.view.LayoutInflater
|
||||
import android.webkit.MimeTypeMap
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.view.isGone
|
||||
import androidx.fragment.app.activityViewModels
|
||||
|
@ -31,6 +32,7 @@ import org.oxycblt.auxio.util.launch
|
|||
|
||||
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
||||
private val detailModel: DetailViewModel by activityViewModels()
|
||||
private val mimeTypes = MimeTypeMap.getSingleton()
|
||||
|
||||
override fun onCreateBinding(inflater: LayoutInflater) =
|
||||
DialogSongDetailBinding.inflate(inflater)
|
||||
|
@ -51,7 +53,6 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
|
|||
|
||||
if (song != null) {
|
||||
binding.detailContainer.isGone = false
|
||||
binding.detailFileName.setText(song.song.fileName)
|
||||
|
||||
if (song.bitrateKbps != null) {
|
||||
binding.detailBitrate.setText(getString(R.string.fmt_bitrate, song.bitrateKbps))
|
||||
|
|
|
@ -58,6 +58,11 @@ constructor(context: Context, attrs: AttributeSet? = null, @AttrRes defStyleAttr
|
|||
staticIcon = styledAttrs.getResourceId(R.styleable.StyledImageView_staticIcon, -1)
|
||||
styledAttrs.recycle()
|
||||
|
||||
if (staticIcon > -1) {
|
||||
@Suppress("LeakingThis")
|
||||
setImageDrawable(StyledDrawable(context, context.getDrawableSafe(staticIcon)))
|
||||
}
|
||||
|
||||
background =
|
||||
MaterialShapeDrawable().apply {
|
||||
fillColor = context.getColorStateListSafe(R.color.sel_cover_bg)
|
||||
|
|
|
@ -59,10 +59,14 @@ sealed class MusicParent : Music() {
|
|||
/** The data object for a song. */
|
||||
data class Song(
|
||||
override val rawName: String,
|
||||
/** The file name of this song, excluding the full path. */
|
||||
val fileName: String,
|
||||
/** The path of this song. */
|
||||
val path: Path,
|
||||
/** The URI linking to this song's file. */
|
||||
val uri: Uri,
|
||||
/** The mime type of this song. */
|
||||
val mimeType: String,
|
||||
/** The size of this song (in bytes) */
|
||||
val size: Long,
|
||||
/** The total duration of this song, in millis. */
|
||||
val durationMs: Long,
|
||||
/** The track number of this song, null if there isn't any. */
|
||||
|
|
|
@ -75,7 +75,7 @@ class MusicStore private constructor() {
|
|||
val displayName =
|
||||
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME))
|
||||
|
||||
songs.find { it.fileName == displayName }
|
||||
songs.find { it.path.name == displayName }
|
||||
}
|
||||
|
||||
/** "Sanitize" a music object from a previous library iteration. */
|
||||
|
|
67
app/src/main/java/org/oxycblt/auxio/music/PathFramework.kt
Normal file
67
app/src/main/java/org/oxycblt/auxio/music/PathFramework.kt
Normal file
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* 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
|
||||
|
||||
import android.content.Context
|
||||
import org.oxycblt.auxio.R
|
||||
|
||||
/**
|
||||
* Represents a path to a file from the android file-system. Intentionally designed to be
|
||||
* version-agnostic and follow modern storage recommendations.
|
||||
*/
|
||||
data class Path(val name: String, val parent: Dir)
|
||||
|
||||
/**
|
||||
* Represents a directory from the android file-system. Intentionally designed to be
|
||||
* version-agnostic and follow modern storage recommendations.
|
||||
*/
|
||||
sealed class Dir {
|
||||
/**
|
||||
* An absolute path.
|
||||
*
|
||||
* This is only used with [Song] instances on pre-Q android versions. This should be avoided in
|
||||
* most cases for [Relative].
|
||||
*/
|
||||
data class Absolute(val path: String) : Dir()
|
||||
|
||||
/**
|
||||
* A directory with a volume.
|
||||
*
|
||||
* This data structure is not version-specific:
|
||||
* - With excluded directories, it is the only path that is used. On versions that do not
|
||||
* support path, [Volume.Primary] is used.
|
||||
* - On songs, this is version-specific. It will only appear on versions that support it.
|
||||
*/
|
||||
data class Relative(val volume: Volume, val relativePath: String) : Dir()
|
||||
|
||||
sealed class Volume {
|
||||
object Primary : Volume()
|
||||
data class Secondary(val name: String) : Volume()
|
||||
}
|
||||
|
||||
fun resolveName(context: Context) =
|
||||
when (this) {
|
||||
is Absolute -> path
|
||||
is Relative ->
|
||||
when (volume) {
|
||||
is Volume.Primary -> context.getString(R.string.fmt_primary_path, relativePath)
|
||||
is Volume.Secondary ->
|
||||
context.getString(R.string.fmt_secondary_path, relativePath)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -26,11 +26,12 @@ import androidx.annotation.RequiresApi
|
|||
import androidx.core.database.getIntOrNull
|
||||
import androidx.core.database.getStringOrNull
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.music.Dir
|
||||
import org.oxycblt.auxio.music.Indexer
|
||||
import org.oxycblt.auxio.music.Path
|
||||
import org.oxycblt.auxio.music.Song
|
||||
import org.oxycblt.auxio.music.albumCoverUri
|
||||
import org.oxycblt.auxio.music.audioUri
|
||||
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
|
||||
|
@ -97,6 +98,8 @@ import org.oxycblt.auxio.util.logW
|
|||
* I wish I was born in the neolithic.
|
||||
*/
|
||||
|
||||
// TODO: Leverage StorageVolume to extend volume support to earlier versions
|
||||
|
||||
/**
|
||||
* Represents a [Indexer.Backend] that loads music from the media database ([MediaStore]). This is
|
||||
* not a fully-featured class by itself, and it's API-specific derivatives should be used instead.
|
||||
|
@ -106,6 +109,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
private var idIndex = -1
|
||||
private var titleIndex = -1
|
||||
private var displayNameIndex = -1
|
||||
private var mimeTypeIndex = -1
|
||||
private var sizeIndex = -1
|
||||
private var durationIndex = -1
|
||||
private var yearIndex = -1
|
||||
private var albumIndex = -1
|
||||
|
@ -181,7 +186,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
open val projection: Array<String>
|
||||
get() = BASE_PROJECTION
|
||||
|
||||
abstract fun buildExcludedSelector(dirs: List<ExcludedDirectory>): Selector
|
||||
abstract fun buildExcludedSelector(dirs: List<Dir.Relative>): Selector
|
||||
|
||||
/**
|
||||
* Build an [Audio] based on the current cursor values. Each implementation should try to obtain
|
||||
|
@ -195,6 +200,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
|
||||
displayNameIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
|
||||
mimeTypeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.MIME_TYPE)
|
||||
sizeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.SIZE)
|
||||
durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
|
||||
yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
|
||||
albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
|
||||
|
@ -209,16 +216,14 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
audio.id = cursor.getLong(idIndex)
|
||||
audio.title = cursor.getString(titleIndex)
|
||||
|
||||
audio.mimeType = cursor.getString(mimeTypeIndex)
|
||||
audio.size = cursor.getLong(sizeIndex)
|
||||
|
||||
// Try to use the DISPLAY_NAME field to obtain a (probably sane) file name
|
||||
// from the android system. Once again though, OEM issues get in our way and
|
||||
// this field isn't available on some platforms. In that case, we have to rely
|
||||
// on DATA to get a reasonable file name.
|
||||
audio.displayName =
|
||||
cursor.getStringOrNull(displayNameIndex)
|
||||
?: cursor
|
||||
.getStringOrNull(dataIndex)
|
||||
?.substringAfterLast(File.separatorChar, MediaStore.UNKNOWN_STRING)
|
||||
?: MediaStore.UNKNOWN_STRING
|
||||
audio.displayName = cursor.getStringOrNull(displayNameIndex)
|
||||
|
||||
audio.duration = cursor.getLong(durationIndex)
|
||||
audio.year = cursor.getIntOrNull(yearIndex)
|
||||
|
@ -260,6 +265,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
var id: Long? = null,
|
||||
var title: String? = null,
|
||||
var displayName: String? = null,
|
||||
var dir: Dir? = null,
|
||||
var mimeType: String? = null,
|
||||
var size: Long? = null,
|
||||
var duration: Long? = null,
|
||||
var track: Int? = null,
|
||||
var disc: Int? = null,
|
||||
|
@ -270,13 +278,18 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
var albumArtist: String? = null,
|
||||
var genre: String? = null
|
||||
) {
|
||||
fun toSong(): Song =
|
||||
Song(
|
||||
fun toSong(): Song {
|
||||
return Song(
|
||||
// Assert that the fields that should exist are present. I can't confirm that
|
||||
// every device provides these fields, but it seems likely that they do.
|
||||
rawName = requireNotNull(title) { "Malformed audio: No title" },
|
||||
fileName = requireNotNull(displayName) { "Malformed audio: No file name" },
|
||||
path =
|
||||
Path(
|
||||
name = requireNotNull(displayName) { "Malformed audio: No display name" },
|
||||
parent = requireNotNull(dir) { "Malformed audio: No parent directory" }),
|
||||
uri = requireNotNull(id) { "Malformed audio: No id" }.audioUri,
|
||||
mimeType = requireNotNull(mimeType) { "Malformed audio: No mime type" },
|
||||
size = requireNotNull(size) { "Malformed audio: No size" },
|
||||
durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
|
||||
track = track,
|
||||
disc = disc,
|
||||
|
@ -287,6 +300,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
_artistName = artist,
|
||||
_albumArtistName = albumArtist,
|
||||
_genreName = genre)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -314,6 +328,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
MediaStore.Audio.AudioColumns._ID,
|
||||
MediaStore.Audio.AudioColumns.TITLE,
|
||||
MediaStore.Audio.AudioColumns.DISPLAY_NAME,
|
||||
MediaStore.Audio.AudioColumns.MIME_TYPE,
|
||||
MediaStore.Audio.AudioColumns.SIZE,
|
||||
MediaStore.Audio.AudioColumns.DURATION,
|
||||
MediaStore.Audio.AudioColumns.YEAR,
|
||||
MediaStore.Audio.AudioColumns.ALBUM,
|
||||
|
@ -334,22 +350,23 @@ abstract class MediaStoreBackend : Indexer.Backend {
|
|||
* A [MediaStoreBackend] that completes the music loading process in a way compatible from
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
class Api21MediaStoreBackend : MediaStoreBackend() {
|
||||
open class Api21MediaStoreBackend : MediaStoreBackend() {
|
||||
private var trackIndex = -1
|
||||
private var dataIndex = -1
|
||||
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
arrayOf(MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATA)
|
||||
|
||||
override fun buildExcludedSelector(dirs: List<ExcludedDirectory>): Selector {
|
||||
override fun buildExcludedSelector(dirs: List<Dir.Relative>): 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) {
|
||||
if (dir.volume is Dir.Volume.Secondary) {
|
||||
logW("Cannot exclude directories on secondary drives")
|
||||
continue
|
||||
}
|
||||
|
@ -364,9 +381,10 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
|
|||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
|
||||
// Initialize the TRACK index if we have not already.
|
||||
// Initialize our indices if we have not already.
|
||||
if (trackIndex == -1) {
|
||||
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
|
||||
dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
|
||||
}
|
||||
|
||||
// TRACK is formatted as DTTT where D is the disc number and T is the track number.
|
||||
|
@ -383,6 +401,18 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
|
|||
}
|
||||
}
|
||||
|
||||
val data = cursor.getStringOrNull(dataIndex)
|
||||
if (data != null) {
|
||||
if (audio.displayName == null) {
|
||||
audio.displayName = data.substringAfterLast(File.separatorChar, "").ifEmpty { null }
|
||||
}
|
||||
|
||||
audio.dir =
|
||||
data.substringBeforeLast(File.separatorChar, "").let { dir ->
|
||||
if (dir.isNotEmpty()) Dir.Absolute(dir) else null
|
||||
}
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
}
|
||||
|
@ -393,8 +423,18 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
|
|||
* @author OxygenCobalt
|
||||
*/
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
open class Api29MediaStoreBackend : MediaStoreBackend() {
|
||||
override fun buildExcludedSelector(dirs: List<ExcludedDirectory>): Selector {
|
||||
open class Api29MediaStoreBackend : Api21MediaStoreBackend() {
|
||||
private var volumeIndex = -1
|
||||
private var relativePathIndex = -1
|
||||
|
||||
override val projection: Array<String>
|
||||
get() =
|
||||
super.projection +
|
||||
arrayOf(
|
||||
MediaStore.Audio.AudioColumns.VOLUME_NAME,
|
||||
MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
||||
|
||||
override fun buildExcludedSelector(dirs: List<Dir.Relative>): Selector {
|
||||
var selector = BASE_SELECTOR
|
||||
val args = mutableListOf<String>()
|
||||
|
||||
|
@ -410,8 +450,8 @@ open class Api29MediaStoreBackend : MediaStoreBackend() {
|
|||
// 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()
|
||||
is Dir.Volume.Primary -> MediaStore.VOLUME_EXTERNAL_PRIMARY
|
||||
is Dir.Volume.Secondary -> dir.volume.name.lowercase()
|
||||
}
|
||||
|
||||
args += "${dir.relativePath}%"
|
||||
|
@ -419,6 +459,32 @@ open class Api29MediaStoreBackend : MediaStoreBackend() {
|
|||
|
||||
return Selector(selector, args)
|
||||
}
|
||||
|
||||
override fun buildAudio(context: Context, cursor: Cursor): Audio {
|
||||
val audio = super.buildAudio(context, cursor)
|
||||
|
||||
if (volumeIndex == -1) {
|
||||
volumeIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.VOLUME_NAME)
|
||||
relativePathIndex =
|
||||
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.RELATIVE_PATH)
|
||||
}
|
||||
|
||||
val volume = cursor.getStringOrNull(volumeIndex)
|
||||
val relativePath = cursor.getStringOrNull(relativePathIndex)
|
||||
|
||||
if (volume != null && relativePath != null) {
|
||||
audio.dir =
|
||||
Dir.Relative(
|
||||
volume =
|
||||
when (volume) {
|
||||
MediaStore.VOLUME_EXTERNAL_PRIMARY -> Dir.Volume.Primary
|
||||
else -> Dir.Volume.Secondary(volume)
|
||||
},
|
||||
relativePath = relativePath)
|
||||
}
|
||||
|
||||
return audio
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -19,9 +19,11 @@ package org.oxycblt.auxio.music.excluded
|
|||
|
||||
import android.content.Context
|
||||
import org.oxycblt.auxio.databinding.ItemExcludedDirBinding
|
||||
import org.oxycblt.auxio.music.Dir
|
||||
import org.oxycblt.auxio.ui.BackingData
|
||||
import org.oxycblt.auxio.ui.BindingViewHolder
|
||||
import org.oxycblt.auxio.ui.MonoAdapter
|
||||
import org.oxycblt.auxio.util.context
|
||||
import org.oxycblt.auxio.util.inflater
|
||||
import org.oxycblt.auxio.util.textSafe
|
||||
|
||||
|
@ -30,23 +32,22 @@ import org.oxycblt.auxio.util.textSafe
|
|||
* @author OxygenCobalt
|
||||
*/
|
||||
class ExcludedAdapter(listener: Listener) :
|
||||
MonoAdapter<ExcludedDirectory, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) {
|
||||
MonoAdapter<Dir.Relative, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) {
|
||||
override val data = ExcludedBackingData(this)
|
||||
override val creator = ExcludedViewHolder.CREATOR
|
||||
|
||||
interface Listener {
|
||||
fun onRemoveDirectory(dir: ExcludedDirectory)
|
||||
fun onRemoveDirectory(dir: Dir.Relative)
|
||||
}
|
||||
|
||||
class ExcludedBackingData(private val adapter: ExcludedAdapter) :
|
||||
BackingData<ExcludedDirectory>() {
|
||||
private val _currentList = mutableListOf<ExcludedDirectory>()
|
||||
val currentList: List<ExcludedDirectory> = _currentList
|
||||
class ExcludedBackingData(private val adapter: ExcludedAdapter) : BackingData<Dir.Relative>() {
|
||||
private val _currentList = mutableListOf<Dir.Relative>()
|
||||
val currentList: List<Dir.Relative> = _currentList
|
||||
|
||||
override fun getItemCount(): Int = _currentList.size
|
||||
override fun getItem(position: Int): ExcludedDirectory = _currentList[position]
|
||||
override fun getItem(position: Int): Dir.Relative = _currentList[position]
|
||||
|
||||
fun add(dir: ExcludedDirectory) {
|
||||
fun add(dir: Dir.Relative) {
|
||||
if (_currentList.contains(dir)) {
|
||||
return
|
||||
}
|
||||
|
@ -55,13 +56,13 @@ class ExcludedAdapter(listener: Listener) :
|
|||
adapter.notifyItemInserted(_currentList.lastIndex)
|
||||
}
|
||||
|
||||
fun addAll(dirs: List<ExcludedDirectory>) {
|
||||
fun addAll(dirs: List<Dir.Relative>) {
|
||||
val oldLastIndex = dirs.lastIndex
|
||||
_currentList.addAll(dirs)
|
||||
adapter.notifyItemRangeInserted(oldLastIndex, dirs.size)
|
||||
}
|
||||
|
||||
fun remove(dir: ExcludedDirectory) {
|
||||
fun remove(dir: Dir.Relative) {
|
||||
val idx = _currentList.indexOf(dir)
|
||||
_currentList.removeAt(idx)
|
||||
adapter.notifyItemRemoved(idx)
|
||||
|
@ -71,9 +72,9 @@ class ExcludedAdapter(listener: Listener) :
|
|||
|
||||
/** The viewholder for [ExcludedAdapter]. Not intended for use in other adapters. */
|
||||
class ExcludedViewHolder private constructor(private val binding: ItemExcludedDirBinding) :
|
||||
BindingViewHolder<ExcludedDirectory, ExcludedAdapter.Listener>(binding.root) {
|
||||
override fun bind(item: ExcludedDirectory, listener: ExcludedAdapter.Listener) {
|
||||
binding.excludedPath.textSafe = item.toString()
|
||||
BindingViewHolder<Dir.Relative, ExcludedAdapter.Listener>(binding.root) {
|
||||
override fun bind(item: Dir.Relative, listener: ExcludedAdapter.Listener) {
|
||||
binding.excludedPath.textSafe = item.resolveName(binding.context)
|
||||
binding.excludedClear.setOnClickListener { listener.onRemoveDirectory(item) }
|
||||
}
|
||||
|
||||
|
|
|
@ -29,6 +29,7 @@ import kotlinx.coroutines.delay
|
|||
import org.oxycblt.auxio.BuildConfig
|
||||
import org.oxycblt.auxio.R
|
||||
import org.oxycblt.auxio.databinding.DialogExcludedBinding
|
||||
import org.oxycblt.auxio.music.Dir
|
||||
import org.oxycblt.auxio.playback.PlaybackViewModel
|
||||
import org.oxycblt.auxio.settings.SettingsManager
|
||||
import org.oxycblt.auxio.ui.ViewBindingDialogFragment
|
||||
|
@ -94,7 +95,7 @@ class ExcludedDialog :
|
|||
val dirs =
|
||||
savedInstanceState
|
||||
?.getStringArrayList(KEY_PENDING_DIRS)
|
||||
?.mapNotNull(ExcludedDirectory::fromString)
|
||||
?.mapNotNull(ExcludedDirectories::fromString)
|
||||
?: settingsManager.excludedDirs
|
||||
|
||||
excludedAdapter.data.addAll(dirs)
|
||||
|
@ -112,7 +113,7 @@ class ExcludedDialog :
|
|||
binding.excludedRecycler.adapter = null
|
||||
}
|
||||
|
||||
override fun onRemoveDirectory(dir: ExcludedDirectory) {
|
||||
override fun onRemoveDirectory(dir: Dir.Relative) {
|
||||
excludedAdapter.data.remove(dir)
|
||||
requireBinding().excludedEmpty.isVisible = excludedAdapter.data.currentList.isEmpty()
|
||||
}
|
||||
|
@ -133,7 +134,7 @@ class ExcludedDialog :
|
|||
}
|
||||
}
|
||||
|
||||
private fun parseExcludedUri(uri: Uri): ExcludedDirectory? {
|
||||
private fun parseExcludedUri(uri: Uri): Dir.Relative? {
|
||||
// Turn the raw URI into a document tree URI
|
||||
val docUri =
|
||||
DocumentsContract.buildDocumentUriUsingTree(
|
||||
|
@ -142,8 +143,8 @@ class ExcludedDialog :
|
|||
// Turn it into a semi-usable path
|
||||
val treeUri = DocumentsContract.getTreeDocumentId(docUri)
|
||||
|
||||
// ExcludedDirectory handles the rest
|
||||
return ExcludedDirectory.fromString(treeUri)
|
||||
// Parsing handles the rest
|
||||
return ExcludedDirectories.fromString(treeUri)
|
||||
}
|
||||
|
||||
private fun saveAndRestart() {
|
||||
|
|
|
@ -0,0 +1,64 @@
|
|||
/*
|
||||
* 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 java.io.File
|
||||
import org.oxycblt.auxio.music.Dir
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
object ExcludedDirectories {
|
||||
private const val VOLUME_PRIMARY_NAME = "primary"
|
||||
|
||||
fun fromString(dir: String): Dir.Relative? {
|
||||
logD("Parse from string $dir")
|
||||
|
||||
val split = dir.split(File.pathSeparator, limit = 2)
|
||||
|
||||
val volume =
|
||||
when (split[0]) {
|
||||
VOLUME_PRIMARY_NAME -> Dir.Volume.Primary
|
||||
else ->
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
Dir.Volume.Secondary(split[0])
|
||||
} else {
|
||||
// While Android Q provides a stable way of accessing volumes, we can't
|
||||
// trust
|
||||
// that DATA provides a stable volume scheme on older versions, so external
|
||||
// volumes are not supported.
|
||||
logW("Cannot use secondary volumes below Android 10")
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
val relativePath = split.getOrNull(1) ?: return null
|
||||
|
||||
return Dir.Relative(volume, relativePath)
|
||||
}
|
||||
|
||||
fun toString(dir: Dir.Relative): String {
|
||||
val volume =
|
||||
when (dir.volume) {
|
||||
is Dir.Volume.Primary -> VOLUME_PRIMARY_NAME
|
||||
is Dir.Volume.Secondary -> dir.volume.name
|
||||
}
|
||||
|
||||
return "${volume}:${dir.relativePath}"
|
||||
}
|
||||
}
|
|
@ -1,71 +0,0 @@
|
|||
/*
|
||||
* 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 java.io.File
|
||||
import org.oxycblt.auxio.util.logW
|
||||
|
||||
/**
|
||||
* Represents a directory excluded from the music loading process. This is a in-code representation
|
||||
* of a typical document tree URI scheme, designed to not only provide support for external volumes,
|
||||
* but also provide it in a way compatible with older android versions.
|
||||
* @author OxygenCobalt
|
||||
*/
|
||||
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 {
|
||||
fun fromString(dir: String): ExcludedDirectory? {
|
||||
val split = dir.split(File.pathSeparator, limit = 2)
|
||||
|
||||
val volume = Volume.fromString(split.getOrNull(0) ?: return null)
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q && volume is Volume.Secondary) {
|
||||
// While Android Q provides a stable way of accessing volumes, we can't trust
|
||||
// that DATA provides a stable volume scheme on older versions, so external
|
||||
// volumes are not supported.
|
||||
logW("Cannot use secondary volumes below API 29")
|
||||
return null
|
||||
}
|
||||
|
||||
val relativePath = split.getOrNull(1) ?: return null
|
||||
|
||||
return ExcludedDirectory(volume, relativePath)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -25,7 +25,7 @@ import android.os.Build
|
|||
import android.os.Environment
|
||||
import androidx.core.content.edit
|
||||
import java.io.File
|
||||
import org.oxycblt.auxio.music.excluded.ExcludedDirectory
|
||||
import org.oxycblt.auxio.music.Dir
|
||||
import org.oxycblt.auxio.ui.accent.Accent
|
||||
import org.oxycblt.auxio.util.logD
|
||||
import org.oxycblt.auxio.util.queryAll
|
||||
|
@ -79,7 +79,7 @@ fun handleAccentCompat(prefs: SharedPreferences): Accent {
|
|||
}
|
||||
|
||||
/**
|
||||
* Converts paths from the old excluded directory database to a list of modern [ExcludedDirectory]
|
||||
* Converts paths from the old excluded directory database to a list of modern [Dir.Relative]
|
||||
* instances.
|
||||
*
|
||||
* Historically, Auxio used an excluded directory database shamelessly ripped from Phonograph. This
|
||||
|
@ -90,12 +90,12 @@ fun handleAccentCompat(prefs: SharedPreferences): Accent {
|
|||
* 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> {
|
||||
fun handleExcludedCompat(context: Context): List<Dir.Relative> {
|
||||
val db = LegacyExcludedDatabase(context)
|
||||
val primaryPrefix = Environment.getExternalStorageDirectory().absolutePath + File.separatorChar
|
||||
return db.readPaths().map { path ->
|
||||
val relativePath = path.removePrefix(primaryPrefix)
|
||||
ExcludedDirectory(ExcludedDirectory.Volume.Primary, relativePath)
|
||||
Dir.Relative(Dir.Volume.Primary, relativePath)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,8 @@ 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.music.Dir
|
||||
import org.oxycblt.auxio.music.excluded.ExcludedDirectories
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainMode
|
||||
import org.oxycblt.auxio.playback.replaygain.ReplayGainPreAmp
|
||||
import org.oxycblt.auxio.playback.state.PlaybackMode
|
||||
|
@ -138,13 +139,13 @@ class SettingsManager private constructor(context: Context) :
|
|||
get() = inner.getBoolean(KEY_PAUSE_ON_REPEAT, false)
|
||||
|
||||
/** The list of directories excluded from indexing. */
|
||||
var excludedDirs: List<ExcludedDirectory>
|
||||
var excludedDirs: List<Dir.Relative>
|
||||
get() =
|
||||
(inner.getStringSet(KEY_EXCLUDED, null) ?: emptySet()).mapNotNull(
|
||||
ExcludedDirectory::fromString)
|
||||
ExcludedDirectories::fromString)
|
||||
set(value) {
|
||||
inner.edit {
|
||||
putStringSet(KEY_EXCLUDED, value.map { it.toString() }.toSet())
|
||||
putStringSet(KEY_EXCLUDED, value.map(ExcludedDirectories::toString).toSet())
|
||||
apply()
|
||||
}
|
||||
}
|
||||
|
|
|
@ -42,7 +42,7 @@
|
|||
app:expandedHintEnabled="false">
|
||||
|
||||
<org.oxycblt.auxio.detail.ReadOnlyTextInput
|
||||
android:id="@+id/detail_relative_path"
|
||||
android:id="@+id/detail_relative_dir"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
tools:text="/path/to" />
|
||||
|
|
|
@ -7,4 +7,8 @@
|
|||
<string name="fmt_two" translatable="false">%1$s • %2$s</string>
|
||||
<string name="fmt_three" translatable="false">%1$s • %2$s • %3$s</string>
|
||||
<string name="fmt_number" translatable="false">%d</string>
|
||||
|
||||
<!-- Note: These are stopgap measures until we make the path code rely on android components! -->
|
||||
<string name="fmt_primary_path">Internal:%s</string>
|
||||
<string name="fmt_secondary_path">SDCARD:%s</string>
|
||||
</resources>
|
|
@ -169,6 +169,7 @@
|
|||
<string name="def_date">No Date</string>
|
||||
<string name="def_track">No Track Number</string>
|
||||
<string name="def_playback">No music playing</string>
|
||||
<string name="def_format">Unknown Format</string>
|
||||
<string name="def_bitrate">No Bitrate</string>
|
||||
<string name="def_sample_rate">No Sample Rate</string>
|
||||
<string name="def_widget_song">Song Name</string>
|
||||
|
|
Loading…
Reference in a new issue