diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt index a1881308..3ad882a1 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeDownloadFinalizer.kt @@ -2,8 +2,6 @@ package com.zarz.spotiflac import android.content.ContentValues import android.content.Context -import android.database.sqlite.SQLiteDatabase -import android.database.sqlite.SQLiteException import android.net.Uri import android.util.Base64 import android.util.Log @@ -35,19 +33,6 @@ import kotlin.math.pow object NativeDownloadFinalizer { internal const val TAG = "NativeFinalizer" const val NATIVE_WORKER_CONTRACT_VERSION = 1 - // Native finalizer owns background-safe history writes while Flutter may be suspended. - // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. - const val HISTORY_SCHEMA_VERSION = 15 - // Keep one native connection for the process. Opening history.db and - // probing/migrating its schema for every finalized track was expensive, - // and a single guarded writer also prevents native finalizer calls from - // interleaving transactions on the same connection. Flutter/sqflite uses - // its own WAL connection, so the busy timeout remains configured once on - // this connection for cross-connection contention. - private val historyDatabaseLock = Any() - private var historyDatabase: SQLiteDatabase? = null - private var historyDatabasePath = "" - private var historyDatabaseSchemaVersion = 0 internal val activeFFmpegSessionIds = mutableSetOf() internal val nativeFFmpegSessionIds = BoundedRegistry(maxEntries = 256) internal val activeFFmpegSessionLock = Any() @@ -63,73 +48,6 @@ object NativeDownloadFinalizer { delegate?.apply(session) } } - private val requiredHistoryColumns = setOf( - "id", - "track_name", - "artist_name", - "album_name", - "album_artist", - "cover_url", - "file_path", - "storage_mode", - "download_tree_uri", - "saf_relative_dir", - "saf_file_name", - "saf_repaired", - "service", - "downloaded_at", - "isrc", - "spotify_id", - "track_number", - "total_tracks", - "disc_number", - "total_discs", - "duration", - "release_date", - "quality", - "bit_depth", - "sample_rate", - "bitrate", - "format", - "genre", - "composer", - "label", - "copyright", - "explicit", - "has_lyrics", - "lyrics_metadata_scan_version", - "has_replaygain", - "replaygain_metadata_scan_version", - "spotify_id_norm", - "isrc_norm", - "match_key", - "album_key", - "search_text", - "sort_track", - "sort_artist", - "sort_album", - "sort_album_artist", - "sort_genre", - "sort_release", - "sort_added", - ) - private val androidStoragePathAliases = listOf( - "/storage/emulated/0", - "/storage/emulated/legacy", - "/storage/self/primary", - "/sdcard", - "/mnt/sdcard", - ) - private val audioExtensions = listOf( - ".flac", - ".m4a", - ".mp3", - ".opus", - ".ogg", - ".wav", - ".aac", - ".mp4", - ) internal data class FinalizeInput( val itemId: String, @@ -240,7 +158,6 @@ object NativeDownloadFinalizer { var qualityMetadataRefreshed = false if (!result.optBoolean("already_exists", false)) { checkCancelled(shouldCancel) - currentStatus("finalizing") finalizeDecryption(context, effectiveInput, state, shouldCancel) checkCancelled(shouldCancel) timedStage("container conversion") { @@ -347,7 +264,7 @@ object NativeDownloadFinalizer { ) { try { buildHistoryRow(effectiveInput, state).also { - upsertHistory( + NativeHistoryStore.upsert( context, it, deduplicateTrack = !preserveQualityVariant, @@ -474,9 +391,6 @@ object NativeDownloadFinalizer { } } - private fun currentStatus(@Suppress("UNUSED_PARAMETER") status: String) { - } - private fun cleanupFailedFinalizationOutput( context: Context, result: JSONObject, @@ -1342,649 +1256,11 @@ object NativeDownloadFinalizer { state.externalLrcWritten ) 1 else 0, ) - putNormalizedHistoryColumns(values) values.put("has_replaygain", if (state.hasReplayGain) 1 else 0) values.put("replaygain_metadata_scan_version", if (state.replayGainMetadataScanned) 1 else 0) return values } - private fun upsertHistory( - context: Context, - values: ContentValues, - deduplicateTrack: Boolean = true, - ) { - withHistoryDatabase(context) { db -> - val initializeSchema = historyDatabaseSchemaVersion != HISTORY_SCHEMA_VERSION - db.beginTransaction() - try { - if (initializeSchema) { - if (db.version > HISTORY_SCHEMA_VERSION) { - throw IllegalStateException( - "history schema v${db.version} is newer than native finalizer contract v$HISTORY_SCHEMA_VERSION" - ) - } - // Only older schemas need metadata backfills. v15 adds - // tree-independent document identities to the path index. - val needsBackfill = db.version < 13 - val needsPathBackfill = db.version < 15 - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS history ( - id TEXT PRIMARY KEY, - track_name TEXT NOT NULL, - artist_name TEXT NOT NULL, - album_name TEXT NOT NULL, - album_artist TEXT, - cover_url TEXT, - file_path TEXT NOT NULL, - storage_mode TEXT, - download_tree_uri TEXT, - saf_relative_dir TEXT, - saf_file_name TEXT, - saf_repaired INTEGER, - service TEXT NOT NULL, - downloaded_at TEXT NOT NULL, - isrc TEXT, - spotify_id TEXT, - track_number INTEGER, - total_tracks INTEGER, - disc_number INTEGER, - total_discs INTEGER, - duration INTEGER, - release_date TEXT, - quality TEXT, - bit_depth INTEGER, - sample_rate INTEGER, - bitrate INTEGER, - format TEXT, - genre TEXT, - composer TEXT, - label TEXT, - copyright TEXT, - explicit INTEGER NOT NULL DEFAULT 0, - has_lyrics INTEGER NOT NULL DEFAULT 0, - lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0, - has_replaygain INTEGER NOT NULL DEFAULT 0, - replaygain_metadata_scan_version INTEGER NOT NULL DEFAULT 0, - spotify_id_norm TEXT, - isrc_norm TEXT, - match_key TEXT, - album_key TEXT, - search_text TEXT, - sort_track TEXT, - sort_artist TEXT, - sort_album TEXT, - sort_album_artist TEXT, - sort_genre TEXT, - sort_release TEXT, - sort_added INTEGER - ) - """.trimIndent() - ) - ensureHistoryColumn(db, "storage_mode", "ALTER TABLE history ADD COLUMN storage_mode TEXT") - ensureHistoryColumn(db, "download_tree_uri", "ALTER TABLE history ADD COLUMN download_tree_uri TEXT") - ensureHistoryColumn(db, "saf_relative_dir", "ALTER TABLE history ADD COLUMN saf_relative_dir TEXT") - ensureHistoryColumn(db, "saf_file_name", "ALTER TABLE history ADD COLUMN saf_file_name TEXT") - ensureHistoryColumn(db, "saf_repaired", "ALTER TABLE history ADD COLUMN saf_repaired INTEGER") - ensureHistoryColumn(db, "composer", "ALTER TABLE history ADD COLUMN composer TEXT") - ensureHistoryColumn(db, "total_tracks", "ALTER TABLE history ADD COLUMN total_tracks INTEGER") - ensureHistoryColumn(db, "total_discs", "ALTER TABLE history ADD COLUMN total_discs INTEGER") - ensureHistoryColumn(db, "bitrate", "ALTER TABLE history ADD COLUMN bitrate INTEGER") - ensureHistoryColumn(db, "format", "ALTER TABLE history ADD COLUMN format TEXT") - ensureHistoryColumn(db, "spotify_id_norm", "ALTER TABLE history ADD COLUMN spotify_id_norm TEXT") - ensureHistoryColumn(db, "isrc_norm", "ALTER TABLE history ADD COLUMN isrc_norm TEXT") - ensureHistoryColumn(db, "match_key", "ALTER TABLE history ADD COLUMN match_key TEXT") - ensureHistoryColumn(db, "album_key", "ALTER TABLE history ADD COLUMN album_key TEXT") - ensureHistoryColumn(db, "search_text", "ALTER TABLE history ADD COLUMN search_text TEXT") - ensureHistoryColumn(db, "sort_track", "ALTER TABLE history ADD COLUMN sort_track TEXT") - ensureHistoryColumn(db, "sort_artist", "ALTER TABLE history ADD COLUMN sort_artist TEXT") - ensureHistoryColumn(db, "sort_album", "ALTER TABLE history ADD COLUMN sort_album TEXT") - ensureHistoryColumn(db, "sort_album_artist", "ALTER TABLE history ADD COLUMN sort_album_artist TEXT") - ensureHistoryColumn(db, "sort_genre", "ALTER TABLE history ADD COLUMN sort_genre TEXT") - ensureHistoryColumn(db, "sort_release", "ALTER TABLE history ADD COLUMN sort_release TEXT") - ensureHistoryColumn(db, "sort_added", "ALTER TABLE history ADD COLUMN sort_added INTEGER") - ensureHistoryColumn(db, "explicit", "ALTER TABLE history ADD COLUMN explicit INTEGER NOT NULL DEFAULT 0") - ensureHistoryColumn(db, "has_lyrics", "ALTER TABLE history ADD COLUMN has_lyrics INTEGER NOT NULL DEFAULT 0") - ensureHistoryColumn(db, "lyrics_metadata_scan_version", "ALTER TABLE history ADD COLUMN lyrics_metadata_scan_version INTEGER NOT NULL DEFAULT 0") - ensureHistoryColumn(db, "has_replaygain", "ALTER TABLE history ADD COLUMN has_replaygain INTEGER NOT NULL DEFAULT 0") - ensureHistoryColumn(db, "replaygain_metadata_scan_version", "ALTER TABLE history ADD COLUMN replaygain_metadata_scan_version INTEGER NOT NULL DEFAULT 0") - ensureHistoryPathKeyTable(db) - if (needsBackfill) { - backfillNormalizedHistoryColumns(db) - } - if (needsPathBackfill) { - backfillHistoryPathKeys(db) - } - validateHistorySchema(db) - db.execSQL("CREATE INDEX IF NOT EXISTS idx_spotify_id ON history(spotify_id)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_isrc ON history(isrc)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_downloaded_at ON history(downloaded_at DESC)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_album ON history(album_name, album_artist)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_album_key ON history(album_key)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_added ON history(sort_added DESC, sort_track, id)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_track ON history(sort_track, sort_artist, id)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_artist ON history(sort_artist, sort_track, id)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_album ON history(sort_album, sort_track, id)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_genre ON history(sort_genre, sort_track, id)") - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_release ON history(sort_release, sort_track, id)") - if (db.version < HISTORY_SCHEMA_VERSION) db.version = HISTORY_SCHEMA_VERSION - } - if (deduplicateTrack) deleteDuplicateHistoryRows(db, values) - db.insertWithOnConflict("history", null, values, SQLiteDatabase.CONFLICT_REPLACE) - replaceHistoryPathKeys(db, values.getAsString("id"), values.getAsString("file_path")) - db.setTransactionSuccessful() - } finally { - db.endTransaction() - } - if (initializeSchema) { - historyDatabaseSchemaVersion = HISTORY_SCHEMA_VERSION - } - } - } - - private inline fun withHistoryDatabase( - context: Context, - block: (SQLiteDatabase) -> T, - ): T { - synchronized(historyDatabaseLock) { - val dbFile = File(File(context.applicationInfo.dataDir, "app_flutter"), "history.db") - dbFile.parentFile?.mkdirs() - val db = historyDatabase?.takeIf { - it.isOpen && historyDatabasePath == dbFile.absolutePath - } ?: run { - historyDatabase?.close() - val opened = SQLiteDatabase.openDatabase( - dbFile.absolutePath, - null, - SQLiteDatabase.OPEN_READWRITE or - SQLiteDatabase.CREATE_IF_NECESSARY or - SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING, - ) - try { - configureHistoryDatabase(opened) - } catch (e: Exception) { - opened.close() - throw e - } - historyDatabase = opened - historyDatabasePath = dbFile.absolutePath - historyDatabaseSchemaVersion = 0 - opened - } - historyDatabase = db - return block(db) - } - } - - private fun configureHistoryDatabase(db: SQLiteDatabase) { - runHistoryPragma(db, "PRAGMA busy_timeout = 5000", required = false) - // CONFLICT_REPLACE must fire the history delete trigger so the - // external-content FTS index does not retain the replaced rowid. - runHistoryPragma(db, "PRAGMA recursive_triggers = ON", required = false) - runHistoryPragma(db, "PRAGMA synchronous = NORMAL", required = false) - runHistoryPragma(db, "PRAGMA journal_mode = WAL", required = false) - } - - private fun runHistoryPragma(db: SQLiteDatabase, sql: String, required: Boolean) { - try { - db.rawQuery(sql, null).use { cursor -> - while (cursor.moveToNext()) { - // PRAGMA setters may return a row; consume it so Android closes the cursor cleanly. - } - } - } catch (e: SQLiteException) { - if (required) throw e - Log.w(TAG, "Unable to apply history database setting: $sql", e) - } - } - - private fun validateHistorySchema(db: SQLiteDatabase) { - val columns = mutableSetOf() - db.rawQuery("PRAGMA table_info(history)", null).use { cursor -> - val nameIndex = cursor.getColumnIndex("name") - while (cursor.moveToNext()) { - if (nameIndex >= 0) { - columns.add(cursor.getString(nameIndex).lowercase(Locale.ROOT)) - } - } - } - val missing = requiredHistoryColumns.filterNot { columns.contains(it) } - if (missing.isNotEmpty()) { - throw IllegalStateException("history schema missing columns for native finalizer: ${missing.joinToString()}") - } - } - - private fun deleteDuplicateHistoryRows(db: SQLiteDatabase, values: ContentValues) { - val id = values.getAsString("id") ?: return - val duplicateIds = linkedSetOf() - val spotifyId = values.getAsString("spotify_id")?.trim().orEmpty() - val spotifyIdNorm = values.getAsString("spotify_id_norm")?.trim().orEmpty() - if (spotifyId.isNotEmpty() || spotifyIdNorm.isNotEmpty()) { - duplicateIds.addAll( - historyIdsForWhere( - db, - "(spotify_id = ? OR spotify_id_norm = ?) AND id <> ?", - arrayOf(spotifyId, spotifyIdNorm, id), - ) - ) - } - - val isrc = values.getAsString("isrc")?.trim().orEmpty() - val isrcNorm = values.getAsString("isrc_norm")?.trim().orEmpty() - if (isrc.isNotEmpty() || isrcNorm.isNotEmpty()) { - duplicateIds.addAll( - historyIdsForWhere( - db, - "(isrc = ? OR isrc_norm = ?) AND id <> ?", - arrayOf(isrc, isrcNorm, id), - ) - ) - } - - if (spotifyIdNorm.isEmpty() && isrcNorm.isEmpty()) { - val matchKey = values.getAsString("match_key")?.trim().orEmpty() - if (matchKey.isNotEmpty()) { - duplicateIds.addAll( - historyIdsForWhere( - db, - "match_key = ? AND id <> ?", - arrayOf(matchKey, id), - ) - ) - } - } - if (duplicateIds.isEmpty()) return - deleteHistoryPathKeys(db, duplicateIds) - val placeholders = duplicateIds.joinToString(",") { "?" } - db.delete("history", "id IN ($placeholders)", duplicateIds.toTypedArray()) - } - - private fun historyIdsForWhere(db: SQLiteDatabase, where: String, args: Array): List { - val ids = mutableListOf() - db.query("history", arrayOf("id"), where, args, null, null, null).use { cursor -> - val idIndex = cursor.getColumnIndex("id") - while (cursor.moveToNext()) { - if (idIndex >= 0) ids.add(cursor.getString(idIndex)) - } - } - return ids - } - - private fun deleteHistoryPathKeys(db: SQLiteDatabase, ids: Collection) { - if (ids.isEmpty()) return - val placeholders = ids.joinToString(",") { "?" } - db.delete("history_path_keys", "item_id IN ($placeholders)", ids.toTypedArray()) - } - - private fun ensureHistoryPathKeyTable(db: SQLiteDatabase) { - db.execSQL( - """ - CREATE TABLE IF NOT EXISTS history_path_keys ( - item_id TEXT NOT NULL, - path_key TEXT NOT NULL, - PRIMARY KEY (item_id, path_key) - ) - """.trimIndent() - ) - db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_path_keys_key ON history_path_keys(path_key)") - } - - private fun backfillNormalizedHistoryColumns(db: SQLiteDatabase) { - db.query( - "history", - arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name", "album_name", "album_artist", "genre", "release_date", "downloaded_at"), - "spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL OR album_key IS NULL OR search_text IS NULL OR sort_track IS NULL OR sort_release IS NULL OR sort_added IS NULL", - null, - null, - null, - null, - ).use { cursor -> - val idIndex = cursor.getColumnIndex("id") - val spotifyIndex = cursor.getColumnIndex("spotify_id") - val isrcIndex = cursor.getColumnIndex("isrc") - val trackIndex = cursor.getColumnIndex("track_name") - val artistIndex = cursor.getColumnIndex("artist_name") - val albumIndex = cursor.getColumnIndex("album_name") - val albumArtistIndex = cursor.getColumnIndex("album_artist") - val genreIndex = cursor.getColumnIndex("genre") - val releaseDateIndex = cursor.getColumnIndex("release_date") - val downloadedAtIndex = cursor.getColumnIndex("downloaded_at") - while (cursor.moveToNext()) { - if (idIndex < 0) continue - val values = ContentValues() - val spotifyId = cursor.getNullableString(spotifyIndex) - val isrc = cursor.getNullableString(isrcIndex) - val trackName = cursor.getNullableString(trackIndex) - val artistName = cursor.getNullableString(artistIndex) - val albumName = cursor.getNullableString(albumIndex) - val albumArtist = cursor.getNullableString(albumArtistIndex) - values.put("spotify_id_norm", normalizeSpotifyId(spotifyId)) - values.put("isrc_norm", normalizeIsrc(isrc)) - values.put("match_key", matchKeyFor(trackName, artistName)) - putAlbumSearchHistoryColumns( - values, - trackName = trackName, - artistName = artistName, - albumName = albumName, - albumArtist = albumArtist, - ) - putQueueSortHistoryColumns( - values, - trackName = trackName, - artistName = artistName, - albumName = albumName, - albumArtist = albumArtist, - genre = cursor.getNullableString(genreIndex), - releaseDate = cursor.getNullableString(releaseDateIndex), - downloadedAt = cursor.getNullableString(downloadedAtIndex), - ) - db.update("history", values, "id = ?", arrayOf(cursor.getString(idIndex))) - } - } - } - - private fun backfillHistoryPathKeys(db: SQLiteDatabase) { - db.query("history", arrayOf("id", "file_path"), null, null, null, null, null).use { cursor -> - val idIndex = cursor.getColumnIndex("id") - val pathIndex = cursor.getColumnIndex("file_path") - while (cursor.moveToNext()) { - if (idIndex >= 0) { - replaceHistoryPathKeys(db, cursor.getString(idIndex), cursor.getNullableString(pathIndex)) - } - } - } - } - - private fun replaceHistoryPathKeys(db: SQLiteDatabase, itemId: String?, filePath: String?) { - val id = itemId?.trim().orEmpty() - if (id.isEmpty()) return - db.delete("history_path_keys", "item_id = ?", arrayOf(id)) - for (key in buildPathMatchKeys(filePath)) { - val values = ContentValues() - values.put("item_id", id) - values.put("path_key", key) - db.insertWithOnConflict("history_path_keys", null, values, SQLiteDatabase.CONFLICT_IGNORE) - } - } - - private fun putNormalizedHistoryColumns(values: ContentValues) { - values.put("spotify_id_norm", normalizeSpotifyId(values.getAsString("spotify_id"))) - values.put("isrc_norm", normalizeIsrc(values.getAsString("isrc"))) - values.put( - "match_key", - matchKeyFor(values.getAsString("track_name"), values.getAsString("artist_name")), - ) - putAlbumSearchHistoryColumns( - values, - trackName = values.getAsString("track_name"), - artistName = values.getAsString("artist_name"), - albumName = values.getAsString("album_name"), - albumArtist = values.getAsString("album_artist"), - ) - putQueueSortHistoryColumns( - values, - trackName = values.getAsString("track_name"), - artistName = values.getAsString("artist_name"), - albumName = values.getAsString("album_name"), - albumArtist = values.getAsString("album_artist"), - genre = values.getAsString("genre"), - releaseDate = values.getAsString("release_date"), - downloadedAt = values.getAsString("downloaded_at"), - ) - } - - private fun putQueueSortHistoryColumns( - values: ContentValues, - trackName: String?, - artistName: String?, - albumName: String?, - albumArtist: String?, - genre: String?, - releaseDate: String?, - downloadedAt: String?, - ) { - values.put("sort_track", normalizeLookupText(trackName)) - values.put("sort_artist", normalizeLookupText(artistName)) - values.put("sort_album", normalizeLookupText(albumName)) - values.put( - "sort_album_artist", - normalizeLookupText(albumArtist?.takeIf { it.trim().isNotEmpty() } ?: artistName), - ) - values.put("sort_genre", normalizeLookupText(genre)) - values.put("sort_release", releaseDate?.trim().orEmpty()) - val sortAdded = parseHistoryTimestampMillis(downloadedAt) - values.put("sort_added", sortAdded) - } - - private fun parseHistoryTimestampMillis(value: String?): Long { - val timestamp = value?.trim().orEmpty() - if (timestamp.isEmpty()) return 0L - return try { - java.time.Instant.parse(timestamp).toEpochMilli() - } catch (_: Exception) { - try { - java.time.OffsetDateTime.parse(timestamp).toInstant().toEpochMilli() - } catch (_: Exception) { - try { - java.time.LocalDateTime.parse(timestamp) - .atZone(java.time.ZoneId.systemDefault()) - .toInstant() - .toEpochMilli() - } catch (_: Exception) { - 0L - } - } - } - } - - private fun putAlbumSearchHistoryColumns( - values: ContentValues, - trackName: String?, - artistName: String?, - albumName: String?, - albumArtist: String?, - ) { - val track = normalizeLookupText(trackName) - val artist = normalizeLookupText(artistName) - val album = normalizeLookupText(albumName) - val resolvedAlbumArtist = normalizeLookupText( - albumArtist?.takeIf { it.trim().isNotEmpty() } ?: artistName, - ) - values.put("album_key", "$album|$resolvedAlbumArtist") - values.put( - "search_text", - listOf(track, artist, album, resolvedAlbumArtist) - .filter { it.isNotEmpty() } - .joinToString(" "), - ) - } - - private fun normalizeLookupText(value: String?): String = - cleanMetadataString(value).lowercase(Locale.ROOT) - - private fun normalizeSpotifyId(value: String?): String = - cleanMetadataString(value).lowercase(Locale.ROOT) - - private fun normalizeIsrc(value: String?): String = - cleanMetadataString(value) - .uppercase(Locale.ROOT) - .replace(Regex("[-\\s]"), "") - - private fun matchKeyFor(trackName: String?, artistName: String?): String { - val track = normalizeLookupText(trackName) - if (track.isEmpty()) return "" - return "$track|${normalizeLookupText(artistName)}" - } - - private fun buildPathMatchKeys(filePath: String?): Set { - val raw = filePath?.trim().orEmpty() - if (raw.isEmpty()) return emptySet() - val cleaned = if (raw.startsWith("EXISTS:")) raw.substring(7).trim() else raw - if (cleaned.isEmpty()) return emptySet() - - val keys = linkedSetOf() - val visited = linkedSetOf() - // Parse the original URI once; repeated percent decoding changes - // opaque provider IDs containing literal percent escapes. - try { - val uri = Uri.parse(cleaned) - safDocumentMatchKey(uri.scheme, uri.authority, uri.pathSegments)?.let(keys::add) - } catch (_: IllegalArgumentException) { - } - - fun addNormalized(value: String) { - val trimmed = value.trim() - if (trimmed.isEmpty()) return - if (!visited.add(trimmed)) return - - keys.add(trimmed) - keys.add(trimmed.lowercase(Locale.ROOT)) - - if (trimmed.contains('\\')) { - val slash = trimmed.replace('\\', '/') - if (slash != trimmed) addNormalized(slash) - } - - if (trimmed.contains('%')) { - try { - val decoded = Uri.decode(trimmed) - if (decoded != trimmed) addNormalized(decoded) - } catch (_: Throwable) { - } - } - - val parsed = try { - Uri.parse(trimmed) - } catch (_: Throwable) { - null - } - if (parsed != null && !parsed.scheme.isNullOrEmpty()) { - val stripped = stripUriQueryAndFragment(trimmed) - keys.add(stripped) - keys.add(stripped.lowercase(Locale.ROOT)) - if (parsed.scheme.equals("file", ignoreCase = true)) { - parsed.path?.let { addNormalized(it) } - } - for (alias in androidExternalStorageDocumentPaths(parsed)) { - addNormalized(alias) - } - } else if (trimmed.startsWith("/")) { - try { - val asFileUri = Uri.fromFile(File(trimmed)).toString() - keys.add(asFileUri) - keys.add(asFileUri.lowercase(Locale.ROOT)) - } catch (_: Throwable) { - } - } - - for (alias in androidEquivalentPaths(trimmed)) { - if (alias != trimmed) addNormalized(alias) - } - } - - addNormalized(cleaned) - - val extensionStripped = linkedSetOf() - for (key in keys) { - stripAudioExtension(key)?.let { - if (it.isNotEmpty()) extensionStripped.add(it) - } - } - keys.addAll(extensionStripped) - return keys - } - - private fun androidExternalStorageDocumentPaths(uri: Uri): List { - if ( - !uri.scheme.equals("content", ignoreCase = true) || - !uri.authority.equals( - "com.android.externalstorage.documents", - ignoreCase = true, - ) - ) { - return emptyList() - } - - val segments = uri.pathSegments - val documentIndex = segments.indexOfLast { it == "document" } - val treeIndex = segments.indexOfLast { it == "tree" } - val idIndex = if (documentIndex >= 0) documentIndex + 1 else treeIndex + 1 - if (idIndex <= 0 || idIndex >= segments.size) return emptyList() - - val documentId = segments.subList(idIndex, segments.size).joinToString("/") - val separator = documentId.indexOf(':') - if ( - separator < 0 || - !documentId.substring(0, separator).equals("primary", ignoreCase = true) - ) { - return emptyList() - } - - val relativePath = documentId - .substring(separator + 1) - .replace('\\', '/') - .trimStart('/') - val suffix = if (relativePath.isEmpty()) "" else "/$relativePath" - return androidStoragePathAliases.map { "$it$suffix" } - } - - private fun stripUriQueryAndFragment(value: String): String { - val queryIndex = value.indexOf('?').let { if (it >= 0) it else value.length } - val fragmentIndex = value.indexOf('#').let { if (it >= 0) it else value.length } - val cut = minOf(queryIndex, fragmentIndex) - return value.substring(0, cut) - } - - private fun stripAudioExtension(path: String): String? { - val lower = path.lowercase(Locale.ROOT) - for (ext in audioExtensions) { - if (lower.endsWith(ext)) { - return path.substring(0, path.length - ext.length) - } - } - return null - } - - private fun androidEquivalentPaths(path: String): List { - val normalized = path.replace('\\', '/') - val lower = normalized.lowercase(Locale.ROOT) - var suffix: String? = null - for (prefix in androidStoragePathAliases) { - if (lower == prefix) { - suffix = "" - break - } - val withSlash = "$prefix/" - if (lower.startsWith(withSlash)) { - suffix = normalized.substring(prefix.length) - break - } - } - val resolvedSuffix = suffix ?: return emptyList() - return androidStoragePathAliases.map { "$it$resolvedSuffix" } - } - - private fun android.database.Cursor.getNullableString(index: Int): String? { - if (index < 0 || isNull(index)) return null - return getString(index) - } - - private fun ensureHistoryColumn(db: SQLiteDatabase, column: String, alterSql: String) { - db.rawQuery("PRAGMA table_info(history)", null).use { cursor -> - val nameIndex = cursor.getColumnIndex("name") - while (cursor.moveToNext()) { - if (nameIndex >= 0 && cursor.getString(nameIndex).equals(column, ignoreCase = true)) { - return - } - } - } - db.execSQL(alterSql) - } - private fun historyToJson(values: ContentValues): JSONObject { val json = JSONObject() fun putCamel(column: String, key: String) { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/NativeHistoryStore.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeHistoryStore.kt new file mode 100644 index 00000000..234b7136 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/NativeHistoryStore.kt @@ -0,0 +1,605 @@ +package com.zarz.spotiflac + +import android.content.ContentValues +import android.content.Context +import android.database.Cursor +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException +import android.net.Uri +import android.util.Log +import java.io.File +import java.util.Locale + +/** Background-safe history writes independent of audio finalization state. */ +internal object NativeHistoryStore { + private const val TAG = "NativeFinalizer" + // Keep this schema contract in sync with Dart HistoryDatabase before bumping either side. + const val HISTORY_SCHEMA_VERSION = 15 + + // One guarded native writer prevents interleaved transactions. Flutter/sqflite + // uses its own WAL connection, so configure the busy timeout once per connection. + private val historyDatabaseLock = Any() + private var historyDatabase: SQLiteDatabase? = null + private var historyDatabasePath = "" + private var historyDatabaseSchemaVersion = 0 + + private val historyColumns = linkedMapOf( + "id" to "TEXT PRIMARY KEY", + "track_name" to "TEXT NOT NULL", + "artist_name" to "TEXT NOT NULL", + "album_name" to "TEXT NOT NULL", + "album_artist" to "TEXT", + "cover_url" to "TEXT", + "file_path" to "TEXT NOT NULL", + "storage_mode" to "TEXT", + "download_tree_uri" to "TEXT", + "saf_relative_dir" to "TEXT", + "saf_file_name" to "TEXT", + "saf_repaired" to "INTEGER", + "service" to "TEXT NOT NULL", + "downloaded_at" to "TEXT NOT NULL", + "isrc" to "TEXT", + "spotify_id" to "TEXT", + "track_number" to "INTEGER", + "total_tracks" to "INTEGER", + "disc_number" to "INTEGER", + "total_discs" to "INTEGER", + "duration" to "INTEGER", + "release_date" to "TEXT", + "quality" to "TEXT", + "bit_depth" to "INTEGER", + "sample_rate" to "INTEGER", + "bitrate" to "INTEGER", + "format" to "TEXT", + "genre" to "TEXT", + "composer" to "TEXT", + "label" to "TEXT", + "copyright" to "TEXT", + "explicit" to "INTEGER NOT NULL DEFAULT 0", + "has_lyrics" to "INTEGER NOT NULL DEFAULT 0", + "lyrics_metadata_scan_version" to "INTEGER NOT NULL DEFAULT 0", + "has_replaygain" to "INTEGER NOT NULL DEFAULT 0", + "replaygain_metadata_scan_version" to "INTEGER NOT NULL DEFAULT 0", + "spotify_id_norm" to "TEXT", + "isrc_norm" to "TEXT", + "match_key" to "TEXT", + "album_key" to "TEXT", + "search_text" to "TEXT", + "sort_track" to "TEXT", + "sort_artist" to "TEXT", + "sort_album" to "TEXT", + "sort_album_artist" to "TEXT", + "sort_genre" to "TEXT", + "sort_release" to "TEXT", + "sort_added" to "INTEGER", + ) + private val migratedHistoryColumns = listOf( + "storage_mode", "download_tree_uri", "saf_relative_dir", "saf_file_name", "saf_repaired", + "composer", "total_tracks", "total_discs", "bitrate", "format", "spotify_id_norm", + "isrc_norm", "match_key", "album_key", "search_text", "sort_track", "sort_artist", + "sort_album", "sort_album_artist", "sort_genre", "sort_release", "sort_added", + "explicit", "has_lyrics", "lyrics_metadata_scan_version", "has_replaygain", + "replaygain_metadata_scan_version", + ) + private val androidStoragePathAliases = listOf( + "/storage/emulated/0", + "/storage/emulated/legacy", + "/storage/self/primary", + "/sdcard", + "/mnt/sdcard", + ) + private val audioExtensions = listOf( + ".flac", ".m4a", ".mp3", ".opus", ".ogg", ".wav", ".aac", ".mp4", + ) + + /** Adds lookup columns to [values] and writes the row and path keys in one transaction. */ + fun upsert(context: Context, values: ContentValues, deduplicateTrack: Boolean = true) { + putNormalizedHistoryColumns(values) + withHistoryDatabase(context) { db -> + val initializeSchema = historyDatabaseSchemaVersion != HISTORY_SCHEMA_VERSION + db.beginTransaction() + try { + if (initializeSchema) { + if (db.version > HISTORY_SCHEMA_VERSION) { + throw IllegalStateException( + "history schema v${db.version} is newer than native finalizer contract v$HISTORY_SCHEMA_VERSION" + ) + } + // Only older schemas need metadata backfills. v15 adds + // tree-independent document identities to the path index. + val needsBackfill = db.version < 13 + val needsPathBackfill = db.version < 15 + val definitions = historyColumns.entries.joinToString(", ") { (name, type) -> + "$name $type" + } + db.execSQL("CREATE TABLE IF NOT EXISTS history ($definitions)") + for (column in migratedHistoryColumns) ensureHistoryColumn(db, column) + ensureHistoryPathKeyTable(db) + if (needsBackfill) backfillNormalizedHistoryColumns(db) + if (needsPathBackfill) backfillHistoryPathKeys(db) + validateHistorySchema(db) + db.execSQL("CREATE INDEX IF NOT EXISTS idx_spotify_id ON history(spotify_id)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_isrc ON history(isrc)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_downloaded_at ON history(downloaded_at DESC)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_album ON history(album_name, album_artist)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_track_artist ON history(track_name, artist_name)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_spotify_id_norm ON history(spotify_id_norm)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_isrc_norm ON history(isrc_norm)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_match_key ON history(match_key)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_album_key ON history(album_key)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_added ON history(sort_added DESC, sort_track, id)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_track ON history(sort_track, sort_artist, id)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_artist ON history(sort_artist, sort_track, id)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_album ON history(sort_album, sort_track, id)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_genre ON history(sort_genre, sort_track, id)") + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_queue_release ON history(sort_release, sort_track, id)") + if (db.version < HISTORY_SCHEMA_VERSION) db.version = HISTORY_SCHEMA_VERSION + } + if (deduplicateTrack) deleteDuplicateHistoryRows(db, values) + db.insertWithOnConflict("history", null, values, SQLiteDatabase.CONFLICT_REPLACE) + replaceHistoryPathKeys(db, values.getAsString("id"), values.getAsString("file_path")) + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + if (initializeSchema) historyDatabaseSchemaVersion = HISTORY_SCHEMA_VERSION + } + } + + private inline fun withHistoryDatabase(context: Context, block: (SQLiteDatabase) -> T): T { + synchronized(historyDatabaseLock) { + val dbFile = File(File(context.applicationInfo.dataDir, "app_flutter"), "history.db") + dbFile.parentFile?.mkdirs() + val db = historyDatabase?.takeIf { + it.isOpen && historyDatabasePath == dbFile.absolutePath + } ?: run { + historyDatabase?.close() + val opened = SQLiteDatabase.openDatabase( + dbFile.absolutePath, + null, + SQLiteDatabase.OPEN_READWRITE or + SQLiteDatabase.CREATE_IF_NECESSARY or + SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING, + ) + try { + configureHistoryDatabase(opened) + } catch (e: Exception) { + opened.close() + throw e + } + historyDatabase = opened + historyDatabasePath = dbFile.absolutePath + historyDatabaseSchemaVersion = 0 + opened + } + return block(db) + } + } + + private fun configureHistoryDatabase(db: SQLiteDatabase) { + runHistoryPragma(db, "PRAGMA busy_timeout = 5000") + // CONFLICT_REPLACE must fire the history delete trigger so the + // external-content FTS index does not retain the replaced rowid. + runHistoryPragma(db, "PRAGMA recursive_triggers = ON") + runHistoryPragma(db, "PRAGMA synchronous = NORMAL") + runHistoryPragma(db, "PRAGMA journal_mode = WAL") + } + + private fun runHistoryPragma(db: SQLiteDatabase, sql: String) { + try { + db.rawQuery(sql, null).use { cursor -> + while (cursor.moveToNext()) { + // PRAGMA setters may return a row; consume it before closing the cursor. + } + } + } catch (e: SQLiteException) { + Log.w(TAG, "Unable to apply history database setting: $sql", e) + } + } + + private fun validateHistorySchema(db: SQLiteDatabase) { + val columns = mutableSetOf() + db.rawQuery("PRAGMA table_info(history)", null).use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + while (cursor.moveToNext()) { + if (nameIndex >= 0) columns.add(cursor.getString(nameIndex).lowercase(Locale.ROOT)) + } + } + val missing = historyColumns.keys.filterNot { columns.contains(it) } + if (missing.isNotEmpty()) { + throw IllegalStateException("history schema missing columns for native finalizer: ${missing.joinToString()}") + } + } + + private fun deleteDuplicateHistoryRows(db: SQLiteDatabase, values: ContentValues) { + val id = values.getAsString("id") ?: return + val duplicateIds = linkedSetOf() + val spotifyId = values.getAsString("spotify_id")?.trim().orEmpty() + val spotifyIdNorm = values.getAsString("spotify_id_norm")?.trim().orEmpty() + if (spotifyId.isNotEmpty() || spotifyIdNorm.isNotEmpty()) { + duplicateIds.addAll( + historyIdsForWhere( + db, + "(spotify_id = ? OR spotify_id_norm = ?) AND id <> ?", + arrayOf(spotifyId, spotifyIdNorm, id), + ) + ) + } + + val isrc = values.getAsString("isrc")?.trim().orEmpty() + val isrcNorm = values.getAsString("isrc_norm")?.trim().orEmpty() + if (isrc.isNotEmpty() || isrcNorm.isNotEmpty()) { + duplicateIds.addAll( + historyIdsForWhere( + db, + "(isrc = ? OR isrc_norm = ?) AND id <> ?", + arrayOf(isrc, isrcNorm, id), + ) + ) + } + + if (spotifyIdNorm.isEmpty() && isrcNorm.isEmpty()) { + val matchKey = values.getAsString("match_key")?.trim().orEmpty() + if (matchKey.isNotEmpty()) { + duplicateIds.addAll( + historyIdsForWhere(db, "match_key = ? AND id <> ?", arrayOf(matchKey, id)) + ) + } + } + if (duplicateIds.isEmpty()) return + deleteHistoryPathKeys(db, duplicateIds) + val placeholders = duplicateIds.joinToString(",") { "?" } + db.delete("history", "id IN ($placeholders)", duplicateIds.toTypedArray()) + } + + private fun historyIdsForWhere(db: SQLiteDatabase, where: String, args: Array): List { + val ids = mutableListOf() + db.query("history", arrayOf("id"), where, args, null, null, null).use { cursor -> + val idIndex = cursor.getColumnIndex("id") + while (cursor.moveToNext()) { + if (idIndex >= 0) ids.add(cursor.getString(idIndex)) + } + } + return ids + } + + private fun deleteHistoryPathKeys(db: SQLiteDatabase, ids: Collection) { + if (ids.isEmpty()) return + val placeholders = ids.joinToString(",") { "?" } + db.delete("history_path_keys", "item_id IN ($placeholders)", ids.toTypedArray()) + } + + private fun ensureHistoryPathKeyTable(db: SQLiteDatabase) { + db.execSQL( + """ + CREATE TABLE IF NOT EXISTS history_path_keys ( + item_id TEXT NOT NULL, + path_key TEXT NOT NULL, + PRIMARY KEY (item_id, path_key) + ) + """.trimIndent() + ) + db.execSQL("CREATE INDEX IF NOT EXISTS idx_history_path_keys_key ON history_path_keys(path_key)") + } + + private fun backfillNormalizedHistoryColumns(db: SQLiteDatabase) { + db.query( + "history", + arrayOf("id", "spotify_id", "isrc", "track_name", "artist_name", "album_name", "album_artist", "genre", "release_date", "downloaded_at"), + "spotify_id_norm IS NULL OR isrc_norm IS NULL OR match_key IS NULL OR album_key IS NULL OR search_text IS NULL OR sort_track IS NULL OR sort_release IS NULL OR sort_added IS NULL", + null, + null, + null, + null, + ).use { cursor -> + val idIndex = cursor.getColumnIndex("id") + val spotifyIndex = cursor.getColumnIndex("spotify_id") + val isrcIndex = cursor.getColumnIndex("isrc") + val trackIndex = cursor.getColumnIndex("track_name") + val artistIndex = cursor.getColumnIndex("artist_name") + val albumIndex = cursor.getColumnIndex("album_name") + val albumArtistIndex = cursor.getColumnIndex("album_artist") + val genreIndex = cursor.getColumnIndex("genre") + val releaseDateIndex = cursor.getColumnIndex("release_date") + val downloadedAtIndex = cursor.getColumnIndex("downloaded_at") + while (cursor.moveToNext()) { + if (idIndex < 0) continue + val values = ContentValues() + val spotifyId = cursor.getNullableString(spotifyIndex) + val isrc = cursor.getNullableString(isrcIndex) + val trackName = cursor.getNullableString(trackIndex) + val artistName = cursor.getNullableString(artistIndex) + val albumName = cursor.getNullableString(albumIndex) + val albumArtist = cursor.getNullableString(albumArtistIndex) + values.put("spotify_id_norm", normalizeLookupText(spotifyId)) + values.put("isrc_norm", normalizeIsrc(isrc)) + values.put("match_key", matchKeyFor(trackName, artistName)) + putAlbumSearchHistoryColumns(values, trackName, artistName, albumName, albumArtist) + putQueueSortHistoryColumns( + values, + trackName = trackName, + artistName = artistName, + albumName = albumName, + albumArtist = albumArtist, + genre = cursor.getNullableString(genreIndex), + releaseDate = cursor.getNullableString(releaseDateIndex), + downloadedAt = cursor.getNullableString(downloadedAtIndex), + ) + db.update("history", values, "id = ?", arrayOf(cursor.getString(idIndex))) + } + } + } + + private fun backfillHistoryPathKeys(db: SQLiteDatabase) { + db.query("history", arrayOf("id", "file_path"), null, null, null, null, null).use { cursor -> + val idIndex = cursor.getColumnIndex("id") + val pathIndex = cursor.getColumnIndex("file_path") + while (cursor.moveToNext()) { + if (idIndex >= 0) { + replaceHistoryPathKeys(db, cursor.getString(idIndex), cursor.getNullableString(pathIndex)) + } + } + } + } + + private fun replaceHistoryPathKeys(db: SQLiteDatabase, itemId: String?, filePath: String?) { + val id = itemId?.trim().orEmpty() + if (id.isEmpty()) return + db.delete("history_path_keys", "item_id = ?", arrayOf(id)) + for (key in buildPathMatchKeys(filePath)) { + val values = ContentValues() + values.put("item_id", id) + values.put("path_key", key) + db.insertWithOnConflict("history_path_keys", null, values, SQLiteDatabase.CONFLICT_IGNORE) + } + } + + private fun putNormalizedHistoryColumns(values: ContentValues) { + values.put("spotify_id_norm", normalizeLookupText(values.getAsString("spotify_id"))) + values.put("isrc_norm", normalizeIsrc(values.getAsString("isrc"))) + values.put( + "match_key", + matchKeyFor(values.getAsString("track_name"), values.getAsString("artist_name")), + ) + putAlbumSearchHistoryColumns( + values, + trackName = values.getAsString("track_name"), + artistName = values.getAsString("artist_name"), + albumName = values.getAsString("album_name"), + albumArtist = values.getAsString("album_artist"), + ) + putQueueSortHistoryColumns( + values, + trackName = values.getAsString("track_name"), + artistName = values.getAsString("artist_name"), + albumName = values.getAsString("album_name"), + albumArtist = values.getAsString("album_artist"), + genre = values.getAsString("genre"), + releaseDate = values.getAsString("release_date"), + downloadedAt = values.getAsString("downloaded_at"), + ) + } + + private fun putQueueSortHistoryColumns( + values: ContentValues, + trackName: String?, + artistName: String?, + albumName: String?, + albumArtist: String?, + genre: String?, + releaseDate: String?, + downloadedAt: String?, + ) { + values.put("sort_track", normalizeLookupText(trackName)) + values.put("sort_artist", normalizeLookupText(artistName)) + values.put("sort_album", normalizeLookupText(albumName)) + values.put( + "sort_album_artist", + normalizeLookupText(albumArtist?.takeIf { it.trim().isNotEmpty() } ?: artistName), + ) + values.put("sort_genre", normalizeLookupText(genre)) + values.put("sort_release", releaseDate?.trim().orEmpty()) + values.put("sort_added", parseHistoryTimestampMillis(downloadedAt)) + } + + private fun parseHistoryTimestampMillis(value: String?): Long { + val timestamp = value?.trim().orEmpty() + if (timestamp.isEmpty()) return 0L + return try { + java.time.Instant.parse(timestamp).toEpochMilli() + } catch (_: Exception) { + try { + java.time.OffsetDateTime.parse(timestamp).toInstant().toEpochMilli() + } catch (_: Exception) { + try { + java.time.LocalDateTime.parse(timestamp) + .atZone(java.time.ZoneId.systemDefault()) + .toInstant() + .toEpochMilli() + } catch (_: Exception) { + 0L + } + } + } + } + + private fun putAlbumSearchHistoryColumns( + values: ContentValues, + trackName: String?, + artistName: String?, + albumName: String?, + albumArtist: String?, + ) { + val track = normalizeLookupText(trackName) + val artist = normalizeLookupText(artistName) + val album = normalizeLookupText(albumName) + val resolvedAlbumArtist = normalizeLookupText( + albumArtist?.takeIf { it.trim().isNotEmpty() } ?: artistName, + ) + values.put("album_key", "$album|$resolvedAlbumArtist") + values.put( + "search_text", + listOf(track, artist, album, resolvedAlbumArtist) + .filter { it.isNotEmpty() } + .joinToString(" "), + ) + } + + private fun cleanMetadataString(value: String?): String { + val trimmed = value?.trim().orEmpty() + return if (trimmed.equals("null", ignoreCase = true)) "" else trimmed + } + + private fun normalizeLookupText(value: String?): String = + cleanMetadataString(value).lowercase(Locale.ROOT) + + private fun normalizeIsrc(value: String?): String = + cleanMetadataString(value) + .uppercase(Locale.ROOT) + .replace(Regex("[-\\s]"), "") + + private fun matchKeyFor(trackName: String?, artistName: String?): String { + val track = normalizeLookupText(trackName) + if (track.isEmpty()) return "" + return "$track|${normalizeLookupText(artistName)}" + } + + private fun buildPathMatchKeys(filePath: String?): Set { + val raw = filePath?.trim().orEmpty() + if (raw.isEmpty()) return emptySet() + val cleaned = if (raw.startsWith("EXISTS:")) raw.substring(7).trim() else raw + if (cleaned.isEmpty()) return emptySet() + + val keys = linkedSetOf() + val visited = linkedSetOf() + // Parse the original URI once; repeated percent decoding changes + // opaque provider IDs containing literal percent escapes. + try { + val uri = Uri.parse(cleaned) + safDocumentMatchKey(uri.scheme, uri.authority, uri.pathSegments)?.let(keys::add) + } catch (_: IllegalArgumentException) { + } + + fun addNormalized(value: String) { + val trimmed = value.trim() + if (trimmed.isEmpty()) return + if (!visited.add(trimmed)) return + keys.add(trimmed) + keys.add(trimmed.lowercase(Locale.ROOT)) + if (trimmed.contains('\\')) { + val slash = trimmed.replace('\\', '/') + if (slash != trimmed) addNormalized(slash) + } + if (trimmed.contains('%')) { + try { + val decoded = Uri.decode(trimmed) + if (decoded != trimmed) addNormalized(decoded) + } catch (_: Throwable) { + } + } + val parsed = try { + Uri.parse(trimmed) + } catch (_: Throwable) { + null + } + if (parsed != null && !parsed.scheme.isNullOrEmpty()) { + val stripped = stripUriQueryAndFragment(trimmed) + keys.add(stripped) + keys.add(stripped.lowercase(Locale.ROOT)) + if (parsed.scheme.equals("file", ignoreCase = true)) { + parsed.path?.let { addNormalized(it) } + } + for (alias in androidExternalStorageDocumentPaths(parsed)) addNormalized(alias) + } else if (trimmed.startsWith("/")) { + try { + val asFileUri = Uri.fromFile(File(trimmed)).toString() + keys.add(asFileUri) + keys.add(asFileUri.lowercase(Locale.ROOT)) + } catch (_: Throwable) { + } + } + for (alias in androidEquivalentPaths(trimmed)) { + if (alias != trimmed) addNormalized(alias) + } + } + + addNormalized(cleaned) + val extensionStripped = linkedSetOf() + for (key in keys) { + stripAudioExtension(key)?.let { + if (it.isNotEmpty()) extensionStripped.add(it) + } + } + keys.addAll(extensionStripped) + return keys + } + + private fun androidExternalStorageDocumentPaths(uri: Uri): List { + if ( + !uri.scheme.equals("content", ignoreCase = true) || + !uri.authority.equals("com.android.externalstorage.documents", ignoreCase = true) + ) { + return emptyList() + } + val segments = uri.pathSegments + val documentIndex = segments.indexOfLast { it == "document" } + val treeIndex = segments.indexOfLast { it == "tree" } + val idIndex = if (documentIndex >= 0) documentIndex + 1 else treeIndex + 1 + if (idIndex <= 0 || idIndex >= segments.size) return emptyList() + val documentId = segments.subList(idIndex, segments.size).joinToString("/") + val separator = documentId.indexOf(':') + if (separator < 0 || !documentId.substring(0, separator).equals("primary", ignoreCase = true)) { + return emptyList() + } + val relativePath = documentId.substring(separator + 1).replace('\\', '/').trimStart('/') + val suffix = if (relativePath.isEmpty()) "" else "/$relativePath" + return androidStoragePathAliases.map { "$it$suffix" } + } + + private fun stripUriQueryAndFragment(value: String): String { + val queryIndex = value.indexOf('?').let { if (it >= 0) it else value.length } + val fragmentIndex = value.indexOf('#').let { if (it >= 0) it else value.length } + return value.substring(0, minOf(queryIndex, fragmentIndex)) + } + + private fun stripAudioExtension(path: String): String? { + val lower = path.lowercase(Locale.ROOT) + for (ext in audioExtensions) { + if (lower.endsWith(ext)) return path.substring(0, path.length - ext.length) + } + return null + } + + private fun androidEquivalentPaths(path: String): List { + val normalized = path.replace('\\', '/') + val lower = normalized.lowercase(Locale.ROOT) + var suffix: String? = null + for (prefix in androidStoragePathAliases) { + if (lower == prefix) { + suffix = "" + break + } + if (lower.startsWith("$prefix/")) { + suffix = normalized.substring(prefix.length) + break + } + } + val resolvedSuffix = suffix ?: return emptyList() + return androidStoragePathAliases.map { "$it$resolvedSuffix" } + } + + private fun Cursor.getNullableString(index: Int): String? { + if (index < 0 || isNull(index)) return null + return getString(index) + } + + private fun ensureHistoryColumn(db: SQLiteDatabase, column: String) { + db.rawQuery("PRAGMA table_info(history)", null).use { cursor -> + val nameIndex = cursor.getColumnIndex("name") + while (cursor.moveToNext()) { + if (nameIndex >= 0 && cursor.getString(nameIndex).equals(column, ignoreCase = true)) return + } + } + db.execSQL("ALTER TABLE history ADD COLUMN $column ${historyColumns.getValue(column)}") + } +} diff --git a/test/models_and_utils_test.dart b/test/models_and_utils_test.dart index 71c80203..ed431100 100644 --- a/test/models_and_utils_test.dart +++ b/test/models_and_utils_test.dart @@ -428,6 +428,10 @@ void main() { 'android/app/src/main/kotlin/com/zarz/spotiflac/' 'NativeDownloadFinalizer.kt', ).readAsStringSync(); + final historyStoreSource = File( + 'android/app/src/main/kotlin/com/zarz/spotiflac/' + 'NativeHistoryStore.kt', + ).readAsStringSync(); final historyDatabaseSource = File( 'lib/services/history_database.dart', ).readAsStringSync(); @@ -439,24 +443,22 @@ void main() { 'lib/providers/download_queue_provider_native_worker.dart', ).readAsStringSync(); - int kotlinConstant(String name) { - final match = RegExp( - 'const val $name = (\\d+)', - ).firstMatch(finalizerSource); + int kotlinConstant(String name, String source) { + final match = RegExp('const val $name = (\\d+)').firstMatch(source); expect(match, isNotNull, reason: 'Missing Kotlin constant $name'); return int.parse(match!.group(1)!); } test('uses the same worker contract version in Dart and Kotlin', () { expect( - kotlinConstant('NATIVE_WORKER_CONTRACT_VERSION'), + kotlinConstant('NATIVE_WORKER_CONTRACT_VERSION', finalizerSource), DownloadRequestPayload.nativeWorkerContractVersion, ); }); test('uses the same history schema version in Dart and Kotlin', () { expect( - kotlinConstant('HISTORY_SCHEMA_VERSION'), + kotlinConstant('HISTORY_SCHEMA_VERSION', historyStoreSource), HistoryDatabase.schemaVersion, ); }); @@ -491,17 +493,17 @@ void main() { }, ); - Set historyTableColumns(String source) { + Map historyTableDefinitions(String source) { final match = RegExp( r'CREATE TABLE(?: IF NOT EXISTS)? history\s*\(([\s\S]*?)\n\s*\)', ).firstMatch(source); expect(match, isNotNull, reason: 'Missing history CREATE TABLE'); - return match! - .group(1)! - .split(',') - .map((definition) => definition.trim().split(RegExp(r'\s+')).first) - .where((column) => column.isNotEmpty) - .toSet(); + final definitions = {}; + for (final definition in match!.group(1)!.split(',')) { + final tokens = definition.trim().split(RegExp(r'\s+')); + definitions[tokens.first] = tokens.skip(1).join(' '); + } + return definitions; } Map historyIndexes(String source) { @@ -520,18 +522,18 @@ void main() { } test('uses the same history columns in Dart and native writers', () { - final dartColumns = historyTableColumns(historyDatabaseSource); - final nativeColumns = historyTableColumns(finalizerSource); - expect(nativeColumns, dartColumns); - - final requiredBlock = RegExp( - r'requiredHistoryColumns\s*=\s*setOf\(([\s\S]*?)\n\s*\)', - ).firstMatch(finalizerSource); - expect(requiredBlock, isNotNull); - final requiredColumns = RegExp( - r'"([a-z0-9_]+)"', - ).allMatches(requiredBlock!.group(1)!).map((m) => m.group(1)!).toSet(); - expect(requiredColumns, dartColumns); + final dartDefinitions = historyTableDefinitions(historyDatabaseSource); + final nativeBlock = RegExp( + r'historyColumns\s*=\s*linkedMapOf\(([\s\S]*?)\n\s*\)', + ).firstMatch(historyStoreSource); + expect(nativeBlock, isNotNull, reason: 'Missing native history schema'); + final nativeDefinitions = { + for (final match in RegExp( + r'"([a-z0-9_]+)"\s+to\s+"([^"]+)"', + ).allMatches(nativeBlock!.group(1)!)) + match.group(1)!: match.group(2)!, + }; + expect(nativeDefinitions, dartDefinitions); final buildHistoryRow = RegExp( r'private fun buildHistoryRow\([\s\S]*?return values', @@ -541,7 +543,7 @@ void main() { r'values\.put\("([a-z0-9_]+)"', ).allMatches(buildHistoryRow!.group(0)!).map((m) => m.group(1)!).toSet(); expect( - dartColumns, + dartDefinitions.keys, containsAll(nativeWrittenColumns), reason: 'Native finalizer writes a column missing from Dart schema', ); @@ -549,7 +551,7 @@ void main() { test('uses the same history indexes in Dart and native writers', () { expect( - historyIndexes(finalizerSource), + historyIndexes(historyStoreSource), historyIndexes(historyDatabaseSource), ); });