diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30bb7ce4c..64328c39f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -30,7 +30,7 @@
- Made the layout of album songs more similar to other songs
#### Dev/Meta
-- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese, Gsset -> Russian]
+- Updated translations [Konstantin Tutsch -> German, cccClyde -> Chinese, Gsset -> Russian, enricocid -> Italian]
- Switched to spotless and ktfmt instead of ktlint
- Migrated constants to centralized table
- Introduced new RecyclerView framework
diff --git a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
index 89b101a6b..863b256fd 100644
--- a/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
+++ b/app/src/main/java/org/oxycblt/auxio/IntegerTable.kt
@@ -53,7 +53,7 @@ object IntegerTable {
const val ITEM_TYPE_QUEUE_SONG = 0xA00E
/** "Music playback" Notification code */
- const val NOTIFICATION_CODE = 0xA0A0
+ const val PLAYBACK_NOTIFICATION_CODE = 0xA0A0
/** Intent request code */
const val REQUEST_CODE = 0xA0C0
diff --git a/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt
deleted file mode 100644
index e0c48033f..000000000
--- a/app/src/main/java/org/oxycblt/auxio/music/Indexer.kt
+++ /dev/null
@@ -1,523 +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
-
-import android.content.ContentUris
-import android.content.Context
-import android.database.Cursor
-import android.net.Uri
-import android.os.Build
-import android.provider.MediaStore
-import androidx.annotation.RequiresApi
-import androidx.core.database.getIntOrNull
-import androidx.core.database.getStringOrNull
-import org.oxycblt.auxio.music.excluded.ExcludedDatabase
-import org.oxycblt.auxio.ui.Sort
-import org.oxycblt.auxio.util.contentResolverSafe
-import org.oxycblt.auxio.util.logD
-
-/**
- * This class acts as the base for most the black magic required to get a remotely sensible music
- * indexing system while still optimizing for time. I would recommend you leave this module now
- * before you lose your sanity trying to understand the hoops I had to jump through for this system,
- * but if you really want to stay, here's a debrief on why this code is so awful.
- *
- * MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to
- * other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime
- * against humanity and probably a way to summon Zalgo if you look at it the wrong way.
- *
- * You think that if you wanted to query a song's genre from a media database, you could just put
- * "genre" in the query and it would return it, right? But not with MediaStore! No, that's too
- * straightforward for this contract that was dropped on it's head as a baby. So instead, you have
- * to query for each genre, query all the songs in each genre, and then iterate through those songs
- * to link every song with their genre. This is not documented anywhere, and the O(mom im scared)
- * algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no
- * point have the devs considered that this system is absolutely insane, and instead focused on
- * adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play
- * Music, and of course every Google Play Music user knew how great that turned out!
- *
- * It's not even ergonomics that makes this API bad. It's base implementation is completely borked
- * as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? I
- * sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see that
- * the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or DATE tag.
- * Once again, this is because internally android uses an ancient in-house metadata parser to get
- * everything indexed, and so far they have not bothered to modernize this parser or even switch it
- * to something more powerful like Taglib, not even in Android 12. ID3v2.4 has been around for *21
- * years.* *It can drink now.* All of my what.
- *
- * Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums
- * table, so we have to go for the less efficient "make a big query on all the songs lol" method so
- * that songs don't end up fragmented across artists. Pretty much every OEM has added some extension
- * or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the
- * normal tables so that you're railroaded into their music app. The way I do blacklisting relies on
- * a semi-deprecated method, and the supposedly "modern" method is SLOWER and causes even more
- * problems since I have to manage databases across version boundaries. Sometimes music will have a
- * deformed clone that I can't filter out, sometimes Genres will just break for no reason, and
- * sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 to *Shift
- * JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY
- *
- * Is there anything we can do about it? No. Google has routinely shut down issues that begged
- * google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
- * shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
- * listening is. As a result, some players like Vanilla and VLC just hack their own
- * pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for
- * Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much
- * harder it is to manage a database of your own media that mirrors the filesystem perfectly. And
- * even if I set aside those crippling issues and changed my indexer to that, it would face the even
- * larger problem of how google keeps trying to kill the filesystem and force you into their
- * ContentResolver API. In the future MediaStore could be the only system we have, which is also the
- * day that greenland melts and birthdays stop happening forever.
- *
- * I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
- * probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
- * Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
- * to your AlgoPop StreamMix™.
- *
- * I wish I was born in the neolithic.
- *
- * @author OxygenCobalt
- */
-object Indexer {
- /**
- * The album_artist MediaStore field has existed since at least API 21, but until API 30 it was
- * a proprietary extension for Google Play Music and was not documented. Since this field
- * probably works on all versions Auxio supports, we suppress the warning about using a
- * possibly-unsupported constant.
- */
- @Suppress("InlinedApi")
- private const val AUDIO_COLUMN_ALBUM_ARTIST = MediaStore.Audio.AudioColumns.ALBUM_ARTIST
-
- fun index(context: Context): MusicStore.Library? {
- // Establish the compatibility object to use when loading songs.
- val compat =
- if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
- Api30MediaStoreCompat()
- } else {
- Api21MediaStoreCompat()
- }
-
- val songs = loadSongs(context, compat)
- if (songs.isEmpty()) return null
-
- val albums = buildAlbums(songs)
- val artists = buildArtists(albums)
- val genres = readGenres(context, songs)
-
- // Sanity check: Ensure that all songs are linked up to albums/artists/genres.
- for (song in songs) {
- if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) {
- throw IllegalStateException(
- "Found malformed song: ${song.rawName} [" +
- "album: ${!song._isMissingAlbum} " +
- "artist: ${!song._isMissingArtist} " +
- "genre: ${!song._isMissingGenre}]")
- }
- }
-
- return MusicStore.Library(genres, artists, albums, songs)
- }
-
- /**
- * Does the initial query over the song database, including excluded directory checks. The songs
- * returned by this function are **not** well-formed. The companion [buildAlbums],
- * [buildArtists], and [readGenres] functions must be called with the returned list so that all
- * songs are properly linked up.
- */
- private fun loadSongs(context: Context, compat: MediaStoreCompat): List {
- val excludedDatabase = ExcludedDatabase.getInstance(context)
- var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1"
- val args = mutableListOf()
-
- // Apply the excluded directories by filtering out specific DATA values.
- // DATA was deprecated in Android 10, but it was un-deprecated in Android 12L,
- // so it's probably okay to use it. The only reason we would want to use
- // another method is for external partitions support, but there is no demand for that.
- for (path in excludedDatabase.readPaths()) {
- selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
- args += "$path%" // Append % so that the selector properly detects children
- }
-
- var songs = mutableListOf()
-
- // Establish the columns that work across all versions of android.
- val proj =
- mutableListOf(
- MediaStore.Audio.AudioColumns._ID,
- MediaStore.Audio.AudioColumns.TITLE,
- MediaStore.Audio.AudioColumns.DISPLAY_NAME,
- MediaStore.Audio.AudioColumns.DURATION,
- MediaStore.Audio.AudioColumns.YEAR,
- MediaStore.Audio.AudioColumns.ALBUM,
- MediaStore.Audio.AudioColumns.ALBUM_ID,
- MediaStore.Audio.AudioColumns.ARTIST,
- AUDIO_COLUMN_ALBUM_ARTIST,
- MediaStore.Audio.AudioColumns.DATA)
-
- // Get the compat impl to add their version-specific columns.
- compat.mutateAudioProjection(proj)
-
- context.contentResolverSafe
- .query(
- MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
- proj.toTypedArray(),
- selector,
- args.toTypedArray(),
- null)
- ?.use { cursor ->
- val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns._ID)
- val titleIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TITLE)
- val fileIndex =
- cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISPLAY_NAME)
- val durationIndex =
- cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DURATION)
- val yearIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.YEAR)
- val albumIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM)
- val albumIdIndex =
- cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ALBUM_ID)
- val artistIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.ARTIST)
- val albumArtistIndex = cursor.getColumnIndexOrThrow(AUDIO_COLUMN_ALBUM_ARTIST)
- val dataIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DATA)
-
- while (cursor.moveToNext()) {
- val raw = Audio()
-
- raw.id = cursor.getLong(idIndex)
- raw.title = cursor.getString(titleIndex)
-
- // 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, see if we can
- // grok a file name from the DATA field.
- raw.displayName =
- cursor.getStringOrNull(fileIndex)
- ?: cursor
- .getStringOrNull(dataIndex)
- ?.substringAfterLast('/', MediaStore.UNKNOWN_STRING)
- ?: MediaStore.UNKNOWN_STRING
-
- raw.duration = cursor.getLong(durationIndex)
- raw.year = cursor.getIntOrNull(yearIndex)
-
- raw.album = cursor.getStringOrNull(albumIndex)
- raw.albumId = cursor.getLong(albumIdIndex)
-
- // If the artist field is , make it null. This makes handling the
- // insanity of the artist field easier later on.
- raw.artist =
- cursor.getStringOrNull(artistIndex)?.run {
- if (this != MediaStore.UNKNOWN_STRING) {
- this
- } else {
- null
- }
- }
-
- raw.albumArtist = cursor.getStringOrNull(albumArtistIndex)
-
- // Allow the compatibility object to add their fields
- compat.populateAudio(cursor, raw)
-
- songs.add(raw.toSong())
- }
- }
-
- // Deduplicate songs to prevent (most) deformed music clones
- songs =
- songs
- .distinctBy {
- it.rawName to
- it._mediaStoreAlbumName to
- it._mediaStoreArtistName to
- it._mediaStoreAlbumArtistName to
- it.track to
- it.disc to
- it.durationMs
- }
- .toMutableList()
-
- logD("Successfully loaded ${songs.size} songs")
-
- return songs
- }
-
- /**
- * Group songs up into their respective albums. Instead of using the unreliable album or artist
- * databases, we instead group up songs by their *lowercase* artist and album name to create
- * albums. This serves two purposes:
- * 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This
- * makes sure both of those are resolved into a single artist called "Rammstein"
- * 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures
- * that all songs are unified under a single album.
- *
- * This does come with some costs, it's far slower than using the album ID itself, and it may
- * result in an unrelated album art being selected depending on the song chosen as the template,
- * but it seems to work pretty well.
- */
- private fun buildAlbums(songs: List): List {
- val albums = mutableListOf()
- val songsByAlbum = songs.groupBy { it._albumGroupingId }
-
- for (entry in songsByAlbum) {
- val albumSongs = entry.value
-
- // Use the song with the latest year as our metadata song.
- // This allows us to replicate the LAST_YEAR field, which is useful as it means that
- // weird years like "0" wont show up if there are alternatives.
- // Note: Normally we could want to use something like maxByWith, but apparently
- // that does not exist in the kotlin stdlib yet.
- val comparator = Sort.NullableComparator()
- var templateSong = albumSongs[0]
- for (i in 1..albumSongs.lastIndex) {
- val candidate = albumSongs[i]
- if (comparator.compare(templateSong.track, candidate.track) < 0) {
- templateSong = candidate
- }
- }
-
- val albumName = templateSong._mediaStoreAlbumName
- val albumYear = templateSong._mediaStoreYear
- val albumCoverUri =
- ContentUris.withAppendedId(
- Uri.parse("content://media/external/audio/albumart"),
- templateSong._mediaStoreAlbumId)
- val artistName = templateSong._artistGroupingName
-
- albums.add(
- Album(
- albumName,
- albumYear,
- albumCoverUri,
- albumSongs,
- artistName,
- ))
- }
-
- logD("Successfully built ${albums.size} albums")
-
- return albums
- }
-
- /**
- * Group up albums into artists. This also requires a de-duplication step due to some edge cases
- * where [buildAlbums] could not detect duplicates.
- */
- private fun buildArtists(albums: List): List {
- val artists = mutableListOf()
- val albumsByArtist = albums.groupBy { it._artistGroupingId }
-
- for (entry in albumsByArtist) {
- val templateAlbum = entry.value[0]
- val artistName =
- if (templateAlbum._artistGroupingName != MediaStore.UNKNOWN_STRING) {
- templateAlbum._artistGroupingName
- } else {
- null
- }
- val artistAlbums = entry.value
-
- artists.add(Artist(artistName, artistAlbums))
- }
-
- logD("Successfully built ${artists.size} artists")
-
- return artists
- }
-
- /**
- * Read all genres and link them up to the given songs. This is the code that requires me to
- * make dozens of useless queries just to link genres up.
- */
- private fun readGenres(context: Context, songs: List): List {
- val genres = mutableListOf()
-
- context.contentResolverSafe
- .query(
- MediaStore.Audio.Genres.EXTERNAL_CONTENT_URI,
- arrayOf(MediaStore.Audio.Genres._ID, MediaStore.Audio.Genres.NAME),
- null,
- null,
- null)
- ?.use { cursor ->
- val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres._ID)
- val nameIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.NAME)
-
- while (cursor.moveToNext()) {
- // Genre names can be a normal name, an ID3v2 constant, or null. Normal names
- // are resolved as usual, but null values don't make sense and are often junk
- // anyway, so we skip genres that have them.
- val id = cursor.getLong(idIndex)
- val name = cursor.getStringOrNull(nameIndex) ?: continue
- val genreSongs = queryGenreSongs(context, id, songs) ?: continue
-
- genres.add(Genre(name, genreSongs))
- }
- }
-
- val songsWithoutGenres = songs.filter { it._isMissingGenre }
- if (songsWithoutGenres.isNotEmpty()) {
- // Songs that don't have a genre will be thrown into an unknown genre.
- val unknownGenre = Genre(null, songsWithoutGenres)
-
- genres.add(unknownGenre)
- }
-
- logD("Successfully loaded ${genres.size} genres")
-
- return genres
- }
-
- /**
- * Queries the genre songs for [genreId]. Some genres are insane and don't contain songs for
- * some reason, so if that's the case then this function will return null.
- */
- private fun queryGenreSongs(context: Context, genreId: Long, songs: List): List? {
- val genreSongs = mutableListOf()
-
- // Don't even bother blacklisting here as useless iterations are less expensive than IO
- context.contentResolverSafe
- .query(
- MediaStore.Audio.Genres.Members.getContentUri("external", genreId),
- arrayOf(MediaStore.Audio.Genres.Members._ID),
- null,
- null,
- null)
- ?.use { cursor ->
- val idIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.Genres.Members._ID)
-
- while (cursor.moveToNext()) {
- val id = cursor.getLong(idIndex)
- songs.find { it._mediaStoreId == id }?.let { song -> genreSongs.add(song) }
- }
- }
-
- return genreSongs.ifEmpty { null }
- }
-
- /**
- * Represents a song as it is represented by MediaStore. This is progressively mutated over
- * several steps of the music loading process until it is complete enough to be transformed into
- * a song.
- *
- * TODO: Add manual metadata parsing.
- */
- private data class Audio(
- var id: Long? = null,
- var title: String? = null,
- var displayName: String? = null,
- var duration: Long? = null,
- var track: Int? = null,
- var disc: Int? = null,
- var year: Int? = null,
- var album: String? = null,
- var albumId: Long? = null,
- var artist: String? = null,
- var albumArtist: String? = null,
- ) {
- fun toSong(): Song =
- Song(
- requireNotNull(title) { "Malformed song: No title" },
- requireNotNull(displayName) { "Malformed song: No file name" },
- requireNotNull(duration) { "Malformed song: No duration" },
- track,
- disc,
- requireNotNull(id) { "Malformed song: No song id" },
- year,
- requireNotNull(album) { "Malformed song: No album name" },
- requireNotNull(albumId) { "Malformed song: No album id" },
- artist,
- albumArtist,
- )
- }
-
- /** A compatibility interface to implement version-specific audio database querying. */
- private interface MediaStoreCompat {
- /** Mutate the pre-existing projection with version-specific values. */
- fun mutateAudioProjection(proj: MutableList)
- /** Mutate [audio] with the columns added in [mutateAudioProjection], */
- fun populateAudio(cursor: Cursor, audio: Audio)
- }
-
- @RequiresApi(Build.VERSION_CODES.R)
- private class Api30MediaStoreCompat : MediaStoreCompat {
- private var trackIndex: Int = -1
- private var discIndex: Int = -1
-
- override fun mutateAudioProjection(proj: MutableList) {
- proj.add(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
- proj.add(MediaStore.Audio.AudioColumns.DISC_NUMBER)
- }
-
- override fun populateAudio(cursor: Cursor, audio: Audio) {
- if (trackIndex == -1) {
- trackIndex =
- cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.CD_TRACK_NUMBER)
- }
-
- if (discIndex == -1) {
- discIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.DISC_NUMBER)
- }
-
- // Both CD_TRACK_NUMBER and DISC_NUMBER tend to be formatted as they are in
- // the tag itself, which is to say that it is formatted as NN/TT tracks, where
- // N is the number and T is the total. Parse the number while leaving out the
- // total, as we have no use for it.
-
- cursor
- .getStringOrNull(trackIndex)
- ?.split('/', limit = 2)
- ?.getOrNull(0)
- ?.toIntOrNull()
- ?.let { audio.track = it }
- cursor
- .getStringOrNull(discIndex)
- ?.split('/', limit = 2)
- ?.getOrNull(0)
- ?.toIntOrNull()
- ?.let { audio.disc = it }
- }
- }
-
- private class Api21MediaStoreCompat : MediaStoreCompat {
- private var trackIndex: Int = -1
-
- override fun mutateAudioProjection(proj: MutableList) {
- proj.add(MediaStore.Audio.AudioColumns.TRACK)
- }
-
- override fun populateAudio(cursor: Cursor, audio: Audio) {
- if (trackIndex == -1) {
- trackIndex = cursor.getColumnIndexOrThrow(MediaStore.Audio.AudioColumns.TRACK)
- }
-
- // TRACK is formatted as DTTT where D is the disc number and T is the track number.
- // At least, I think so. I've so far been unable to reproduce disc numbers on older
- // devices. Keep it around just in case.
-
- val rawTrack = cursor.getIntOrNull(trackIndex)
- if (rawTrack != null) {
- audio.track = rawTrack % 1000
-
- // A disc number of 0 means that there is no disc.
- val disc = rawTrack / 1000
- if (disc > 0) {
- audio.disc = disc
- }
- }
- }
- }
-}
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 65cf60be1..470344db4 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/Music.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/Music.kt
@@ -81,6 +81,8 @@ data class Song(
val _mediaStoreArtistName: String?,
/** Internal field. Do not use. */
val _mediaStoreAlbumArtistName: String?,
+ /** Internal field. Do not use. */
+ val _mediaStoreGenreName: String?
) : Music() {
override val id: Long
get() {
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 3f83df869..3a5b292cb 100644
--- a/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
+++ b/app/src/main/java/org/oxycblt/auxio/music/MusicStore.kt
@@ -26,6 +26,7 @@ import androidx.core.content.ContextCompat
import java.lang.Exception
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import org.oxycblt.auxio.music.indexer.Indexer
import org.oxycblt.auxio.util.contentResolverSafe
import org.oxycblt.auxio.util.logD
import org.oxycblt.auxio.util.logE
diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt b/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt
new file mode 100644
index 000000000..899b2d20c
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/Indexer.kt
@@ -0,0 +1,196 @@
+/*
+ * 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.indexer
+
+import android.content.ContentUris
+import android.content.Context
+import android.database.Cursor
+import android.net.Uri
+import android.os.Build
+import android.provider.MediaStore
+import org.oxycblt.auxio.music.Album
+import org.oxycblt.auxio.music.Artist
+import org.oxycblt.auxio.music.Genre
+import org.oxycblt.auxio.music.MusicStore
+import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.ui.Sort
+import org.oxycblt.auxio.util.logD
+
+object Indexer {
+ fun index(context: Context): MusicStore.Library? {
+ // Establish the backend to use when initially loading songs.
+ val backend =
+ when {
+ Build.VERSION.SDK_INT >= Build.VERSION_CODES.R -> Api30MediaStoreBackend()
+ else -> Api21MediaStoreBackend()
+ }
+
+ val songs = buildSongs(context, backend)
+ if (songs.isEmpty()) return null
+
+ val albums = buildAlbums(songs)
+ val artists = buildArtists(albums)
+ val genres = buildGenres(songs)
+
+ // Sanity check: Ensure that all songs are linked up to albums/artists/genres.
+ for (song in songs) {
+ if (song._isMissingAlbum || song._isMissingArtist || song._isMissingGenre) {
+ throw IllegalStateException(
+ "Found malformed song: ${song.rawName} [" +
+ "album: ${!song._isMissingAlbum} " +
+ "artist: ${!song._isMissingArtist} " +
+ "genre: ${!song._isMissingGenre}]")
+ }
+ }
+
+ return MusicStore.Library(genres, artists, albums, songs)
+ }
+
+ /**
+ * Does the initial query over the song database, including excluded directory checks. The songs
+ * returned by this function are **not** well-formed. The companion [buildAlbums],
+ * [buildArtists], and [buildGenres] functions must be called with the returned list so that all
+ * songs are properly linked up.
+ */
+ private fun buildSongs(context: Context, backend: Backend): List {
+ var songs = backend.query(context).use { cursor -> backend.loadSongs(context, cursor) }
+
+ // Deduplicate songs to prevent (most) deformed music clones
+ songs =
+ songs.distinctBy {
+ it.rawName to
+ it._mediaStoreAlbumName to
+ it._mediaStoreArtistName to
+ it._mediaStoreAlbumArtistName to
+ it._mediaStoreGenreName to
+ it.track to
+ it.disc to
+ it.durationMs
+ }
+
+ logD("Successfully loaded ${songs.size} songs")
+
+ return songs
+ }
+
+ /**
+ * Group songs up into their respective albums. Instead of using the unreliable album or artist
+ * databases, we instead group up songs by their *lowercase* artist and album name to create
+ * albums. This serves two purposes:
+ * 1. Sometimes artist names can be styled differently, e.g "Rammstein" vs. "RAMMSTEIN". This
+ * makes sure both of those are resolved into a single artist called "Rammstein"
+ * 2. Sometimes MediaStore will split album IDs up if the songs differ in format. This ensures
+ * that all songs are unified under a single album.
+ *
+ * This does come with some costs, it's far slower than using the album ID itself, and it may
+ * result in an unrelated album art being selected depending on the song chosen as the template,
+ * but it seems to work pretty well.
+ */
+ private fun buildAlbums(songs: List): List {
+ val albums = mutableListOf()
+ val songsByAlbum = songs.groupBy { it._albumGroupingId }
+
+ for (entry in songsByAlbum) {
+ val albumSongs = entry.value
+
+ // Use the song with the latest year as our metadata song.
+ // This allows us to replicate the LAST_YEAR field, which is useful as it means that
+ // weird years like "0" wont show up if there are alternatives.
+ // Note: Normally we could want to use something like maxByWith, but apparently
+ // that does not exist in the kotlin stdlib yet.
+ val comparator = Sort.NullableComparator()
+ var templateSong = albumSongs[0]
+ for (i in 1..albumSongs.lastIndex) {
+ val candidate = albumSongs[i]
+ if (comparator.compare(templateSong.track, candidate.track) < 0) {
+ templateSong = candidate
+ }
+ }
+
+ val albumName = templateSong._mediaStoreAlbumName
+ val albumYear = templateSong._mediaStoreYear
+ val albumCoverUri =
+ ContentUris.withAppendedId(
+ Uri.parse("content://media/external/audio/albumart"),
+ templateSong._mediaStoreAlbumId)
+ val artistName = templateSong._artistGroupingName
+
+ albums.add(
+ Album(
+ albumName,
+ albumYear,
+ albumCoverUri,
+ albumSongs,
+ artistName,
+ ))
+ }
+
+ logD("Successfully built ${albums.size} albums")
+
+ return albums
+ }
+
+ /**
+ * Group up albums into artists. This also requires a de-duplication step due to some edge cases
+ * where [buildAlbums] could not detect duplicates.
+ */
+ private fun buildArtists(albums: List): List {
+ val artists = mutableListOf()
+ val albumsByArtist = albums.groupBy { it._artistGroupingId }
+
+ for (entry in albumsByArtist) {
+ val templateAlbum = entry.value[0]
+ val artistName =
+ if (templateAlbum._artistGroupingName != MediaStore.UNKNOWN_STRING) {
+ templateAlbum._artistGroupingName
+ } else {
+ null
+ }
+ val artistAlbums = entry.value
+
+ artists.add(Artist(artistName, artistAlbums))
+ }
+
+ logD("Successfully built ${artists.size} artists")
+
+ return artists
+ }
+
+ /**
+ * Read all genres and link them up to the given songs. This is the code that requires me to
+ * make dozens of useless queries just to link genres up.
+ */
+ private fun buildGenres(songs: List): List {
+ val genres = mutableListOf()
+ val songsByGenre = songs.groupBy { it._mediaStoreGenreName?.hashCode() }
+
+ for (entry in songsByGenre) {
+ val templateSong = entry.value[0]
+ genres.add(Genre(templateSong._mediaStoreGenreName, entry.value))
+ }
+
+ logD("Successfully built ${genres.size} genres")
+
+ return genres
+ }
+
+ interface Backend {
+ fun query(context: Context): Cursor
+ fun loadSongs(context: Context, cursor: Cursor): Collection
+ }
+}
diff --git a/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt b/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt
new file mode 100644
index 000000000..683e6fab0
--- /dev/null
+++ b/app/src/main/java/org/oxycblt/auxio/music/indexer/MediaStoreBackend.kt
@@ -0,0 +1,365 @@
+/*
+ * 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.indexer
+
+import android.content.Context
+import android.database.Cursor
+import android.os.Build
+import android.provider.MediaStore
+import androidx.annotation.RequiresApi
+import androidx.core.database.getIntOrNull
+import androidx.core.database.getStringOrNull
+import org.oxycblt.auxio.music.Song
+import org.oxycblt.auxio.music.excluded.ExcludedDatabase
+import org.oxycblt.auxio.util.contentResolverSafe
+
+/*
+ * This class acts as the base for most the black magic required to get a remotely sensible music
+ * indexing system while still optimizing for time. I would recommend you leave this module now
+ * before you lose your sanity trying to understand the hoops I had to jump through for this system,
+ * but if you really want to stay, here's a debrief on why this code is so awful.
+ *
+ * MediaStore is not a good API. It is not even a bad API. Calling it a bad API is an insult to
+ * other bad android APIs, like CoordinatorLayout or InputMethodManager. No. MediaStore is a crime
+ * against humanity and probably a way to summon Zalgo if you look at it the wrong way.
+ *
+ * You think that if you wanted to query a song's genre from a media database, you could just put
+ * "genre" in the query and it would return it, right? But not with MediaStore! No, that's too
+ * straightforward for this contract that was dropped on it's head as a baby. So instead, you have
+ * to query for each genre, query all the songs in each genre, and then iterate through those songs
+ * to link every song with their genre. This is not documented anywhere, and the O(mom im scared)
+ * algorithm you have to run to get it working single-handedly DOUBLES Auxio's loading times. At no
+ * point have the devs considered that this system is absolutely insane, and instead focused on
+ * adding infuriat- I mean nice proprietary extensions to MediaStore for their own Google Play
+ * Music, and of course every Google Play Music user knew how great that turned out!
+ *
+ * It's not even ergonomics that makes this API bad. It's base implementation is completely borked
+ * as well. Did you know that MediaStore doesn't accept dates that aren't from ID3v2.3 MP3 files? I
+ * sure didn't, until I decided to upgrade my music collection to ID3v2.4 and FLAC only to see that
+ * the metadata parser has a brain aneurysm the moment it stumbles upon a dreaded TRDC or DATE tag.
+ * Once again, this is because internally android uses an ancient in-house metadata parser to get
+ * everything indexed, and so far they have not bothered to modernize this parser or even switch it
+ * to something more powerful like Taglib, not even in Android 12. ID3v2.4 has been around for *21
+ * years.* *It can drink now.* All of my what.
+ *
+ * Not to mention all the other infuriating quirks. Album artists can't be accessed from the albums
+ * table, so we have to go for the less efficient "make a big query on all the songs lol" method so
+ * that songs don't end up fragmented across artists. Pretty much every OEM has added some extension
+ * or quirk to MediaStore that I cannot reproduce, with some OEMs (COUGHSAMSUNGCOUGH) crippling the
+ * normal tables so that you're railroaded into their music app. The way I do blacklisting relies on
+ * a semi-deprecated method, and the supposedly "modern" method is SLOWER and causes even more
+ * problems since I have to manage databases across version boundaries. Sometimes music will have a
+ * deformed clone that I can't filter out, sometimes Genres will just break for no reason, and
+ * sometimes tags encoded in UTF-8 will be interpreted as anything from UTF-16 to Latin-1 to *Shift
+ * JIS* WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY WHY
+ *
+ * Is there anything we can do about it? No. Google has routinely shut down issues that begged
+ * google to fix glaring issues with MediaStore or to just take the API behind the woodshed and
+ * shoot it. Largely because they have zero incentive to improve it given how "obscure" local music
+ * listening is. As a result, some players like Vanilla and VLC just hack their own
+ * pseudo-MediaStore implementation from their own (better) parsers, but this is both infeasible for
+ * Auxio due to how incredibly slow it is to get a file handle from the android sandbox AND how much
+ * harder it is to manage a database of your own media that mirrors the filesystem perfectly. And
+ * even if I set aside those crippling issues and changed my indexer to that, it would face the even
+ * larger problem of how google keeps trying to kill the filesystem and force you into their
+ * ContentResolver API. In the future MediaStore could be the only system we have, which is also the
+ * day that greenland melts and birthdays stop happening forever.
+ *
+ * I'm pretty sure nothing is going to happen and MediaStore will continue to be neglected and
+ * probably deprecated eventually for a "new" API that just coincidentally excludes music indexing.
+ * Because go screw yourself for wanting to listen to music you own. Be a good consoomer and listen
+ * to your AlgoPop StreamMix™.
+ *
+ * I wish I was born in the neolithic.
+ */
+
+abstract class MediaStoreBackend : Indexer.Backend {
+ private var idIndex = -1
+ private var titleIndex = -1
+ private var fileIndex = -1
+ private var durationIndex = -1
+ private var yearIndex = -1
+ private var albumIndex = -1
+ private var albumIdIndex = -1
+ private var artistIndex = -1
+ private var albumArtistIndex = -1
+ private var dataIndex = -1
+
+ override fun query(context: Context): Cursor {
+ val excludedDatabase = ExcludedDatabase.getInstance(context)
+ var selector = "${MediaStore.Audio.Media.IS_MUSIC}=1"
+ val args = mutableListOf()
+
+ // Apply the excluded directories by filtering out specific DATA values.
+ // DATA was deprecated in Android 10, but it was un-deprecated in Android 12L,
+ // so it's probably okay to use it. The only reason we would want to use
+ // another method is for external partitions support, but there is no demand for that.
+ for (path in excludedDatabase.readPaths()) {
+ selector += " AND ${MediaStore.Audio.Media.DATA} NOT LIKE ?"
+ args += "$path%" // Append % so that the selector properly detects children
+ }
+
+ return requireNotNull(
+ context.contentResolverSafe.query(
+ MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
+ projection,
+ selector,
+ args.toTypedArray(),
+ null)) { "Content resolver failure: No Cursor returned" }
+ }
+
+ override fun loadSongs(context: Context, cursor: Cursor): Collection {
+ val audios = mutableListOf