diff --git a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt index e9b87567c..a9dadf38e 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/ReadOnlyTextInput.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt index d5b468bbb..f28f9b9fd 100644 --- a/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/detail/SongDetailDialog.kt @@ -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() { 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() { 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)) diff --git a/app/src/main/java/org/oxycblt/auxio/image/BaseStyledImageView.kt b/app/src/main/java/org/oxycblt/auxio/image/BaseStyledImageView.kt index f5d64307f..7d8e800ad 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/BaseStyledImageView.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/BaseStyledImageView.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/music/Music.kt b/app/src/main/java/org/oxycblt/auxio/music/Music.kt index 40ee1aaa9..62b0766d3 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt @@ -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. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt index 30f71b6bf..db6bd855a 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt @@ -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. */ diff --git a/app/src/main/java/org/oxycblt/auxio/music/PathFramework.kt b/app/src/main/java/org/oxycblt/auxio/music/PathFramework.kt new file mode 100644 index 000000000..df8a57aee --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/PathFramework.kt @@ -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 . + */ + +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) + } + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt index 849af3987..717e158b0 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/backend/MediaStoreBackend.kt @@ -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 get() = BASE_PROJECTION - abstract fun buildExcludedSelector(dirs: List): Selector + abstract fun buildExcludedSelector(dirs: List): 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 get() = super.projection + arrayOf(MediaStore.Audio.AudioColumns.TRACK, MediaStore.Audio.AudioColumns.DATA) - override fun buildExcludedSelector(dirs: List): Selector { + override fun buildExcludedSelector(dirs: List): Selector { val base = Environment.getExternalStorageDirectory().absolutePath var selector = BASE_SELECTOR val args = mutableListOf() // Apply the excluded directories by filtering out specific DATA values. for (dir in dirs) { - if (dir.volume is ExcludedDirectory.Volume.Secondary) { + 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): Selector { +open class Api29MediaStoreBackend : Api21MediaStoreBackend() { + private var volumeIndex = -1 + private var relativePathIndex = -1 + + override val projection: Array + get() = + super.projection + + arrayOf( + MediaStore.Audio.AudioColumns.VOLUME_NAME, + MediaStore.Audio.AudioColumns.RELATIVE_PATH) + + override fun buildExcludedSelector(dirs: List): Selector { var selector = BASE_SELECTOR val args = mutableListOf() @@ -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 + } } /** diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedAdapter.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedAdapter.kt index 9e84d14e8..4a07f485b 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedAdapter.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedAdapter.kt @@ -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(listener) { + MonoAdapter(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() { - private val _currentList = mutableListOf() - val currentList: List = _currentList + class ExcludedBackingData(private val adapter: ExcludedAdapter) : BackingData() { + private val _currentList = mutableListOf() + val currentList: List = _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) { + fun addAll(dirs: List) { 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(binding.root) { - override fun bind(item: ExcludedDirectory, listener: ExcludedAdapter.Listener) { - binding.excludedPath.textSafe = item.toString() + BindingViewHolder(binding.root) { + override fun bind(item: Dir.Relative, listener: ExcludedAdapter.Listener) { + binding.excludedPath.textSafe = item.resolveName(binding.context) binding.excludedClear.setOnClickListener { listener.onRemoveDirectory(item) } } diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt index 111945623..baa6a819c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDialog.kt @@ -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() { diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectories.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectories.kt new file mode 100644 index 000000000..64ab4c323 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectories.kt @@ -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 . + */ + +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}" + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectory.kt b/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectory.kt deleted file mode 100644 index 7b9a075ab..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/excluded/ExcludedDirectory.kt +++ /dev/null @@ -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 . - */ - -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) - } - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt index af14da91b..f679bee26 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsCompat.kt @@ -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 { +fun handleExcludedCompat(context: Context): List { 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) } } diff --git a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt index 16e506a02..ecf77469b 100644 --- a/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt +++ b/app/src/main/java/org/oxycblt/auxio/settings/SettingsManager.kt @@ -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 + var excludedDirs: List 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() } } diff --git a/app/src/main/res/layout/dialog_song_detail.xml b/app/src/main/res/layout/dialog_song_detail.xml index c9f0c3ce1..d58ff1ecf 100644 --- a/app/src/main/res/layout/dialog_song_detail.xml +++ b/app/src/main/res/layout/dialog_song_detail.xml @@ -42,7 +42,7 @@ app:expandedHintEnabled="false"> diff --git a/app/src/main/res/values/donottranslate.xml b/app/src/main/res/values/donottranslate.xml index b7f87867e..0db7cd3c8 100644 --- a/app/src/main/res/values/donottranslate.xml +++ b/app/src/main/res/values/donottranslate.xml @@ -7,4 +7,8 @@ %1$s • %2$s %1$s • %2$s • %3$s %d + + + Internal:%s + SDCARD:%s \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index c18a68c13..27f127a6a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -169,6 +169,7 @@ No Date No Track Number No music playing + Unknown Format No Bitrate No Sample Rate Song Name