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:
OxygenCobalt 2022-06-11 16:21:16 -06:00
parent 3f85678d99
commit 7373451912
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
16 changed files with 265 additions and 128 deletions

View file

@ -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)

View file

@ -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))

View file

@ -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)

View file

@ -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. */

View file

@ -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. */

View 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)
}
}
}

View file

@ -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
}
}
/**

View file

@ -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) }
}

View file

@ -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() {

View file

@ -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}"
}
}

View file

@ -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)
}
}
}

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -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" />

View file

@ -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>

View file

@ -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>