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 package org.oxycblt.auxio.detail
import android.content.Context import android.content.Context
import android.graphics.Color
import android.graphics.drawable.Drawable
import android.graphics.drawable.RippleDrawable
import android.os.Build import android.os.Build
import android.text.method.MovementMethod import android.text.method.MovementMethod
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.annotation.AttrRes 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 com.google.android.material.textfield.TextInputEditText
import org.oxycblt.auxio.R
import org.oxycblt.auxio.util.getAttrStateListSafe
class ReadOnlyTextInput : TextInputEditText { class ReadOnlyTextInput : TextInputEditText {
constructor(context: Context) : super(context) constructor(context: Context) : super(context)

View file

@ -19,6 +19,7 @@ package org.oxycblt.auxio.detail
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.webkit.MimeTypeMap
import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AlertDialog
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.fragment.app.activityViewModels import androidx.fragment.app.activityViewModels
@ -31,6 +32,7 @@ import org.oxycblt.auxio.util.launch
class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() { class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
private val detailModel: DetailViewModel by activityViewModels() private val detailModel: DetailViewModel by activityViewModels()
private val mimeTypes = MimeTypeMap.getSingleton()
override fun onCreateBinding(inflater: LayoutInflater) = override fun onCreateBinding(inflater: LayoutInflater) =
DialogSongDetailBinding.inflate(inflater) DialogSongDetailBinding.inflate(inflater)
@ -51,7 +53,6 @@ class SongDetailDialog : ViewBindingDialogFragment<DialogSongDetailBinding>() {
if (song != null) { if (song != null) {
binding.detailContainer.isGone = false binding.detailContainer.isGone = false
binding.detailFileName.setText(song.song.fileName)
if (song.bitrateKbps != null) { if (song.bitrateKbps != null) {
binding.detailBitrate.setText(getString(R.string.fmt_bitrate, song.bitrateKbps)) 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) staticIcon = styledAttrs.getResourceId(R.styleable.StyledImageView_staticIcon, -1)
styledAttrs.recycle() styledAttrs.recycle()
if (staticIcon > -1) {
@Suppress("LeakingThis")
setImageDrawable(StyledDrawable(context, context.getDrawableSafe(staticIcon)))
}
background = background =
MaterialShapeDrawable().apply { MaterialShapeDrawable().apply {
fillColor = context.getColorStateListSafe(R.color.sel_cover_bg) fillColor = context.getColorStateListSafe(R.color.sel_cover_bg)

View file

@ -59,10 +59,14 @@ sealed class MusicParent : Music() {
/** The data object for a song. */ /** The data object for a song. */
data class Song( data class Song(
override val rawName: String, override val rawName: String,
/** The file name of this song, excluding the full path. */ /** The path of this song. */
val fileName: String, val path: Path,
/** The URI linking to this song's file. */ /** The URI linking to this song's file. */
val uri: Uri, 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. */ /** The total duration of this song, in millis. */
val durationMs: Long, val durationMs: Long,
/** The track number of this song, null if there isn't any. */ /** The track number of this song, null if there isn't any. */

View file

@ -75,7 +75,7 @@ class MusicStore private constructor() {
val displayName = val displayName =
cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) 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. */ /** "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.getIntOrNull
import androidx.core.database.getStringOrNull import androidx.core.database.getStringOrNull
import java.io.File import java.io.File
import org.oxycblt.auxio.music.Dir
import org.oxycblt.auxio.music.Indexer import org.oxycblt.auxio.music.Indexer
import org.oxycblt.auxio.music.Path
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.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
@ -97,6 +98,8 @@ import org.oxycblt.auxio.util.logW
* I wish I was born in the neolithic. * 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 * 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. * 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 idIndex = -1
private var titleIndex = -1 private var titleIndex = -1
private var displayNameIndex = -1 private var displayNameIndex = -1
private var mimeTypeIndex = -1
private var sizeIndex = -1
private var durationIndex = -1 private var durationIndex = -1
private var yearIndex = -1 private var yearIndex = -1
private var albumIndex = -1 private var albumIndex = -1
@ -181,7 +186,7 @@ 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 abstract fun buildExcludedSelector(dirs: List<Dir.Relative>): 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
@ -195,6 +200,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE) titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
displayNameIndex = displayNameIndex =
cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME) 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) durationIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR) yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM) albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
@ -209,16 +216,14 @@ abstract class MediaStoreBackend : Indexer.Backend {
audio.id = cursor.getLong(idIndex) audio.id = cursor.getLong(idIndex)
audio.title = cursor.getString(titleIndex) 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 // 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 // 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 // this field isn't available on some platforms. In that case, we have to rely
// on DATA to get a reasonable file name. // on DATA to get a reasonable file name.
audio.displayName = audio.displayName = cursor.getStringOrNull(displayNameIndex)
cursor.getStringOrNull(displayNameIndex)
?: cursor
.getStringOrNull(dataIndex)
?.substringAfterLast(File.separatorChar, MediaStore.UNKNOWN_STRING)
?: MediaStore.UNKNOWN_STRING
audio.duration = cursor.getLong(durationIndex) audio.duration = cursor.getLong(durationIndex)
audio.year = cursor.getIntOrNull(yearIndex) audio.year = cursor.getIntOrNull(yearIndex)
@ -260,6 +265,9 @@ abstract class MediaStoreBackend : Indexer.Backend {
var id: Long? = null, var id: Long? = null,
var title: String? = null, var title: String? = null,
var displayName: String? = null, var displayName: String? = null,
var dir: Dir? = null,
var mimeType: String? = null,
var size: Long? = null,
var duration: Long? = null, var duration: Long? = null,
var track: Int? = null, var track: Int? = null,
var disc: Int? = null, var disc: Int? = null,
@ -270,13 +278,18 @@ abstract class MediaStoreBackend : Indexer.Backend {
var albumArtist: String? = null, var albumArtist: String? = null,
var genre: String? = null var genre: String? = null
) { ) {
fun toSong(): Song = fun toSong(): Song {
Song( return Song(
// Assert that the fields that should exist are present. I can't confirm that // 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. // every device provides these fields, but it seems likely that they do.
rawName = requireNotNull(title) { "Malformed audio: No title" }, 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, 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" }, durationMs = requireNotNull(duration) { "Malformed audio: No duration" },
track = track, track = track,
disc = disc, disc = disc,
@ -287,6 +300,7 @@ abstract class MediaStoreBackend : Indexer.Backend {
_artistName = artist, _artistName = artist,
_albumArtistName = albumArtist, _albumArtistName = albumArtist,
_genreName = genre) _genreName = genre)
}
} }
companion object { companion object {
@ -314,6 +328,8 @@ abstract class MediaStoreBackend : Indexer.Backend {
MediaStore.Audio.AudioColumns._ID, MediaStore.Audio.AudioColumns._ID,
MediaStore.Audio.AudioColumns.TITLE, MediaStore.Audio.AudioColumns.TITLE,
MediaStore.Audio.AudioColumns.DISPLAY_NAME, MediaStore.Audio.AudioColumns.DISPLAY_NAME,
MediaStore.Audio.AudioColumns.MIME_TYPE,
MediaStore.Audio.AudioColumns.SIZE,
MediaStore.Audio.AudioColumns.DURATION, MediaStore.Audio.AudioColumns.DURATION,
MediaStore.Audio.AudioColumns.YEAR, MediaStore.Audio.AudioColumns.YEAR,
MediaStore.Audio.AudioColumns.ALBUM, 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 * A [MediaStoreBackend] that completes the music loading process in a way compatible from
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class Api21MediaStoreBackend : MediaStoreBackend() { open class Api21MediaStoreBackend : MediaStoreBackend() {
private var trackIndex = -1 private var trackIndex = -1
private var dataIndex = -1
override val projection: Array<String> override val projection: Array<String>
get() = get() =
super.projection + super.projection +
arrayOf(MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATA) 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 val base = Environment.getExternalStorageDirectory().absolutePath
var selector = BASE_SELECTOR var selector = BASE_SELECTOR
val args = mutableListOf<String>() val args = mutableListOf<String>()
// Apply the excluded directories by filtering out specific DATA values. // Apply the excluded directories by filtering out specific DATA values.
for (dir in dirs) { 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") logW("Cannot exclude directories on secondary drives")
continue continue
} }
@ -364,9 +381,10 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
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)
// Initialize the TRACK index if we have not already. // Initialize our indices if we have not already.
if (trackIndex == -1) { if (trackIndex == -1) {
trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK) 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. // 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 return audio
} }
} }
@ -393,8 +423,18 @@ class Api21MediaStoreBackend : MediaStoreBackend() {
* @author OxygenCobalt * @author OxygenCobalt
*/ */
@RequiresApi(Build.VERSION_CODES.Q) @RequiresApi(Build.VERSION_CODES.Q)
open class Api29MediaStoreBackend : MediaStoreBackend() { open class Api29MediaStoreBackend : Api21MediaStoreBackend() {
override fun buildExcludedSelector(dirs: List<ExcludedDirectory>): Selector { 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 var selector = BASE_SELECTOR
val args = mutableListOf<String>() 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. // name stored in-app. I have no idea how well this holds up on other devices.
args += args +=
when (dir.volume) { when (dir.volume) {
is ExcludedDirectory.Volume.Primary -> MediaStore.VOLUME_EXTERNAL_PRIMARY is Dir.Volume.Primary -> MediaStore.VOLUME_EXTERNAL_PRIMARY
is ExcludedDirectory.Volume.Secondary -> dir.volume.name.lowercase() is Dir.Volume.Secondary -> dir.volume.name.lowercase()
} }
args += "${dir.relativePath}%" args += "${dir.relativePath}%"
@ -419,6 +459,32 @@ open class Api29MediaStoreBackend : MediaStoreBackend() {
return Selector(selector, args) 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 android.content.Context
import org.oxycblt.auxio.databinding.ItemExcludedDirBinding import org.oxycblt.auxio.databinding.ItemExcludedDirBinding
import org.oxycblt.auxio.music.Dir
import org.oxycblt.auxio.ui.BackingData import org.oxycblt.auxio.ui.BackingData
import org.oxycblt.auxio.ui.BindingViewHolder import org.oxycblt.auxio.ui.BindingViewHolder
import org.oxycblt.auxio.ui.MonoAdapter import org.oxycblt.auxio.ui.MonoAdapter
import org.oxycblt.auxio.util.context
import org.oxycblt.auxio.util.inflater import org.oxycblt.auxio.util.inflater
import org.oxycblt.auxio.util.textSafe import org.oxycblt.auxio.util.textSafe
@ -30,23 +32,22 @@ import org.oxycblt.auxio.util.textSafe
* @author OxygenCobalt * @author OxygenCobalt
*/ */
class ExcludedAdapter(listener: Listener) : class ExcludedAdapter(listener: Listener) :
MonoAdapter<ExcludedDirectory, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) { MonoAdapter<Dir.Relative, ExcludedAdapter.Listener, ExcludedViewHolder>(listener) {
override val data = ExcludedBackingData(this) override val data = ExcludedBackingData(this)
override val creator = ExcludedViewHolder.CREATOR override val creator = ExcludedViewHolder.CREATOR
interface Listener { interface Listener {
fun onRemoveDirectory(dir: ExcludedDirectory) fun onRemoveDirectory(dir: Dir.Relative)
} }
class ExcludedBackingData(private val adapter: ExcludedAdapter) : class ExcludedBackingData(private val adapter: ExcludedAdapter) : BackingData<Dir.Relative>() {
BackingData<ExcludedDirectory>() { private val _currentList = mutableListOf<Dir.Relative>()
private val _currentList = mutableListOf<ExcludedDirectory>() val currentList: List<Dir.Relative> = _currentList
val currentList: List<ExcludedDirectory> = _currentList
override fun getItemCount(): Int = _currentList.size 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)) { if (_currentList.contains(dir)) {
return return
} }
@ -55,13 +56,13 @@ class ExcludedAdapter(listener: Listener) :
adapter.notifyItemInserted(_currentList.lastIndex) adapter.notifyItemInserted(_currentList.lastIndex)
} }
fun addAll(dirs: List<ExcludedDirectory>) { fun addAll(dirs: List<Dir.Relative>) {
val oldLastIndex = dirs.lastIndex val oldLastIndex = dirs.lastIndex
_currentList.addAll(dirs) _currentList.addAll(dirs)
adapter.notifyItemRangeInserted(oldLastIndex, dirs.size) adapter.notifyItemRangeInserted(oldLastIndex, dirs.size)
} }
fun remove(dir: ExcludedDirectory) { fun remove(dir: Dir.Relative) {
val idx = _currentList.indexOf(dir) val idx = _currentList.indexOf(dir)
_currentList.removeAt(idx) _currentList.removeAt(idx)
adapter.notifyItemRemoved(idx) adapter.notifyItemRemoved(idx)
@ -71,9 +72,9 @@ class ExcludedAdapter(listener: Listener) :
/** 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<ExcludedDirectory, ExcludedAdapter.Listener>(binding.root) { BindingViewHolder<Dir.Relative, ExcludedAdapter.Listener>(binding.root) {
override fun bind(item: ExcludedDirectory, listener: ExcludedAdapter.Listener) { override fun bind(item: Dir.Relative, listener: ExcludedAdapter.Listener) {
binding.excludedPath.textSafe = item.toString() binding.excludedPath.textSafe = item.resolveName(binding.context)
binding.excludedClear.setOnClickListener { listener.onRemoveDirectory(item) } 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.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.music.Dir
import org.oxycblt.auxio.playback.PlaybackViewModel import org.oxycblt.auxio.playback.PlaybackViewModel
import org.oxycblt.auxio.settings.SettingsManager import org.oxycblt.auxio.settings.SettingsManager
import org.oxycblt.auxio.ui.ViewBindingDialogFragment import org.oxycblt.auxio.ui.ViewBindingDialogFragment
@ -94,7 +95,7 @@ class ExcludedDialog :
val dirs = val dirs =
savedInstanceState savedInstanceState
?.getStringArrayList(KEY_PENDING_DIRS) ?.getStringArrayList(KEY_PENDING_DIRS)
?.mapNotNull(ExcludedDirectory::fromString) ?.mapNotNull(ExcludedDirectories::fromString)
?: settingsManager.excludedDirs ?: settingsManager.excludedDirs
excludedAdapter.data.addAll(dirs) excludedAdapter.data.addAll(dirs)
@ -112,7 +113,7 @@ class ExcludedDialog :
binding.excludedRecycler.adapter = null binding.excludedRecycler.adapter = null
} }
override fun onRemoveDirectory(dir: ExcludedDirectory) { override fun onRemoveDirectory(dir: Dir.Relative) {
excludedAdapter.data.remove(dir) excludedAdapter.data.remove(dir)
requireBinding().excludedEmpty.isVisible = excludedAdapter.data.currentList.isEmpty() 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 // Turn the raw URI into a document tree URI
val docUri = val docUri =
DocumentsContract.buildDocumentUriUsingTree( DocumentsContract.buildDocumentUriUsingTree(
@ -142,8 +143,8 @@ class ExcludedDialog :
// Turn it into a semi-usable path // Turn it into a semi-usable path
val treeUri = DocumentsContract.getTreeDocumentId(docUri) val treeUri = DocumentsContract.getTreeDocumentId(docUri)
// ExcludedDirectory handles the rest // Parsing handles the rest
return ExcludedDirectory.fromString(treeUri) return ExcludedDirectories.fromString(treeUri)
} }
private fun saveAndRestart() { 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 android.os.Environment
import androidx.core.content.edit import androidx.core.content.edit
import java.io.File 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.ui.accent.Accent
import org.oxycblt.auxio.util.logD import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.queryAll 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. * instances.
* *
* Historically, Auxio used an excluded directory database shamelessly ripped from Phonograph. This * 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 * path-based excluded system to a volume-based excluded system at the same time. These are both
* rolled into this conversion. * rolled into this conversion.
*/ */
fun handleExcludedCompat(context: Context): List<ExcludedDirectory> { fun handleExcludedCompat(context: Context): List<Dir.Relative> {
val db = LegacyExcludedDatabase(context) val db = LegacyExcludedDatabase(context)
val primaryPrefix = Environment.getExternalStorageDirectory().absolutePath + File.separatorChar val primaryPrefix = Environment.getExternalStorageDirectory().absolutePath + File.separatorChar
return db.readPaths().map { path -> return db.readPaths().map { path ->
val relativePath = path.removePrefix(primaryPrefix) 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.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.music.Dir
import org.oxycblt.auxio.music.excluded.ExcludedDirectories
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
@ -138,13 +139,13 @@ class SettingsManager private constructor(context: Context) :
get() = inner.getBoolean(KEY_PAUSE_ON_REPEAT, false) get() = inner.getBoolean(KEY_PAUSE_ON_REPEAT, false)
/** The list of directories excluded from indexing. */ /** The list of directories excluded from indexing. */
var excludedDirs: List<ExcludedDirectory> var excludedDirs: List<Dir.Relative>
get() = get() =
(inner.getStringSet(KEY_EXCLUDED, null) ?: emptySet()).mapNotNull( (inner.getStringSet(KEY_EXCLUDED, null) ?: emptySet()).mapNotNull(
ExcludedDirectory::fromString) ExcludedDirectories::fromString)
set(value) { set(value) {
inner.edit { inner.edit {
putStringSet(KEY_EXCLUDED, value.map { it.toString() }.toSet()) putStringSet(KEY_EXCLUDED, value.map(ExcludedDirectories::toString).toSet())
apply() apply()
} }
} }

View file

@ -42,7 +42,7 @@
app:expandedHintEnabled="false"> app:expandedHintEnabled="false">
<org.oxycblt.auxio.detail.ReadOnlyTextInput <org.oxycblt.auxio.detail.ReadOnlyTextInput
android:id="@+id/detail_relative_path" android:id="@+id/detail_relative_dir"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
tools:text="/path/to" /> tools:text="/path/to" />

View file

@ -7,4 +7,8 @@
<string name="fmt_two" translatable="false">%1$s • %2$s</string> <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_three" translatable="false">%1$s • %2$s • %3$s</string>
<string name="fmt_number" translatable="false">%d</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> </resources>

View file

@ -169,6 +169,7 @@
<string name="def_date">No Date</string> <string name="def_date">No Date</string>
<string name="def_track">No Track Number</string> <string name="def_track">No Track Number</string>
<string name="def_playback">No music playing</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_bitrate">No Bitrate</string>
<string name="def_sample_rate">No Sample Rate</string> <string name="def_sample_rate">No Sample Rate</string>
<string name="def_widget_song">Song Name</string> <string name="def_widget_song">Song Name</string>