From 9d732f42c91bfa28282361be7e4b3756ba5ee34c Mon Sep 17 00:00:00 2001 From: bxkr Date: Tue, 8 Sep 2026 11:37:04 +0300 Subject: [PATCH 1/4] Add "Use folder cover images" setting --- app/src/main/AndroidManifest.xml | 3 + .../pixelplayeross/PixelPlayerApplication.kt | 3 + .../data/media/ImageCacheManager.kt | 14 +++++ .../preferences/UserPreferencesRepository.kt | 18 ++++++ .../screens/SettingsCategoryScreen.kt | 57 ++++++++++++++++++- .../settings/search/SettingsRegistry.kt | 14 +++++ .../viewmodel/SettingsViewModel.kt | 21 +++++++ .../pixelplayeross/utils/AlbumArtUtils.kt | 49 +++++++++++++++- .../main/res/values-ar/strings_settings.xml | 4 ++ .../main/res/values-de/strings_settings.xml | 4 ++ .../main/res/values-es/strings_settings.xml | 4 ++ .../main/res/values-fr/strings_settings.xml | 4 ++ .../main/res/values-in/strings_settings.xml | 4 ++ .../main/res/values-it/strings_settings.xml | 4 ++ .../main/res/values-ko/strings_settings.xml | 4 ++ .../main/res/values-nb/strings_settings.xml | 4 ++ .../main/res/values-ru/strings_settings.xml | 4 ++ .../main/res/values-tr/strings_settings.xml | 4 ++ .../res/values-zh-rCN/strings_settings.xml | 4 ++ app/src/main/res/values/strings_settings.xml | 4 ++ .../pixelplayeross/utils/AlbumArtUtilsTest.kt | 57 +++++++++++++++++++ 21 files changed, 281 insertions(+), 3 deletions(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 098325ab..ff32c855 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -12,6 +12,9 @@ android:name="android.permission.BLUETOOTH_SCAN" android:usesPermissionFlags="neverForLocation" /> + + diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt index c44b5cf0..abc2c05f 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt @@ -133,6 +133,9 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. if (savedLimit != null) { AlbumArtCacheManager.configuredCacheLimitMb = savedLimit.toLong() } + AlbumArtUtils.folderAlbumArtEnabled = runCatching { + userPreferencesRepository.get().useFolderAlbumArtFlow.first() + }.getOrDefault(false) } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/ImageCacheManager.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/ImageCacheManager.kt index 9f9d4904..6e730944 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/ImageCacheManager.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/media/ImageCacheManager.kt @@ -12,6 +12,20 @@ import javax.inject.Singleton class ImageCacheManager @Inject constructor( @ApplicationContext private val context: Context ) { + /** + * Drops every decoded and on-disk image Coil holds. + * + * Needed when artwork changes for the whole library at once and there is no list of affected + * URIs to hand to [invalidateCoverArtCaches] — the per-URI path can only guess at Coil's + * size-suffixed memory keys, so it cannot be trusted for a library-wide change. + */ + @OptIn(ExperimentalCoilApi::class) + fun clearAllCoverArtCaches() { + val imageLoader = context.imageLoader + imageLoader.memoryCache?.clear() + imageLoader.diskCache?.clear() + } + @OptIn(ExperimentalCoilApi::class) fun invalidateCoverArtCaches(vararg uriStrings: String?) { val imageLoader = context.imageLoader diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt index f7c4e1fe..56087e58 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt @@ -211,6 +211,7 @@ constructor( val ALBUM_ART_QUALITY = stringPreferencesKey("album_art_quality") val ALBUM_ART_CACHE_LIMIT_MB = intPreferencesKey("album_art_cache_limit_mb") + val USE_FOLDER_ALBUM_ART = booleanPreferencesKey("use_folder_album_art") val TAP_BACKGROUND_CLOSES_PLAYER = booleanPreferencesKey("tap_background_closes_player") val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") val ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED = @@ -1600,6 +1601,23 @@ constructor( } } + /** + * Whether a cover image sitting next to the audio file (cover.jpg, folder.jpg, …) should be + * used as album art in preference to embedded artwork. Off by default: folder images are only + * trusted when the user explicitly opts in, because generic directories can hold unrelated + * pictures. See [com.lostf1sh.pixelplayeross.utils.AlbumArtUtils.findExternalAlbumArtFile]. + */ + val useFolderAlbumArtFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.USE_FOLDER_ALBUM_ART] ?: false + } + + suspend fun setUseFolderAlbumArt(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.USE_FOLDER_ALBUM_ART] = enabled + } + } + /** * Whether tapping the background area of the player sheet closes it. * Default is false to avoid accidental dismissals while interacting with the full player. diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt index b480a6f2..f459e3b4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt @@ -11,11 +11,15 @@ import androidx.compose.ui.draw.rotate import android.app.Activity import android.content.Context import android.net.Uri +import android.Manifest import android.os.Build import android.os.Environment import android.os.SystemClock import android.text.format.Formatter import android.widget.Toast +import com.google.accompanist.permissions.ExperimentalPermissionsApi +import com.google.accompanist.permissions.isGranted +import com.google.accompanist.permissions.rememberPermissionState import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent @@ -63,6 +67,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.ClearAll import androidx.compose.material.icons.outlined.Folder +import androidx.compose.material.icons.outlined.Image import androidx.compose.material.icons.outlined.Info import androidx.compose.material.icons.outlined.Language import androidx.compose.material.icons.outlined.LightMode @@ -185,7 +190,11 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @androidx.annotation.OptIn(UnstableApi::class) -@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class) +@OptIn( + ExperimentalMaterial3Api::class, + ExperimentalMaterial3ExpressiveApi::class, + ExperimentalPermissionsApi::class +) @Composable fun SettingsCategoryScreen( categoryId: String, @@ -242,6 +251,44 @@ fun SettingsCategoryScreen( mutableStateOf(uiState.albumArtCacheLimitMb.toFloat()) } + val useFolderAlbumArt by settingsViewModel.useFolderAlbumArt.collectAsStateWithLifecycle() + + // Reading a cover image next to an audio file needs READ_MEDIA_IMAGES on API 33+; + // READ_MEDIA_AUDIO covers audio files only. Below that, READ_EXTERNAL_STORAGE (already + // granted during setup) is enough, so there is nothing to request. + // + // The result is taken from the request callback rather than by observing the granted state, + // because a denial leaves that state unchanged and would report nothing back to the user. A + // partial "Select photos" grant on API 34+ also arrives here as "not granted", which is + // correct: it gives no access to arbitrary music folders, so the setting must stay off. + val folderArtRefreshingMessage = stringResource(R.string.setcat_folder_album_art_refreshing) + val folderArtPermissionMessage = + stringResource(R.string.setcat_folder_album_art_permission_required) + val enableFolderAlbumArt: () -> Unit = { + settingsViewModel.setUseFolderAlbumArt(true) + Toast.makeText(context, folderArtRefreshingMessage, Toast.LENGTH_SHORT).show() + } + val imagesPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + rememberPermissionState(Manifest.permission.READ_MEDIA_IMAGES) { granted -> + if (granted) { + enableFolderAlbumArt() + } else { + Toast.makeText(context, folderArtPermissionMessage, Toast.LENGTH_LONG).show() + } + } + } else { + null + } + val onFolderAlbumArtToggled: (Boolean) -> Unit = { enabled -> + when { + !enabled -> settingsViewModel.setUseFolderAlbumArt(false) + // Null below API 33, where no image permission exists to request. + imagesPermissionState == null || imagesPermissionState.status.isGranted -> + enableFolderAlbumArt() + else -> imagesPermissionState.launchPermissionRequest() + } + } + val exportLauncher = rememberLauncherForActivityResult( contract = ActivityResultContracts.CreateDocument("application/octet-stream") ) { uri -> @@ -510,6 +557,14 @@ fun SettingsCategoryScreen( leadingIcon = { Icon(Icons.Outlined.Folder, null, tint = MaterialTheme.colorScheme.secondary) }, modifier = Modifier.settingHighlight("item_library_auto_scan_lrc", highlightKey) ) + SwitchSettingItem( + title = stringResource(R.string.setcat_folder_album_art_title), + subtitle = stringResource(R.string.setcat_folder_album_art_subtitle), + checked = useFolderAlbumArt, + onCheckedChange = onFolderAlbumArtToggled, + leadingIcon = { Icon(Icons.Outlined.Image, null, tint = MaterialTheme.colorScheme.secondary) }, + modifier = Modifier.settingHighlight("item_library_folder_album_art", highlightKey) + ) SettingsItem( title = stringResource(R.string.setcat_find_duplicates_title), subtitle = stringResource(R.string.setcat_find_duplicates_subtitle), diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt index c5bb0834..7eb5294d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt @@ -98,6 +98,20 @@ object SettingsRegistry { getValue = { it.autoScanLrcFiles }, onToggle = { viewModel, checked -> viewModel.setAutoScanLrcFiles(checked) } ), + SettingSpec( + id = "library_folder_album_art", + itemKey = "item_library_folder_album_art", + titleRes = R.string.setcat_folder_album_art_title, + subtitleRes = R.string.setcat_folder_album_art_subtitle, + category = SettingsCategory.LIBRARY, + subscreenRoute = Screen.SettingsCategory.createRoute("library"), + // Navigates instead of toggling in place: enabling needs a READ_MEDIA_IMAGES + // prompt, which only the settings screen can launch. + type = SettingType.NAVIGABLE_CARD, + keywordsStatic = listOf( + "cover", "cover.jpg", "folder.jpg", "album art", "artwork", "image", "folder" + ) + ), SettingSpec( id = "library_find_duplicates", itemKey = "item_library_find_duplicates", diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt index 3281486c..458d0797 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt @@ -21,6 +21,7 @@ import com.lostf1sh.pixelplayeross.data.preferences.CarouselStyle import com.lostf1sh.pixelplayeross.data.preferences.LibraryNavigationMode import com.lostf1sh.pixelplayeross.data.preferences.ThemePreference import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository +import com.lostf1sh.pixelplayeross.data.media.ImageCacheManager import com.lostf1sh.pixelplayeross.data.preferences.AlbumArtQuality import com.lostf1sh.pixelplayeross.data.preferences.AlbumArtColorAccuracy import com.lostf1sh.pixelplayeross.data.preferences.AlbumArtPaletteStyle @@ -181,6 +182,7 @@ class SettingsViewModel @Inject constructor( private val lyricsRepository: LyricsRepository, private val musicRepository: MusicRepository, private val backupManager: BackupManager, + private val imageCacheManager: ImageCacheManager, @ApplicationContext private val context: Context ) : ViewModel() { @@ -902,6 +904,9 @@ class SettingsViewModel @Inject constructor( val tapBackgroundClosesPlayer: StateFlow = userPreferencesRepository.tapBackgroundClosesPlayerFlow .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), true) + val useFolderAlbumArt: StateFlow = userPreferencesRepository.useFolderAlbumArtFlow + .stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), false) + fun setAlbumArtQuality(quality: AlbumArtQuality) { viewModelScope.launch { userPreferencesRepository.setAlbumArtQuality(quality) @@ -915,6 +920,22 @@ class SettingsViewModel @Inject constructor( } } + /** + * Folder covers outrank embedded artwork, so flipping this in either direction invalidates + * every cached cover and every "no art" marker: they were all resolved under the old + * precedence. The rescan then rebuilds each song's stored artwork URI. + */ + fun setUseFolderAlbumArt(enabled: Boolean) { + viewModelScope.launch { + if (enabled == useFolderAlbumArt.value) return@launch + userPreferencesRepository.setUseFolderAlbumArt(enabled) + com.lostf1sh.pixelplayeross.utils.AlbumArtUtils.folderAlbumArtEnabled = enabled + com.lostf1sh.pixelplayeross.utils.AlbumArtCacheManager.clearAllCache(context) + imageCacheManager.clearAllCoverArtCaches() + syncManager.fullSync() + } + } + fun setUseSmoothCorners(enabled: Boolean) { viewModelScope.launch { userPreferencesRepository.setUseSmoothCorners(enabled) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt index ae655914..f810e145 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt @@ -36,6 +36,20 @@ object AlbumArtUtils { // background on first access (one-time migration for art cached before bounding existed). private const val OVERSIZED_CACHED_ART_BYTES = 900L * 1024 + // Folder covers are arbitrary user files with no size ceiling, unlike embedded pictures which + // a tagger already had to fit into the audio file. Refuse absurd ones before reading them + // whole into memory to be bounded. + private const val MAX_EXTERNAL_ART_BYTES = 20L * 1024 * 1024 + + /** + * Mirrors `UserPreferencesRepository.useFolderAlbumArtFlow`. AlbumArtUtils is a plain object + * reached from a Coil fetcher and a ContentProvider, so it cannot take a DI dependency; the + * value is pushed in from PixelPlayerApplication at startup and from SettingsViewModel on + * change, the same way as [AlbumArtCacheManager.configuredCacheLimitMb]. + */ + @Volatile + var folderAlbumArtEnabled: Boolean = false + private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // Tracks cache files currently being shrunk so rapid repeated loads of the same oversized // cover don't read the large blob into memory more than once concurrently. @@ -70,8 +84,9 @@ object AlbumArtUtils { /** * Main function to get album art for local songs. * - * Local artwork is intentionally embedded-only. Falling back to folder images such as - * cover.jpg/thumb.jpg can pick unrelated Gallery files when music is stored in mixed + * Local artwork is embedded-only unless the user opts in to folder covers via + * [folderAlbumArtEnabled]. That opt-in exists because falling back to folder images such as + * cover.jpg/thumb.jpg unasked can pick unrelated Gallery files when music is stored in mixed * directories, and can duplicate the same image across unrelated tracks. */ fun getAlbumArtUri( @@ -181,6 +196,13 @@ object AlbumArtUtils { return null } + // Folder covers outrank embedded pictures when the user has opted in: a cover.jpg is + // usually the full-resolution original, while embedded art is often a downscaled copy. + readExternalAlbumArtBytes(resolvedPath)?.let { bytes -> + cacheAlbumArtBytes(appContext, bytes, songId) + return cachedFile.takeIf { it.exists() && it.length() > 0 } + } + extractEmbeddedAlbumArtBytes(resolvedPath)?.let { bytes -> cacheAlbumArtBytes(appContext, bytes, songId) return cachedFile.takeIf { it.exists() && it.length() > 0 } @@ -240,6 +262,11 @@ object AlbumArtUtils { noArtFile.delete() } + if (readExternalAlbumArtBytes(filePath) != null) { + noArtFile.delete() + return true + } + val hasEmbeddedArt = extractEmbeddedAlbumArtBytes(filePath)?.isNotEmpty() == true if (hasEmbeddedArt) { noArtFile.delete() @@ -263,6 +290,24 @@ object AlbumArtUtils { }.getOrNull() } + /** + * Artwork bytes from a cover image sitting next to the audio file, or null when the user has + * not opted in, no trusted cover exists, or the candidate is too large to bound safely. + * + * On API 33+ reading these needs READ_MEDIA_IMAGES: without it [findExternalAlbumArtFile]'s + * `exists()` check simply fails, so this degrades to null rather than throwing. + */ + internal fun readExternalAlbumArtBytes( + filePath: String, + enabled: Boolean = folderAlbumArtEnabled, + maxBytes: Long = MAX_EXTERNAL_ART_BYTES + ): ByteArray? { + if (!enabled) return null + val artFile = findExternalAlbumArtFile(filePath) ?: return null + if (artFile.length() > maxBytes) return null + return runCatching { artFile.readBytes() }.getOrNull()?.takeIf { it.isNotEmpty() } + } + internal fun findExternalAlbumArtFile(filePath: String): File? { val audioFile = File(filePath) val directory = audioFile.parentFile ?: return null diff --git a/app/src/main/res/values-ar/strings_settings.xml b/app/src/main/res/values-ar/strings_settings.xml index bd052591..5f74068b 100644 --- a/app/src/main/res/values-ar/strings_settings.xml +++ b/app/src/main/res/values-ar/strings_settings.xml @@ -83,6 +83,10 @@ تغيير أنماط التجميعة بشكل دوري في كل مرة تزور فيها الصفحة الرئيسية. تدوير الأنماط تلقائياً فحص وتعيين ملفات الكلمات المزامنة (.lrc) المتواجدة في نفس المجلد تلقائياً أثناء المزامنة. + استخدام صور الغلاف من المجلد + استخدام cover.jpg أو folder.jpg أو صورة مشابهة من مجلد الأغنية كغلاف للألبوم، مع تفضيلها على الغلاف المضمّن. يتم تجاهل المجلدات المشتركة مثل Music و Downloads و DCIM و Pictures. + يلزم الوصول إلى الصور لقراءة صور الغلاف من مجلدات الموسيقى. + جارٍ تحديث أغلفة الألبومات… فحص تلقائي لملفات .lrc التشغيل في الخلفية بدون إلقاء نظرة خاطفة diff --git a/app/src/main/res/values-de/strings_settings.xml b/app/src/main/res/values-de/strings_settings.xml index 89049ed2..60d9c617 100644 --- a/app/src/main/res/values-de/strings_settings.xml +++ b/app/src/main/res/values-de/strings_settings.xml @@ -83,6 +83,10 @@ Bei jedem Home-Besuch ein anderes Collage-Muster zeigen. Muster automatisch wechseln .lrc-Dateien im selben Ordner werden beim Sync automatisch erkannt und zugewiesen. + Ordner-Coverbilder verwenden + cover.jpg, folder.jpg oder ein ähnliches Bild aus dem Ordner eines Titels wird als Albumcover verwendet, bevorzugt gegenüber eingebetteten Covern. Gemeinsam genutzte Ordner wie Music, Downloads, DCIM und Pictures werden ignoriert. + Für das Lesen von Coverbildern aus deinen Musikordnern wird die Berechtigung für Fotos benötigt. + Albumcover werden aktualisiert… .lrc-Dateien automatisch scannen Hintergrundwiedergabe Kein Peek diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index 2d1546df..f25e2a7e 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -83,6 +83,10 @@ Ciclar a través de los patrones de collage cada vez que visitas el Inicio. Rotar patrones automáticamente Buscar y asignar automáticamente archivos .lrc en la misma carpeta durante la sincronización de la biblioteca. + Usar imágenes de portada de la carpeta + Usar cover.jpg, folder.jpg o una imagen similar de la carpeta de cada canción como carátula, en lugar de la carátula incrustada. Se ignoran las carpetas compartidas como Music, Downloads, DCIM y Pictures. + Se necesita acceso a las fotos para leer las imágenes de portada de tus carpetas de música. + Actualizando carátulas… Escanear archivos .lrc automáticamente Reproducción en segundo plano Sin vista lateral diff --git a/app/src/main/res/values-fr/strings_settings.xml b/app/src/main/res/values-fr/strings_settings.xml index 187db448..12c00d36 100644 --- a/app/src/main/res/values-fr/strings_settings.xml +++ b/app/src/main/res/values-fr/strings_settings.xml @@ -83,6 +83,10 @@ Changer de motif de pêle-mêle à chaque visite de l\'Accueil. Rotation auto des motifs Analyser et assigner automatiquement les fichiers .lrc du même dossier lors de la synchro. + Utiliser les pochettes du dossier + Utiliser cover.jpg, folder.jpg ou une image similaire du dossier d\'un morceau comme pochette, de préférence à la pochette intégrée. Les dossiers partagés comme Music, Downloads, DCIM et Pictures sont ignorés. + L\'accès aux photos est nécessaire pour lire les images de pochette dans vos dossiers de musique. + Actualisation des pochettes… Analyse auto des fichiers .lrc Lecture en arrière-plan Sans aperçu diff --git a/app/src/main/res/values-in/strings_settings.xml b/app/src/main/res/values-in/strings_settings.xml index 766b3961..3d6b6007 100644 --- a/app/src/main/res/values-in/strings_settings.xml +++ b/app/src/main/res/values-in/strings_settings.xml @@ -83,6 +83,10 @@ Berganti-ganti pola kolase setiap kali Anda mengunjungi Beranda. Putar Pola Otomatis Secara otomatis memindai dan menetapkan file .lrc di folder yang sama selama sinkronisasi pustaka. + Gunakan gambar sampul dari folder + Gunakan cover.jpg, folder.jpg, atau gambar serupa dari folder lagu sebagai sampul album, didahulukan daripada sampul yang tertanam. Folder bersama seperti Music, Downloads, DCIM, dan Pictures diabaikan. + Akses foto diperlukan untuk membaca gambar sampul dari folder musik Anda. + Memperbarui sampul album… Pindai otomatis file .lrc Pemutaran Latar Belakang Tanpa Intip diff --git a/app/src/main/res/values-it/strings_settings.xml b/app/src/main/res/values-it/strings_settings.xml index 6941c270..c5a2c434 100644 --- a/app/src/main/res/values-it/strings_settings.xml +++ b/app/src/main/res/values-it/strings_settings.xml @@ -83,6 +83,10 @@ Cicla tra i pattern collage ogni volta che visiti Home. Rotazione automatica pattern Scansiona e assegna automaticamente file .lrc nella stessa cartella durante la sincronizzazione libreria. + Usa le copertine della cartella + Usa cover.jpg, folder.jpg o un\'immagine simile dalla cartella del brano come copertina dell\'album, con precedenza sulla copertina incorporata. Le cartelle condivise come Music, Downloads, DCIM e Pictures vengono ignorate. + L\'accesso alle foto è necessario per leggere le copertine dalle tue cartelle musicali. + Aggiornamento copertine… Scansione automatica file .lrc Riproduzione in background Nessuna anteprima diff --git a/app/src/main/res/values-ko/strings_settings.xml b/app/src/main/res/values-ko/strings_settings.xml index d0d3e87d..368c785d 100644 --- a/app/src/main/res/values-ko/strings_settings.xml +++ b/app/src/main/res/values-ko/strings_settings.xml @@ -83,6 +83,10 @@ 홈을 방문할 때마다 콜라주 패턴을 순환합니다. 패턴 자동 회전 라이브러리 동기화 중 같은 폴더에 있는 .lrc 파일을 자동으로 스캔하여 할당합니다. + 폴더의 커버 이미지 사용 + 곡이 있는 폴더의 cover.jpg, folder.jpg 등의 이미지를 내장 아트워크보다 우선해 앨범 아트로 사용합니다. Music, Downloads, DCIM, Pictures 같은 공용 폴더는 무시됩니다. + 음악 폴더의 커버 이미지를 읽으려면 사진 접근 권한이 필요합니다. + 앨범 아트를 새로 고치는 중… .lrc 파일 자동 스캔 백그라운드 재생 미리보기 없음 diff --git a/app/src/main/res/values-nb/strings_settings.xml b/app/src/main/res/values-nb/strings_settings.xml index 07c4c606..d0b8b4fc 100644 --- a/app/src/main/res/values-nb/strings_settings.xml +++ b/app/src/main/res/values-nb/strings_settings.xml @@ -83,6 +83,10 @@ Bytt mønster hver gang du besøker Hjem-skjermen. Roter mønstre automatisk Skann og tildel .lrc-filer i samme mappe automatisk under synkronisering. + Bruk omslagsbilder fra mappen + Bruk cover.jpg, folder.jpg eller et lignende bilde fra sangens egen mappe som albumbilde, foran innebygd omslag. Delte mapper som Music, Downloads, DCIM og Pictures ignoreres. + Tilgang til bilder kreves for å lese omslagsbilder fra musikkmappene dine. + Oppdaterer albumbilder… Skann .lrc-filer automatisk Bakgrunnsavspilling Ingen kikk diff --git a/app/src/main/res/values-ru/strings_settings.xml b/app/src/main/res/values-ru/strings_settings.xml index f45cad05..d0751ce6 100644 --- a/app/src/main/res/values-ru/strings_settings.xml +++ b/app/src/main/res/values-ru/strings_settings.xml @@ -83,6 +83,10 @@ Менять шаблон коллажа при каждом открытии главной страницы. Автосмена шаблонов Автоматически искать и привязывать .lrc-файлы из той же папки во время синхронизации. + Использовать обложки из папки + Использовать cover.jpg, folder.jpg или похожее изображение из папки трека в качестве обложки вместо встроенной. Общие папки вроде Music, Downloads, DCIM и Pictures игнорируются. + Для чтения обложек из ваших музыкальных папок нужен доступ к фото. + Обновление обложек… Автосканирование .lrc-файлов Фоновое воспроизведение Без предпросмотра diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index 6e033861..403f0236 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -93,6 +93,10 @@ Ana Sayfa\'yı her ziyaret ettiğinizde kolaj desenleri arasında geçiş yapın. Desenleri Otomatik Döndür Kitaplık senkronizasyonu sırasında aynı klasördeki .lrc dosyalarını otomatik olarak tara ve ata. + Klasördeki kapak görsellerini kullan + Parçanın kendi klasöründeki cover.jpg, folder.jpg veya benzeri bir görseli, gömülü kapak yerine albüm kapağı olarak kullan. Music, Downloads, DCIM ve Pictures gibi ortak klasörler yok sayılır. + Müzik klasörlerinizdeki kapak görsellerini okumak için fotoğraf erişimi gerekir. + Albüm kapakları yenileniyor… .lrc dosyalarını otomatik tara Arka Planda Oynatma Göz Atma Yok diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index efd0cf57..fc05b149 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -52,6 +52,10 @@ 开始完整重新扫描… 自动扫描.lrc歌词文件 曲库同步时自动扫描同目录下的lrc歌词并绑定歌曲 + 使用文件夹中的封面图片 + 优先使用歌曲所在文件夹中的 cover.jpg、folder.jpg 等图片作为专辑封面,而非内嵌封面。Music、Downloads、DCIM 和 Pictures 等共用文件夹会被忽略。 + 读取音乐文件夹中的封面图片需要照片访问权限。 + 正在刷新专辑封面… 可选在线服务 来自 LRCLIB 的在线歌词 本地无歌词时允许从 lrclib.net 搜索、手动获取歌词 diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index feeb7469..e70e1486 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -60,6 +60,10 @@ Full rescan started… Auto-scan .lrc files Automatically scan and assign .lrc files in the same folder during library sync. + Use folder cover images + Use cover.jpg, folder.jpg or a similar image from a song\'s own folder as its album art, in preference to embedded artwork. Shared folders such as Music, Downloads, DCIM and Pictures are ignored. + Photo access is required to read cover images from your music folders. + Refreshing album art… Optional online services Online lyrics from LRCLIB Allow searches and manual fetches from lrclib.net when lyrics are missing locally. diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtilsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtilsTest.kt index 8328304c..b0245251 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtilsTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtilsTest.kt @@ -57,4 +57,61 @@ class AlbumArtUtilsTest { assertThat(resolved).isNull() root.deleteRecursively() } + + @Test + fun readExternalAlbumArtBytes_returnsCoverBytesWhenEnabled() { + val root = createTempDirectory("album-art-test").toFile() + val albumDir = root.resolve("Daft Punk - Discovery").apply { mkdirs() } + val songFile = albumDir.resolve("One More Time.mp3").apply { writeBytes(byteArrayOf(1, 2, 3)) } + val coverBytes = ByteArray(2048) { 7 } + albumDir.resolve("cover.jpg").writeBytes(coverBytes) + + val resolved = AlbumArtUtils.readExternalAlbumArtBytes(songFile.absolutePath, enabled = true) + + assertThat(resolved).isEqualTo(coverBytes) + root.deleteRecursively() + } + + @Test + fun readExternalAlbumArtBytes_returnsNullWhenDisabled() { + val root = createTempDirectory("album-art-test").toFile() + val albumDir = root.resolve("Daft Punk - Discovery").apply { mkdirs() } + val songFile = albumDir.resolve("Aerodynamic.mp3").apply { writeBytes(byteArrayOf(1, 2, 3)) } + albumDir.resolve("cover.jpg").writeBytes(ByteArray(2048) { 7 }) + + val resolved = AlbumArtUtils.readExternalAlbumArtBytes(songFile.absolutePath, enabled = false) + + assertThat(resolved).isNull() + root.deleteRecursively() + } + + @Test + fun readExternalAlbumArtBytes_returnsNullWhenCoverExceedsSizeLimit() { + val root = createTempDirectory("album-art-test").toFile() + val albumDir = root.resolve("Daft Punk - Discovery").apply { mkdirs() } + val songFile = albumDir.resolve("Digital Love.mp3").apply { writeBytes(byteArrayOf(1, 2, 3)) } + albumDir.resolve("cover.jpg").writeBytes(ByteArray(4096) { 7 }) + + val resolved = AlbumArtUtils.readExternalAlbumArtBytes( + filePath = songFile.absolutePath, + enabled = true, + maxBytes = 2048L + ) + + assertThat(resolved).isNull() + root.deleteRecursively() + } + + @Test + fun readExternalAlbumArtBytes_stillHonoursExcludedDirectoriesWhenEnabled() { + val root = createTempDirectory("album-art-test").toFile() + val downloadsDir = root.resolve("Downloads").apply { mkdirs() } + val songFile = downloadsDir.resolve("Fresh Track.mp3").apply { writeBytes(byteArrayOf(1, 2, 3)) } + downloadsDir.resolve("cover.jpg").writeBytes(ByteArray(2048) { 5 }) + + val resolved = AlbumArtUtils.readExternalAlbumArtBytes(songFile.absolutePath, enabled = true) + + assertThat(resolved).isNull() + root.deleteRecursively() + } } From 7c17595cf67533dd1b3d44c0eca6b034b657e574 Mon Sep 17 00:00:00 2001 From: bxkr Date: Tue, 8 Sep 2026 13:02:29 +0300 Subject: [PATCH 2/4] Fix startup race, restore staleness and revocation --- .../pixelplayeross/PixelPlayerApplication.kt | 27 +++++- .../preferences/UserPreferencesRepository.kt | 16 ++++ .../screens/SettingsCategoryScreen.kt | 6 +- .../viewmodel/SettingsViewModel.kt | 8 +- .../pixelplayeross/utils/AlbumArtUtils.kt | 82 ++++++++++++++++--- .../utils/FolderAlbumArtUpdateTest.kt | 36 ++++++++ 6 files changed, 157 insertions(+), 18 deletions(-) create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt index abc2c05f..c19816ab 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt @@ -20,6 +20,8 @@ import com.lostf1sh.pixelplayeross.data.repository.ArtistImageRepository import com.lostf1sh.pixelplayeross.presentation.viewmodel.LibraryStateHolder import com.lostf1sh.pixelplayeross.presentation.viewmodel.ThemeStateHolder import com.lostf1sh.pixelplayeross.utils.AlbumArtCacheManager +import com.lostf1sh.pixelplayeross.utils.FolderAlbumArtUpdate +import com.lostf1sh.pixelplayeross.utils.resolveFolderAlbumArtUpdate import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils import com.lostf1sh.pixelplayeross.utils.AppLocaleManager import com.lostf1sh.pixelplayeross.utils.CrashHandler @@ -63,6 +65,9 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. @Inject lateinit var userPreferencesRepository: dagger.Lazy + @Inject + lateinit var imageCacheManager: dagger.Lazy + @Inject lateinit var syncManager: dagger.Lazy @@ -133,9 +138,25 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. if (savedLimit != null) { AlbumArtCacheManager.configuredCacheLimitMb = savedLimit.toLong() } - AlbumArtUtils.folderAlbumArtEnabled = runCatching { - userPreferencesRepository.get().useFolderAlbumArtFlow.first() - }.getOrDefault(false) + } + + // Collected rather than read once: the preference can also change underneath us, most + // notably when a backup restore writes it straight into DataStore. A stale mirror would + // leave the toggle reporting one thing while artwork resolution did another. + startupScope.launch { + userPreferencesRepository.get().useFolderAlbumArtFlow.collect { enabled -> + val update = resolveFolderAlbumArtUpdate( + previous = AlbumArtUtils.folderAlbumArtPreferenceOrNull(), + observed = enabled + ) + if (update == FolderAlbumArtUpdate.IGNORE) return@collect + AlbumArtUtils.setFolderAlbumArtPreference(enabled) + // Every cached cover and "no art" marker was resolved under the old precedence. + if (update == FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) { + AlbumArtCacheManager.clearAllCache(this@PixelPlayerApplication) + imageCacheManager.get().clearAllCoverArtCaches() + } + } } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt index 56087e58..f5d457d2 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt @@ -30,6 +30,7 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -1229,6 +1230,21 @@ constructor( /** Default word-based delimiters (matched case-insensitively with whitespace boundaries) */ val DEFAULT_ARTIST_WORD_DELIMITERS = listOf("featuring", "feat.", "feat", "ft.", "ft", "vs.", "vs", "versus", "with", "prod.", "prod") const val DEFAULT_ALBUM_ART_CACHE_LIMIT_MB = 200 + + /** + * Synchronous read of the folder-cover-art opt-in. + * + * [com.lostf1sh.pixelplayeross.utils.AlbumArtUtils] resolves artwork from a Coil fetcher + * and a ContentProvider, both off the main thread, and it cannot proceed without knowing + * this value: resolving under a wrong assumption persists a cache entry or a "no art" + * marker that later reads then trust. The mirror is normally pushed in long before any + * artwork is requested, so this only runs if a very early request wins that race. + */ + fun readUseFolderAlbumArtBlocking(context: Context): Boolean = runBlocking { + runCatching { + context.applicationContext.dataStore.data.first()[PreferencesKeys.USE_FOLDER_ALBUM_ART] + }.getOrNull() ?: false + } } val navBarCornerRadiusFlow: Flow = diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt index f459e3b4..fa8d84ab 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt @@ -279,6 +279,10 @@ fun SettingsCategoryScreen( } else { null } + // Shown as off when the permission is gone even though the preference is still true: the + // feature genuinely is not working in that state, and tapping it re-requests access. + val hasImagesPermission = + imagesPermissionState == null || imagesPermissionState.status.isGranted val onFolderAlbumArtToggled: (Boolean) -> Unit = { enabled -> when { !enabled -> settingsViewModel.setUseFolderAlbumArt(false) @@ -560,7 +564,7 @@ fun SettingsCategoryScreen( SwitchSettingItem( title = stringResource(R.string.setcat_folder_album_art_title), subtitle = stringResource(R.string.setcat_folder_album_art_subtitle), - checked = useFolderAlbumArt, + checked = useFolderAlbumArt && hasImagesPermission, onCheckedChange = onFolderAlbumArtToggled, leadingIcon = { Icon(Icons.Outlined.Image, null, tint = MaterialTheme.colorScheme.secondary) }, modifier = Modifier.settingHighlight("item_library_folder_album_art", highlightKey) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt index 458d0797..32226fd4 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt @@ -927,9 +927,13 @@ class SettingsViewModel @Inject constructor( */ fun setUseFolderAlbumArt(enabled: Boolean) { viewModelScope.launch { - if (enabled == useFolderAlbumArt.value) return@launch + // Deliberately not skipped when the stored value already matches: re-enabling after + // the image permission was granted back leaves the preference unchanged but still + // needs the caches rebuilt, because everything was resolved without folder access. + // Mirrored before the write so the app-wide observer sees its own value and does not + // invalidate a second time. + com.lostf1sh.pixelplayeross.utils.AlbumArtUtils.setFolderAlbumArtPreference(enabled) userPreferencesRepository.setUseFolderAlbumArt(enabled) - com.lostf1sh.pixelplayeross.utils.AlbumArtUtils.folderAlbumArtEnabled = enabled com.lostf1sh.pixelplayeross.utils.AlbumArtCacheManager.clearAllCache(context) imageCacheManager.clearAllCoverArtCaches() syncManager.fullSync() diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt index f810e145..636b4af8 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt @@ -1,14 +1,19 @@ package com.lostf1sh.pixelplayeross.utils +import android.Manifest import android.content.ContentUris import android.content.Context +import android.content.pm.PackageManager import android.graphics.Bitmap import android.graphics.BitmapFactory import android.net.Uri +import android.os.Build import android.provider.MediaStore +import androidx.core.content.ContextCompat import androidx.core.content.FileProvider import androidx.core.net.toUri import com.lostf1sh.pixelplayeross.data.media.AudioMetadataReader +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -44,11 +49,21 @@ object AlbumArtUtils { /** * Mirrors `UserPreferencesRepository.useFolderAlbumArtFlow`. AlbumArtUtils is a plain object * reached from a Coil fetcher and a ContentProvider, so it cannot take a DI dependency; the - * value is pushed in from PixelPlayerApplication at startup and from SettingsViewModel on - * change, the same way as [AlbumArtCacheManager.configuredCacheLimitMb]. + * value is pushed in from PixelPlayerApplication and from SettingsViewModel, the same way as + * [AlbumArtCacheManager.configuredCacheLimitMb]. + * + * Null means "not read yet" rather than "disabled" — treating the two alike would let an + * artwork request that beats startup cache the wrong result permanently. */ @Volatile - var folderAlbumArtEnabled: Boolean = false + private var folderAlbumArtPreference: Boolean? = null + + fun setFolderAlbumArtPreference(enabled: Boolean) { + folderAlbumArtPreference = enabled + } + + /** The mirrored preference, or null if it has not been read yet. */ + fun folderAlbumArtPreferenceOrNull(): Boolean? = folderAlbumArtPreference private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) // Tracks cache files currently being shrunk so rapid repeated loads of the same oversized @@ -84,8 +99,8 @@ object AlbumArtUtils { /** * Main function to get album art for local songs. * - * Local artwork is embedded-only unless the user opts in to folder covers via - * [folderAlbumArtEnabled]. That opt-in exists because falling back to folder images such as + * Local artwork is embedded-only unless the user opts in to folder covers, which + * [isFolderAlbumArtEnabled] resolves. That opt-in exists because falling back to folder images such as * cover.jpg/thumb.jpg unasked can pick unrelated Gallery files when music is stored in mixed * directories, and can duplicate the same image across unrelated tracks. */ @@ -198,7 +213,7 @@ object AlbumArtUtils { // Folder covers outrank embedded pictures when the user has opted in: a cover.jpg is // usually the full-resolution original, while embedded art is often a downscaled copy. - readExternalAlbumArtBytes(resolvedPath)?.let { bytes -> + readExternalAlbumArtBytes(resolvedPath, isFolderAlbumArtEnabled(appContext))?.let { bytes -> cacheAlbumArtBytes(appContext, bytes, songId) return cachedFile.takeIf { it.exists() && it.length() > 0 } } @@ -262,7 +277,7 @@ object AlbumArtUtils { noArtFile.delete() } - if (readExternalAlbumArtBytes(filePath) != null) { + if (readExternalAlbumArtBytes(filePath, isFolderAlbumArtEnabled(appContext)) != null) { noArtFile.delete() return true } @@ -291,15 +306,37 @@ object AlbumArtUtils { } /** - * Artwork bytes from a cover image sitting next to the audio file, or null when the user has - * not opted in, no trusted cover exists, or the candidate is too large to bound safely. + * Whether folder covers should be used right now: the user opted in *and* the app can + * actually read image files. * - * On API 33+ reading these needs READ_MEDIA_IMAGES: without it [findExternalAlbumArtFile]'s - * `exists()` check simply fails, so this degrades to null rather than throwing. + * The permission is checked rather than assumed because it can be taken away after the user + * opts in — revoked in system settings, or auto-reset while the app goes unused. Without the + * check the stored preference would keep claiming the feature is on while every folder read + * silently failed. + */ + internal fun isFolderAlbumArtEnabled(appContext: Context): Boolean { + val preferred = folderAlbumArtPreference + ?: UserPreferencesRepository.readUseFolderAlbumArtBlocking(appContext) + .also { folderAlbumArtPreference = it } + return preferred && canReadImageFiles(appContext) + } + + /** Below API 33 the already-granted READ_EXTERNAL_STORAGE covers sibling image files. */ + internal fun canReadImageFiles(appContext: Context): Boolean { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true + return ContextCompat.checkSelfPermission( + appContext, + Manifest.permission.READ_MEDIA_IMAGES + ) == PackageManager.PERMISSION_GRANTED + } + + /** + * Artwork bytes from a cover image sitting next to the audio file, or null when folder covers + * are not in use, no trusted cover exists, or the candidate is too large to bound safely. */ internal fun readExternalAlbumArtBytes( filePath: String, - enabled: Boolean = folderAlbumArtEnabled, + enabled: Boolean, maxBytes: Long = MAX_EXTERNAL_ART_BYTES ): ByteArray? { if (!enabled) return null @@ -624,3 +661,24 @@ internal fun resolveAlbumArtUriForLibraryScan( } return LocalArtworkUri.buildSongUri(songId) } + +/** What the app-wide preference observer should do with a value it just observed. */ +internal enum class FolderAlbumArtUpdate { + /** Already mirrored — whoever set it handled any invalidation. */ + IGNORE, + + /** First value seen this process; nothing was cached under a different setting yet. */ + MIRROR_ONLY, + + /** Changed out-of-band (a backup restore), so cached artwork is now stale. */ + MIRROR_AND_INVALIDATE +} + +internal fun resolveFolderAlbumArtUpdate( + previous: Boolean?, + observed: Boolean +): FolderAlbumArtUpdate = when { + previous == observed -> FolderAlbumArtUpdate.IGNORE + previous == null -> FolderAlbumArtUpdate.MIRROR_ONLY + else -> FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt new file mode 100644 index 00000000..f8276a54 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt @@ -0,0 +1,36 @@ +package com.lostf1sh.pixelplayeross.utils + +import com.google.common.truth.Truth.assertThat +import org.junit.jupiter.api.Test + +/** + * The app-wide observer mirrors the folder-cover preference into [AlbumArtUtils]. Folder covers + * outrank embedded art, so a value that changes outside the settings screen — a backup restore + * writing straight into DataStore — leaves every cached cover resolved under the old precedence. + */ +class FolderAlbumArtUpdateTest { + + @Test + fun firstObservedValue_onlyMirrors() { + assertThat(resolveFolderAlbumArtUpdate(previous = null, observed = true)) + .isEqualTo(FolderAlbumArtUpdate.MIRROR_ONLY) + assertThat(resolveFolderAlbumArtUpdate(previous = null, observed = false)) + .isEqualTo(FolderAlbumArtUpdate.MIRROR_ONLY) + } + + @Test + fun valueAlreadyMirrored_isIgnored() { + assertThat(resolveFolderAlbumArtUpdate(previous = true, observed = true)) + .isEqualTo(FolderAlbumArtUpdate.IGNORE) + assertThat(resolveFolderAlbumArtUpdate(previous = false, observed = false)) + .isEqualTo(FolderAlbumArtUpdate.IGNORE) + } + + @Test + fun outOfBandChange_invalidatesCachedArtwork() { + assertThat(resolveFolderAlbumArtUpdate(previous = false, observed = true)) + .isEqualTo(FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) + assertThat(resolveFolderAlbumArtUpdate(previous = true, observed = false)) + .isEqualTo(FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) + } +} From 79c558bb83d2e38bdedf6910fcdcd3adf10a474f Mon Sep 17 00:00:00 2001 From: bxkr Date: Tue, 8 Sep 2026 13:19:31 +0300 Subject: [PATCH 3/4] Record and reconcile the effective cover state --- .../pixelplayeross/PixelPlayerApplication.kt | 40 ++++++++++++++----- .../preferences/UserPreferencesRepository.kt | 24 +++++++++++ .../viewmodel/SettingsViewModel.kt | 5 +++ .../utils/FolderAlbumArtUpdateTest.kt | 24 ++++++++--- 4 files changed, 77 insertions(+), 16 deletions(-) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt index c19816ab..50ad3cd1 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt @@ -86,6 +86,9 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. private val appLifecycleObserver = object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { libraryStateHolder.get().restoreAfterTrimIfNeeded() + // Returning from system settings is the moment a revoked image permission can come + // back; granting one does not restart the process the way revoking it does. + startupScope.launch { reconcileFolderAlbumArtCache() } m3uSyncCoordinator.get().onAppForeground() advancedPerformanceDiagnosticsController.get().onAppForeground() } @@ -145,21 +148,38 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. // leave the toggle reporting one thing while artwork resolution did another. startupScope.launch { userPreferencesRepository.get().useFolderAlbumArtFlow.collect { enabled -> - val update = resolveFolderAlbumArtUpdate( - previous = AlbumArtUtils.folderAlbumArtPreferenceOrNull(), - observed = enabled - ) - if (update == FolderAlbumArtUpdate.IGNORE) return@collect AlbumArtUtils.setFolderAlbumArtPreference(enabled) - // Every cached cover and "no art" marker was resolved under the old precedence. - if (update == FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) { - AlbumArtCacheManager.clearAllCache(this@PixelPlayerApplication) - imageCacheManager.get().clearAllCoverArtCaches() - } + reconcileFolderAlbumArtCache() } } } + /** + * Drops cached artwork when the effective folder-cover state no longer matches the state the + * cache was built under. + * + * Effective state is the preference *and* the image permission, because a cover that cannot + * be read is indistinguishable from one that is switched off — both cache embedded art and + * "no art" markers that then suppress folder covers. The permission can come back without the + * preference ever changing (granted again in system settings), and cached artwork lives in + * `filesDir`, so nothing short of this reconciliation would notice. + */ + private suspend fun reconcileFolderAlbumArtCache() { + val preferences = userPreferencesRepository.get() + val effective = AlbumArtUtils.isFolderAlbumArtEnabled(this) + val recorded = runCatching { preferences.folderAlbumArtCacheStateFlow.first() }.getOrNull() + + when (resolveFolderAlbumArtUpdate(previous = recorded, observed = effective)) { + FolderAlbumArtUpdate.IGNORE -> return + FolderAlbumArtUpdate.MIRROR_ONLY -> Unit + FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE -> { + AlbumArtCacheManager.clearAllCache(this) + imageCacheManager.get().clearAllCoverArtCaches() + } + } + runCatching { preferences.setFolderAlbumArtCacheState(effective) } + } + override fun newImageLoader(): ImageLoader { return imageLoader.get().newBuilder() .components { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt index f5d457d2..536ded43 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt @@ -90,6 +90,9 @@ constructor( private val backupExcludedKeyNames = setOf( PreferencesKeys.INITIAL_SETUP_DONE.name, + // Describes this device's artwork cache, not a user choice — restoring another + // device's value would misreport what the local cache was built from. + PreferencesKeys.FOLDER_ALBUM_ART_CACHE_STATE.name, "listenbrainz_token", "lastfm_session_key" ) @@ -213,6 +216,7 @@ constructor( val ALBUM_ART_QUALITY = stringPreferencesKey("album_art_quality") val ALBUM_ART_CACHE_LIMIT_MB = intPreferencesKey("album_art_cache_limit_mb") val USE_FOLDER_ALBUM_ART = booleanPreferencesKey("use_folder_album_art") + val FOLDER_ALBUM_ART_CACHE_STATE = booleanPreferencesKey("folder_album_art_cache_state") val TAP_BACKGROUND_CLOSES_PLAYER = booleanPreferencesKey("tap_background_closes_player") val HAPTICS_ENABLED = booleanPreferencesKey("haptics_enabled") val ADVANCED_PERFORMANCE_DIAGNOSTICS_ENABLED = @@ -1634,6 +1638,26 @@ constructor( } } + /** + * The effective folder-cover state the artwork cache was last built under, or null if it has + * never been recorded. + * + * Persisted because the cache it describes is: covers live in `filesDir` and outlive the + * process, so an in-memory flag could not tell whether entries were produced with or without + * folder access. Effective means the preference *and* the image permission — losing the + * permission silently changes what gets cached just as much as switching the setting off. + */ + val folderAlbumArtCacheStateFlow: Flow = + dataStore.data.map { preferences -> + preferences[PreferencesKeys.FOLDER_ALBUM_ART_CACHE_STATE] + } + + suspend fun setFolderAlbumArtCacheState(enabled: Boolean) { + dataStore.edit { preferences -> + preferences[PreferencesKeys.FOLDER_ALBUM_ART_CACHE_STATE] = enabled + } + } + /** * Whether tapping the background area of the player sheet closes it. * Default is false to avoid accidental dismissals while interacting with the full player. diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt index 32226fd4..2d4f41c0 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt @@ -936,6 +936,11 @@ class SettingsViewModel @Inject constructor( userPreferencesRepository.setUseFolderAlbumArt(enabled) com.lostf1sh.pixelplayeross.utils.AlbumArtCacheManager.clearAllCache(context) imageCacheManager.clearAllCoverArtCaches() + // Record what the cache is being rebuilt under so the app-wide reconciler recognises + // its own state and does not clear it again. + userPreferencesRepository.setFolderAlbumArtCacheState( + com.lostf1sh.pixelplayeross.utils.AlbumArtUtils.isFolderAlbumArtEnabled(context) + ) syncManager.fullSync() } } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt index f8276a54..30b61d09 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt @@ -4,14 +4,14 @@ import com.google.common.truth.Truth.assertThat import org.junit.jupiter.api.Test /** - * The app-wide observer mirrors the folder-cover preference into [AlbumArtUtils]. Folder covers - * outrank embedded art, so a value that changes outside the settings screen — a backup restore - * writing straight into DataStore — leaves every cached cover resolved under the old precedence. + * Cached covers live in `filesDir` and outlive the process, so the app records the effective + * folder-cover state each cache was built under — the opt-in *and* the image permission — and + * reconciles it on launch, on returning to the foreground, and when the preference changes. */ class FolderAlbumArtUpdateTest { @Test - fun firstObservedValue_onlyMirrors() { + fun firstRecordedState_onlyStoresIt() { assertThat(resolveFolderAlbumArtUpdate(previous = null, observed = true)) .isEqualTo(FolderAlbumArtUpdate.MIRROR_ONLY) assertThat(resolveFolderAlbumArtUpdate(previous = null, observed = false)) @@ -19,18 +19,30 @@ class FolderAlbumArtUpdateTest { } @Test - fun valueAlreadyMirrored_isIgnored() { + fun unchangedState_isIgnored() { assertThat(resolveFolderAlbumArtUpdate(previous = true, observed = true)) .isEqualTo(FolderAlbumArtUpdate.IGNORE) assertThat(resolveFolderAlbumArtUpdate(previous = false, observed = false)) .isEqualTo(FolderAlbumArtUpdate.IGNORE) } + /** Turning the setting on or off, or a backup restore writing straight into DataStore. */ @Test - fun outOfBandChange_invalidatesCachedArtwork() { + fun preferenceChange_invalidatesCachedArtwork() { assertThat(resolveFolderAlbumArtUpdate(previous = false, observed = true)) .isEqualTo(FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) assertThat(resolveFolderAlbumArtUpdate(previous = true, observed = false)) .isEqualTo(FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) } + + /** + * The image permission granted back in system settings while the opt-in never changed. The + * cache still holds embedded art and "no art" markers written without folder access, which + * would otherwise keep suppressing folder covers indefinitely. + */ + @Test + fun permissionRegrantedWithoutPreferenceChange_invalidatesCachedArtwork() { + assertThat(resolveFolderAlbumArtUpdate(previous = false, observed = true)) + .isEqualTo(FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) + } } From 8c4bc8be86165628943f68fceca1e0be0aabde1a Mon Sep 17 00:00:00 2001 From: Duhan <136324426+lostf1sh@users.noreply.github.com> Date: Sat, 19 Sep 2026 12:19:43 +0300 Subject: [PATCH 4/4] Fix folder artwork handling and add artist portraits --- .../ArtistFolderDirectoriesDaoTest.kt | 132 +++++++++++ .../data/worker/SyncWorkerTest.kt | 3 +- app/src/main/AndroidManifest.xml | 2 + .../pixelplayeross/PixelPlayerApplication.kt | 46 +--- .../data/database/AlbumArtThemeDao.kt | 7 + .../pixelplayeross/data/database/MusicDao.kt | 19 ++ .../preferences/UserPreferencesRepository.kt | 2 +- .../data/repository/ArtistImageRepository.kt | 59 ++++- .../data/repository/MusicRepositoryImpl.kt | 35 ++- .../pixelplayeross/data/worker/SyncWorker.kt | 6 +- .../lostf1sh/pixelplayeross/di/AppModule.kt | 5 +- .../components/LocalArtworkImageModel.kt | 49 +++++ .../components/OptimizedAlbumArt.kt | 35 +-- .../presentation/components/SmartImage.kt | 18 +- .../screens/SettingsCategoryScreen.kt | 42 ++-- .../settings/search/SettingsRegistry.kt | 3 +- .../viewmodel/ColorSchemeProcessor.kt | 121 ++++++---- .../viewmodel/SettingsViewModel.kt | 20 +- .../viewmodel/ThemeStateHolder.kt | 88 +++++++- .../pixelplayeross/utils/AlbumArtUtils.kt | 184 +++++++++++----- .../utils/FolderArtistArtUtils.kt | 63 ++++++ .../utils/FolderArtworkPermissions.kt | 15 ++ .../utils/FolderArtworkSettingsCoordinator.kt | 101 +++++++++ .../main/res/values-ar/strings_settings.xml | 6 +- .../main/res/values-de/strings_settings.xml | 6 +- .../main/res/values-es/strings_settings.xml | 6 +- .../main/res/values-fr/strings_settings.xml | 6 +- .../main/res/values-in/strings_settings.xml | 6 +- .../main/res/values-it/strings_settings.xml | 6 +- .../main/res/values-ko/strings_settings.xml | 6 +- .../main/res/values-nb/strings_settings.xml | 6 +- .../main/res/values-ru/strings_settings.xml | 6 +- .../main/res/values-tr/strings_settings.xml | 6 +- .../res/values-zh-rCN/strings_settings.xml | 6 +- app/src/main/res/values/strings_settings.xml | 6 +- .../repository/ArtistImageRepositoryTest.kt | 127 ++++++++++- .../repository/MusicRepositoryImplTest.kt | 44 ++++ .../components/LocalArtworkImageModelTest.kt | 69 ++++++ .../viewmodel/ThemeStateHolderArtworkTest.kt | 129 +++++++++++ .../pixelplayeross/utils/AlbumArtUtilsTest.kt | 208 ++++++++++++++++++ .../utils/FolderAlbumArtUpdateTest.kt | 6 +- .../utils/FolderArtistArtUtilsTest.kt | 120 ++++++++++ .../utils/FolderArtworkPermissionsTest.kt | 25 +++ .../FolderArtworkSettingsCoordinatorTest.kt | 203 +++++++++++++++++ 44 files changed, 1805 insertions(+), 253 deletions(-) create mode 100644 app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/ArtistFolderDirectoriesDaoTest.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/LocalArtworkImageModel.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtistArtUtils.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkPermissions.kt create mode 100644 app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkSettingsCoordinator.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/LocalArtworkImageModelTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolderArtworkTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtistArtUtilsTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkPermissionsTest.kt create mode 100644 app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkSettingsCoordinatorTest.kt diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/ArtistFolderDirectoriesDaoTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/ArtistFolderDirectoriesDaoTest.kt new file mode 100644 index 00000000..cbbac131 --- /dev/null +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/database/ArtistFolderDirectoriesDaoTest.kt @@ -0,0 +1,132 @@ +package com.lostf1sh.pixelplayeross.data.database + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ArtistFolderDirectoriesDaoTest { + private lateinit var database: PixelPlayerDatabase + private lateinit var musicDao: MusicDao + + @Before + fun createDatabase() { + database = Room.inMemoryDatabaseBuilder( + ApplicationProvider.getApplicationContext(), + PixelPlayerDatabase::class.java + ) + .addCallback(PixelPlayerDatabase.createRuntimeArtifactsCallback()) + .allowMainThreadQueries() + .build() + musicDao = database.musicDao() + } + + @After + fun closeDatabase() { + database.close() + } + + @Test + fun returnsDistinctLocalDirectoriesForPrimaryAlbumAndGuestArtists() = runTest { + insertArtistRolesFixture() + + assertEquals( + listOf("/music/Album A", "/music/Album B", "/music/Album C"), + musicDao.getLocalArtistDirectories(ARTIST_ID, limit = 64) + ) + } + + @Test + fun appliesLimitAfterDeduplicationAndExcludingRemoteAndEmptyPaths() = runTest { + insertArtistRolesFixture() + + assertEquals( + listOf("/music/Album A", "/music/Album B"), + musicDao.getLocalArtistDirectories(ARTIST_ID, limit = 2) + ) + } + + private suspend fun insertArtistRolesFixture() { + val songs = listOf( + // The same folder is reachable through every UNION branch and multiple tracks. + song(1L, "/music/Album A", artistId = ARTIST_ID, albumArtistId = ARTIST_ID), + song(2L, "/music/Album A", artistId = ARTIST_ID), + song(3L, "/music/Album B", albumArtistId = ARTIST_ID), + song(4L, "/music/Album C"), + song(5L, "/aaa-unrelated"), + song(6L, "", artistId = ARTIST_ID, albumArtistId = ARTIST_ID), + // Plausible cached paths on cloud tracks must be excluded in each UNION branch. + song(-1L, "/aaa-cloud-primary", artistId = ARTIST_ID, sourceType = SourceType.NAVIDROME), + song(-2L, "/aaa-cloud-album", albumArtistId = ARTIST_ID, sourceType = SourceType.JELLYFIN), + song(-3L, "/aaa-cloud-guest", sourceType = SourceType.NAVIDROME) + ) + musicDao.insertMusicData( + songs = songs, + albums = listOf( + AlbumEntity( + id = ALBUM_ID, + title = "Fixture Album", + artistName = "Other Artist", + artistId = OTHER_ARTIST_ID, + albumArtUriString = null, + songCount = songs.size, + dateAdded = 0L, + year = 2026 + ) + ), + artists = listOf( + ArtistEntity(id = ARTIST_ID, name = "Artist", trackCount = 0), + ArtistEntity(id = OTHER_ARTIST_ID, name = "Other Artist", trackCount = 0) + ) + ) + musicDao.insertSongArtistCrossRefs( + listOf( + SongArtistCrossRef(songId = 1L, artistId = ARTIST_ID, isPrimary = true), + SongArtistCrossRef(songId = 4L, artistId = ARTIST_ID, isPrimary = false), + SongArtistCrossRef(songId = 5L, artistId = OTHER_ARTIST_ID, isPrimary = true), + SongArtistCrossRef(songId = 6L, artistId = ARTIST_ID, isPrimary = true), + SongArtistCrossRef(songId = -3L, artistId = ARTIST_ID, isPrimary = false) + ) + ) + } + + private fun song( + id: Long, + directory: String, + artistId: Long = OTHER_ARTIST_ID, + albumArtistId: Long = OTHER_ARTIST_ID, + sourceType: Int = SourceType.LOCAL + ): SongEntity = SongEntity( + id = id, + title = "Track $id", + artistName = if (artistId == ARTIST_ID) "Artist" else "Other Artist", + artistId = artistId, + albumArtistId = albumArtistId, + albumName = "Fixture Album", + albumId = ALBUM_ID, + contentUriString = when (sourceType) { + SourceType.NAVIDROME -> "navidrome://track-$id" + SourceType.JELLYFIN -> "jellyfin://track-$id" + else -> "content://media/external/audio/media/$id" + }, + albumArtUriString = null, + duration = 180_000L, + genre = null, + filePath = "$directory/track-$id.flac", + parentDirectoryPath = directory, + sourceType = sourceType + ) + + private companion object { + const val ARTIST_ID = 42L + const val OTHER_ARTIST_ID = 99L + const val ALBUM_ID = 201L + } +} diff --git a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt index 593b2995..0f9ae357 100644 --- a/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt +++ b/app/src/androidTest/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorkerTest.kt @@ -63,7 +63,8 @@ class SyncWorkerTest { musicDao = dao, userPreferencesRepository = createTestPreferencesRepository(), lyricsRepository = mockk(relaxed = true), - cloudSyncCoordinator = mockk(relaxed = true) + cloudSyncCoordinator = mockk(relaxed = true), + artistImageRepository = mockk(relaxed = true) ) } else { null diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ff32c855..4dfbdaaf 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -15,6 +15,8 @@ + + diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt index 50ad3cd1..5d5c8a47 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/PixelPlayerApplication.kt @@ -20,9 +20,7 @@ import com.lostf1sh.pixelplayeross.data.repository.ArtistImageRepository import com.lostf1sh.pixelplayeross.presentation.viewmodel.LibraryStateHolder import com.lostf1sh.pixelplayeross.presentation.viewmodel.ThemeStateHolder import com.lostf1sh.pixelplayeross.utils.AlbumArtCacheManager -import com.lostf1sh.pixelplayeross.utils.FolderAlbumArtUpdate -import com.lostf1sh.pixelplayeross.utils.resolveFolderAlbumArtUpdate -import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils +import com.lostf1sh.pixelplayeross.utils.FolderArtworkSettingsCoordinator import com.lostf1sh.pixelplayeross.utils.AppLocaleManager import com.lostf1sh.pixelplayeross.utils.CrashHandler import com.lostf1sh.pixelplayeross.utils.MediaMetadataRetrieverPool @@ -66,7 +64,7 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. lateinit var userPreferencesRepository: dagger.Lazy @Inject - lateinit var imageCacheManager: dagger.Lazy + lateinit var folderArtworkSettingsCoordinator: dagger.Lazy @Inject lateinit var syncManager: dagger.Lazy @@ -86,13 +84,15 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. private val appLifecycleObserver = object : DefaultLifecycleObserver { override fun onStart(owner: LifecycleOwner) { libraryStateHolder.get().restoreAfterTrimIfNeeded() - // Returning from system settings is the moment a revoked image permission can come - // back; granting one does not restart the process the way revoking it does. - startupScope.launch { reconcileFolderAlbumArtCache() } m3uSyncCoordinator.get().onAppForeground() advancedPerformanceDiagnosticsController.get().onAppForeground() } + override fun onResume(owner: LifecycleOwner) { + // Image access can change while system settings is open, without restarting us. + startupScope.launch { folderArtworkSettingsCoordinator.get().reconcile() } + } + override fun onStop(owner: LifecycleOwner) { advancedPerformanceDiagnosticsController.get().onAppBackground() } @@ -134,7 +134,6 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. advancedPerformanceDiagnosticsController.get().start(startupScope) startupScope.launch { - AlbumArtUtils.migrateLegacyCacheLocation(this@PixelPlayerApplication) val savedLimit = runCatching { userPreferencesRepository.get().albumArtCacheLimitMbFlow.first() }.getOrNull() @@ -147,37 +146,10 @@ class PixelPlayerApplication : Application(), ImageLoaderFactory, Configuration. // notably when a backup restore writes it straight into DataStore. A stale mirror would // leave the toggle reporting one thing while artwork resolution did another. startupScope.launch { - userPreferencesRepository.get().useFolderAlbumArtFlow.collect { enabled -> - AlbumArtUtils.setFolderAlbumArtPreference(enabled) - reconcileFolderAlbumArtCache() - } - } - } - - /** - * Drops cached artwork when the effective folder-cover state no longer matches the state the - * cache was built under. - * - * Effective state is the preference *and* the image permission, because a cover that cannot - * be read is indistinguishable from one that is switched off — both cache embedded art and - * "no art" markers that then suppress folder covers. The permission can come back without the - * preference ever changing (granted again in system settings), and cached artwork lives in - * `filesDir`, so nothing short of this reconciliation would notice. - */ - private suspend fun reconcileFolderAlbumArtCache() { - val preferences = userPreferencesRepository.get() - val effective = AlbumArtUtils.isFolderAlbumArtEnabled(this) - val recorded = runCatching { preferences.folderAlbumArtCacheStateFlow.first() }.getOrNull() - - when (resolveFolderAlbumArtUpdate(previous = recorded, observed = effective)) { - FolderAlbumArtUpdate.IGNORE -> return - FolderAlbumArtUpdate.MIRROR_ONLY -> Unit - FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE -> { - AlbumArtCacheManager.clearAllCache(this) - imageCacheManager.get().clearAllCoverArtCaches() + userPreferencesRepository.get().useFolderAlbumArtFlow.collect { + folderArtworkSettingsCoordinator.get().reconcile() } } - runCatching { preferences.setFolderAlbumArtCacheState(effective) } } override fun newImageLoader(): ImageLoader { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeDao.kt index 37cc0dfc..33b2b771 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/AlbumArtThemeDao.kt @@ -17,4 +17,11 @@ interface AlbumArtThemeDao { @Query("DELETE FROM album_art_themes WHERE albumArtUriString IN (:uriStrings)") suspend fun deleteThemesByUris(uriStrings: List) + + @Query(""" + DELETE FROM album_art_themes + WHERE albumArtUriString GLOB 'pixelplayer_local_art://*' + OR albumArtUriString GLOB 'pixelplay_local_art://*' + """) + suspend fun deleteLocalArtworkThemes() } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt index 9ef3b4bb..4c255f80 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/database/MusicDao.kt @@ -1572,6 +1572,25 @@ interface MusicDao { @Query("SELECT custom_image_uri FROM artists WHERE id = :artistId") suspend fun getArtistCustomImage(artistId: Long): String? + /** + * Resolve indexed artist IDs first, then look up their paths by primary key. CROSS JOIN + * keeps SQLite from scanning every local song via source_type for each artist in a list. + */ + @Query(""" + SELECT DISTINCT songs.parent_directory_path FROM ( + SELECT id AS song_id FROM songs WHERE artist_id = :artistId + UNION + SELECT id AS song_id FROM songs WHERE album_artist_id = :artistId + UNION + SELECT song_id FROM song_artist_cross_ref WHERE artist_id = :artistId + ) AS artist_songs + CROSS JOIN songs ON songs.id = artist_songs.song_id + WHERE songs.source_type = 0 AND songs.parent_directory_path != '' + ORDER BY songs.parent_directory_path + LIMIT :limit + """) + suspend fun getLocalArtistDirectories(artistId: Long, limit: Int): List + @Query(""" SELECT * FROM songs WHERE (:applyDirectoryFilter = 0 OR id < 0 OR parent_directory_path IN (:allowedParentDirs)) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt index 536ded43..190cf88a 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/preferences/UserPreferencesRepository.kt @@ -1630,7 +1630,7 @@ constructor( val useFolderAlbumArtFlow: Flow = dataStore.data.map { preferences -> preferences[PreferencesKeys.USE_FOLDER_ALBUM_ART] ?: false - } + }.distinctUntilChanged() suspend fun setUseFolderAlbumArt(enabled: Boolean) { dataStore.edit { preferences -> diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/ArtistImageRepository.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/ArtistImageRepository.kt index c3763951..838c9f22 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/ArtistImageRepository.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/ArtistImageRepository.kt @@ -8,8 +8,11 @@ import android.util.LruCache import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.network.deezer.DeezerApiService import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository +import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils +import com.lostf1sh.pixelplayeross.utils.FolderArtistArtUtils import com.lostf1sh.pixelplayeross.utils.NetworkRetryUtils import com.lostf1sh.pixelplayeross.utils.isRetryableNetworkError +import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async @@ -18,6 +21,10 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import java.io.File import java.io.FileOutputStream import kotlin.math.max @@ -30,18 +37,23 @@ import timber.log.Timber import androidx.core.graphics.scale /** - * Repository for fetching and caching artist images from Deezer API. + * Resolves local artist portraits before fetching and caching Deezer images. * Uses both in-memory LRU cache and Room database for persistent storage. */ @Singleton class ArtistImageRepository @Inject constructor( private val deezerApiService: DeezerApiService, private val musicDao: MusicDao, - private val userPreferencesRepository: UserPreferencesRepository + private val userPreferencesRepository: UserPreferencesRepository, + @ApplicationContext private val context: Context ) { companion object { private const val TAG = "ArtistImageRepository" private const val CACHE_SIZE = 100 + // Path/miss records are tiny; keep large artist lists from repeating disk discovery + // whenever a single Deezer URL update causes the library to emit again. + private const val FOLDER_IMAGE_CACHE_SIZE = 4_096 + private const val MAX_ARTIST_DIRECTORIES = 64 private const val PREFETCH_CONCURRENCY = 3 private val deezerSizeRegex = Regex("/\\d{2,4}x\\d{2,4}([\\-.])") private const val NETWORK_RETRY_ATTEMPTS = 3 @@ -66,6 +78,11 @@ class ArtistImageRepository @Inject constructor( } private val memoryCache = LruCache(CACHE_SIZE) + private data class FolderImageResult(val path: String?, val revision: Long) + private val folderImageCache = LruCache(FOLDER_IMAGE_CACHE_SIZE) + private val folderLookupMutex = Mutex() + private val _folderArtworkRevision = MutableStateFlow(0L) + val folderArtworkRevision: StateFlow = _folderArtworkRevision.asStateFlow() private val fetchMutex = Mutex() private val pendingFetches = mutableSetOf() @@ -82,6 +99,7 @@ class ArtistImageRepository @Inject constructor( */ suspend fun getArtistImageUrl(artistName: String, artistId: Long): String? { if (artistName.isBlank()) return null + getFolderArtistImageUrl(artistId, artistName)?.let { return it } if (!userPreferencesRepository.externalArtistImagesEnabledFlow.first()) return null val normalizedName = artistName.trim().lowercase() @@ -226,11 +244,48 @@ class ArtistImageRepository @Inject constructor( fun clearCache() { memoryCache.evictAll() failedFetches.clear() + folderImageCache.evictAll() + _folderArtworkRevision.update { it + 1 } + } + + /** + * Folder images are kept separate from persisted third-party URLs and user overrides so + * disabling the option (or losing image access) immediately restores the existing image. + */ + suspend fun getFolderArtistImageUrl(artistId: Long, artistName: String): String? { + if (artistName.isBlank() || !isFolderArtworkEnabled()) return null + return withContext(Dispatchers.IO) { + folderLookupMutex.withLock { + if (!isFolderArtworkEnabled()) return@withLock null + val revision = _folderArtworkRevision.value + folderImageCache.get(artistId)?.takeIf { it.revision == revision }?.let { cached -> + if (cached.path == null || File(cached.path).let { it.isFile && it.canRead() }) { + return@withLock cached.path + } + } + val directories = musicDao.getLocalArtistDirectories( + artistId, + MAX_ARTIST_DIRECTORIES + ) + val imagePath = FolderArtistArtUtils.findArtistImage(directories, artistName)?.absolutePath + if (revision != _folderArtworkRevision.value || !isFolderArtworkEnabled()) { + return@withLock null + } + folderImageCache.put(artistId, FolderImageResult(imagePath, revision)) + imagePath + } + } + } + + private suspend fun isFolderArtworkEnabled(): Boolean { + return userPreferencesRepository.useFolderAlbumArtFlow.first() && + AlbumArtUtils.canReadImageFiles(context) } /** * Returns the effective image URL for an artist: * - If a custom (user-set) image exists in DB → returns that path + * - Otherwise prefers an opted-in folder portrait when available * - Otherwise falls back to the Deezer URL (fetching from API if needed) */ suspend fun getEffectiveArtistImageUrl(artistId: Long, artistName: String): String? { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt index 7b6578f2..54b09f0e 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImpl.kt @@ -109,6 +109,17 @@ class MusicRepositoryImpl @Inject constructor( @Volatile private var currentSongArtistPrefetchJob: Job? = null @Volatile private var currentSongArtistPrefetchSongId: Long? = null + private suspend fun Artist.withFolderArtwork(): Artist { + if (!customImageUri.isNullOrBlank()) return this + val folderImage = artistImageRepository.getFolderArtistImageUrl(id, name) ?: return this + return copy(imageUrl = folderImage) + } + + private fun Flow>.withFolderArtwork(): Flow> = + combine(this, artistImageRepository.folderArtworkRevision) { artists, _ -> + artists.map { it.withFolderArtwork() } + } + private fun normalizePath(path: String): String = runCatching { File(path).canonicalPath }.getOrElse { File(path).absolutePath } @@ -225,8 +236,9 @@ class MusicRepositoryImpl @Inject constructor( return combine( userPreferencesRepository.allowedDirectoriesFlow, userPreferencesRepository.blockedDirectoriesFlow, - userPreferencesRepository.groupByAlbumArtistFlow - ) { allowedDirs, blockedDirs, groupByAlbumArtist -> + userPreferencesRepository.groupByAlbumArtistFlow, + artistImageRepository.folderArtworkRevision + ) { allowedDirs, blockedDirs, groupByAlbumArtist, _ -> Triple(allowedDirs, blockedDirs, groupByAlbumArtist) }.flatMapLatest { (allowedDirs, blockedDirs, groupByAlbumArtist) -> flow { @@ -256,7 +268,7 @@ class MusicRepositoryImpl @Inject constructor( ) }.flatMapLatest { it } }.map { pagingData -> - pagingData.map { entity -> entity.toArtist() } + pagingData.map { entity -> entity.toArtist().withFolderArtwork() } }.flowOn(Dispatchers.IO) } @@ -353,7 +365,7 @@ class MusicRepositoryImpl @Inject constructor( filterMode = storageFilter.toFilterMode(), limit = limit, offset = offset - ).map { it.toArtist() } + ).map { it.toArtist().withFolderArtwork() } } override suspend fun getFirstPlayableSong(): Song? = withContext(Dispatchers.IO) { @@ -423,8 +435,9 @@ class MusicRepositoryImpl @Inject constructor( filterMode = storageFilter.toFilterMode() ) .distinctUntilChanged() - .map { entities -> - val artists = entities.map { it.toArtist() } + .map { entities -> entities.map { it.toArtist() } } + .withFolderArtwork() + .map { artists -> val missingImages = artists.missingImageCandidates() if (missingImages.isNotEmpty()) { prefetchJob?.cancel() @@ -444,7 +457,10 @@ class MusicRepositoryImpl @Inject constructor( } override fun getArtistById(artistId: Long): Flow { - return musicDao.getArtistById(artistId).map { it?.toArtist() } + return combine( + musicDao.getArtistById(artistId), + artistImageRepository.folderArtworkRevision + ) { entity, _ -> entity?.toArtist()?.withFolderArtwork() }.flowOn(Dispatchers.IO) } override suspend fun getArtistIdByName(name: String): Long? = withContext(Dispatchers.IO) { @@ -454,6 +470,7 @@ class MusicRepositoryImpl @Inject constructor( override fun getArtistsForSong(songId: Long): Flow> { return musicDao.getArtistsForSong(songId) .map { entities -> entities.map { it.toArtist() } } + .withFolderArtwork() .distinctUntilChanged() .onEach { artists -> val missingImages = artists.missingImageCandidates() @@ -551,7 +568,7 @@ class MusicRepositoryImpl @Inject constructor( if (query.isBlank()) return flowOf(emptyList()) return musicDao.searchArtists(query, emptyList(), false).map { entities -> entities.map { it.toArtist() } - }.flowOn(Dispatchers.IO) + }.withFolderArtwork().flowOn(Dispatchers.IO) } override suspend fun searchPlaylists(query: String): List { @@ -756,7 +773,7 @@ class MusicRepositoryImpl @Inject constructor( allowedParentDirs = filter.allowedParentDirs, applyDirectoryFilter = filter.applyFilter, filterMode = StorageFilter.ALL.toFilterMode() - ).first().map { it.toArtist() } + ).first().map { it.toArtist().withFolderArtwork() } } override suspend fun setFavoriteStatus(songId: String, isFavorite: Boolean) = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt index c05b5b98..55d4a01b 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/data/worker/SyncWorker.kt @@ -28,6 +28,7 @@ import com.lostf1sh.pixelplayeross.data.media.normalizeArtistMetadataValues import com.lostf1sh.pixelplayeross.data.model.Song import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository import com.lostf1sh.pixelplayeross.data.repository.LyricsRepository +import com.lostf1sh.pixelplayeross.data.repository.ArtistImageRepository import com.lostf1sh.pixelplayeross.data.service.PlaybackActivityTracker import com.lostf1sh.pixelplayeross.utils.AlbumArtCacheManager import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils @@ -104,7 +105,8 @@ constructor( private val musicDao: MusicDao, private val userPreferencesRepository: UserPreferencesRepository, private val lyricsRepository: LyricsRepository, - private val cloudSyncCoordinator: CloudSyncCoordinator + private val cloudSyncCoordinator: CloudSyncCoordinator, + private val artistImageRepository: ArtistImageRepository ) : CoroutineWorker(appContext, workerParams) { private val contentResolver: ContentResolver = appContext.contentResolver @@ -370,6 +372,7 @@ constructor( "totalSongs" to totalSongs.toString() ) } + artistImageRepository.clearCache() return@withContext Result.success( workDataOf(OUTPUT_TOTAL_SONGS to totalSongs) ) @@ -463,6 +466,7 @@ constructor( "totalSongs" to finalTotalSongs.toString() ) } + artistImageRepository.clearCache() Result.success(workDataOf(OUTPUT_TOTAL_SONGS to finalTotalSongs)) } catch (e: Exception) { Timber.tag(TAG).e(e, "Error during MediaStore synchronization") diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt index d0f021d1..af679d09 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/di/AppModule.kt @@ -508,8 +508,9 @@ object AppModule { fun provideArtistImageRepository( deezerApiService: DeezerApiService, musicDao: MusicDao, - userPreferencesRepository: UserPreferencesRepository + userPreferencesRepository: UserPreferencesRepository, + @ApplicationContext context: Context ): ArtistImageRepository { - return ArtistImageRepository(deezerApiService, musicDao, userPreferencesRepository) + return ArtistImageRepository(deezerApiService, musicDao, userPreferencesRepository, context) } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/LocalArtworkImageModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/LocalArtworkImageModel.kt new file mode 100644 index 00000000..d109e7a1 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/LocalArtworkImageModel.kt @@ -0,0 +1,49 @@ +package com.lostf1sh.pixelplayeross.presentation.components + +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil.memory.MemoryCache +import coil.request.CachePolicy +import coil.request.ImageRequest +import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils +import com.lostf1sh.pixelplayeross.utils.LocalArtworkUri + +/** Observe only rare local-art invalidations, never playback state or position updates. */ +@Composable +internal fun rememberArtworkModelWithCacheVersion(model: Any?): Any? { + val isLocalArtwork = remember(model) { localArtworkModelUri(model) != null } + if (!isLocalArtwork) return model + + val version by AlbumArtUtils.artworkCacheVersion.collectAsStateWithLifecycle() + return remember(model, version) { withLocalArtworkCacheVersion(model, version) } +} + +internal fun withLocalArtworkCacheVersion(model: Any?, version: Long): Any? { + val uri = localArtworkModelUri(model) ?: return model + val separator = if ('?' in uri) '&' else '?' + val versionedUri = "$uri${separator}artwork_version=$version" + if (model !is ImageRequest) return versionedUri + + return model.newBuilder() + .data(versionedUri) + .memoryCacheKey(model.memoryCacheKey?.let { key -> + MemoryCache.Key("${key.key}_artwork_version_$version", key.extras) + }) + // A caller's old placeholder key can otherwise resurrect the invalidated image. + .placeholderMemoryCacheKey(null as MemoryCache.Key?) + .diskCachePolicy(CachePolicy.DISABLED) + .build() +} + +private fun localArtworkModelUri(model: Any?): String? { + val data = if (model is ImageRequest) model.data else model + val uri = when (data) { + is String -> data + is Uri -> data.toString() + else -> return null + } + return uri.takeIf(LocalArtworkUri::isLocalArtworkUri) +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/OptimizedAlbumArt.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/OptimizedAlbumArt.kt index e403023b..a5482147 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/OptimizedAlbumArt.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/OptimizedAlbumArt.kt @@ -48,16 +48,17 @@ fun OptimizedAlbumArt( targetSize: Size = SafeOriginalAlbumArtSize, placeholderModel: Any? = null ) { + val artworkModel = rememberArtworkModelWithCacheVersion(uri) val context = LocalContext.current val requestTargetSize = remember(targetSize) { safeAlbumArtTargetSize(targetSize) } - val isStableLocalArtwork = remember(uri) { - when (uri) { - is String -> LocalArtworkUri.isLocalArtworkUri(uri) - is Uri -> LocalArtworkUri.isLocalArtworkUri(uri) + val isStableLocalArtwork = remember(artworkModel) { + when (artworkModel) { + is String -> LocalArtworkUri.isLocalArtworkUri(artworkModel) + is Uri -> LocalArtworkUri.isLocalArtworkUri(artworkModel) is ImageRequest -> { - val data = uri.data + val data = artworkModel.data (data as? String)?.let(LocalArtworkUri::isLocalArtworkUri) == true || LocalArtworkUri.isLocalArtworkUri(data as? Uri) } @@ -66,7 +67,7 @@ fun OptimizedAlbumArt( } if (renderDirectAlbumArt( - model = uri, + model = artworkModel, title = title, modifier = modifier ) @@ -74,28 +75,28 @@ fun OptimizedAlbumArt( return } - val memoryCacheKey = remember(uri, requestTargetSize) { - albumArtMemoryCacheKey(uri, requestTargetSize) + val memoryCacheKey = remember(artworkModel, requestTargetSize) { + albumArtMemoryCacheKey(artworkModel, requestTargetSize) } - val placeholderMemoryCacheKey = remember(memoryCacheKey, uri) { - when (uri) { - is ImageRequest -> uri.placeholderMemoryCacheKey - ?: uri.memoryCacheKey + val placeholderMemoryCacheKey = remember(memoryCacheKey, artworkModel) { + when (artworkModel) { + is ImageRequest -> artworkModel.placeholderMemoryCacheKey + ?: artworkModel.memoryCacheKey ?: memoryCacheKey?.let { MemoryCache.Key(it) } else -> memoryCacheKey?.let { MemoryCache.Key(it) } } } - val requestModel = remember(context, uri, requestTargetSize) { - when (uri) { - is ImageRequest -> uri.newBuilder(context).apply { + val requestModel = remember(context, artworkModel, requestTargetSize) { + when (artworkModel) { + is ImageRequest -> artworkModel.newBuilder(context).apply { size(requestTargetSize) - if (uri.memoryCacheKey == null) { + if (artworkModel.memoryCacheKey == null) { memoryCacheKey(memoryCacheKey) } placeholderMemoryCacheKey(placeholderMemoryCacheKey) }.build() else -> ImageRequest.Builder(context) - .data(uri) + .data(artworkModel) .crossfade(350) .error(R.drawable.ic_music_placeholder) .size(requestTargetSize) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/SmartImage.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/SmartImage.kt index cdbdc6a2..8064c5a0 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/SmartImage.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/components/SmartImage.kt @@ -37,6 +37,7 @@ import coil.size.Size import coil.compose.SubcomposeAsyncImage import coil.compose.SubcomposeAsyncImageContent import com.lostf1sh.pixelplayeross.R +import com.lostf1sh.pixelplayeross.utils.LocalArtworkUri val SmartImageCompactListTargetSize = Size(96, 96) val SmartImageListTargetSize = Size(128, 128) @@ -91,24 +92,31 @@ fun SmartImage( return } + val artworkModel = rememberArtworkModelWithCacheVersion(model) val request = remember( context, - model, + artworkModel, crossfadeDurationMillis, useDiskCache, useMemoryCache, allowHardware, requestTargetSize ) { - if (model is ImageRequest) { - model.newBuilder(context) + if (artworkModel is ImageRequest) { + artworkModel.newBuilder(context) .size(requestTargetSize) .build() } else { ImageRequest.Builder(context) - .data(model) + .data(artworkModel) .crossfade(crossfadeDurationMillis) - .diskCachePolicy(if (useDiskCache) CachePolicy.ENABLED else CachePolicy.DISABLED) + .diskCachePolicy( + if (useDiskCache && !LocalArtworkUri.isLocalArtworkUri(artworkModel as? String)) { + CachePolicy.ENABLED + } else { + CachePolicy.DISABLED + } + ) .memoryCachePolicy(if (useMemoryCache) CachePolicy.ENABLED else CachePolicy.DISABLED) .allowHardware(allowHardware) .size(requestTargetSize) diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt index fa8d84ab..0f3bb304 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/screens/SettingsCategoryScreen.kt @@ -11,7 +11,6 @@ import androidx.compose.ui.draw.rotate import android.app.Activity import android.content.Context import android.net.Uri -import android.Manifest import android.os.Build import android.os.Environment import android.os.SystemClock @@ -19,7 +18,7 @@ import android.text.format.Formatter import android.widget.Toast import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.isGranted -import com.google.accompanist.permissions.rememberPermissionState +import com.google.accompanist.permissions.rememberMultiplePermissionsState import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedContent @@ -254,13 +253,12 @@ fun SettingsCategoryScreen( val useFolderAlbumArt by settingsViewModel.useFolderAlbumArt.collectAsStateWithLifecycle() // Reading a cover image next to an audio file needs READ_MEDIA_IMAGES on API 33+; - // READ_MEDIA_AUDIO covers audio files only. Below that, READ_EXTERNAL_STORAGE (already - // granted during setup) is enough, so there is nothing to request. + // READ_MEDIA_AUDIO covers audio files only. Older Android versions use the storage + // permission also requested during setup; it may have been revoked since then. // - // The result is taken from the request callback rather than by observing the granted state, - // because a denial leaves that state unchanged and would report nothing back to the user. A - // partial "Select photos" grant on API 34+ also arrives here as "not granted", which is - // correct: it gives no access to arbitrary music folders, so the setting must stay off. + // Request selected-photo access alongside full access on Android 14+. Without it Android's + // compatibility mode reports a temporary full grant even when only selected photos are + // readable. Folder discovery needs full access, so a partial grant leaves the setting off. val folderArtRefreshingMessage = stringResource(R.string.setcat_folder_album_art_refreshing) val folderArtPermissionMessage = stringResource(R.string.setcat_folder_album_art_permission_required) @@ -268,28 +266,26 @@ fun SettingsCategoryScreen( settingsViewModel.setUseFolderAlbumArt(true) Toast.makeText(context, folderArtRefreshingMessage, Toast.LENGTH_SHORT).show() } - val imagesPermissionState = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - rememberPermissionState(Manifest.permission.READ_MEDIA_IMAGES) { granted -> - if (granted) { - enableFolderAlbumArt() - } else { - Toast.makeText(context, folderArtPermissionMessage, Toast.LENGTH_LONG).show() - } + val folderArtPermissions = remember { + com.lostf1sh.pixelplayeross.utils.folderArtworkPermissions(Build.VERSION.SDK_INT) + } + val imagesPermissionState = rememberMultiplePermissionsState(folderArtPermissions) { grants -> + if (grants[folderArtPermissions.first()] == true) { + enableFolderAlbumArt() + } else { + Toast.makeText(context, folderArtPermissionMessage, Toast.LENGTH_LONG).show() } - } else { - null } // Shown as off when the permission is gone even though the preference is still true: the // feature genuinely is not working in that state, and tapping it re-requests access. - val hasImagesPermission = - imagesPermissionState == null || imagesPermissionState.status.isGranted + val hasImagesPermission = imagesPermissionState.permissions.any { + it.permission == folderArtPermissions.first() && it.status.isGranted + } val onFolderAlbumArtToggled: (Boolean) -> Unit = { enabled -> when { !enabled -> settingsViewModel.setUseFolderAlbumArt(false) - // Null below API 33, where no image permission exists to request. - imagesPermissionState == null || imagesPermissionState.status.isGranted -> - enableFolderAlbumArt() - else -> imagesPermissionState.launchPermissionRequest() + hasImagesPermission -> enableFolderAlbumArt() + else -> imagesPermissionState.launchMultiplePermissionRequest() } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt index 7eb5294d..866844be 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/settings/search/SettingsRegistry.kt @@ -109,7 +109,8 @@ object SettingsRegistry { // prompt, which only the settings screen can launch. type = SettingType.NAVIGABLE_CARD, keywordsStatic = listOf( - "cover", "cover.jpg", "folder.jpg", "album art", "artwork", "image", "folder" + "cover", "cover.jpg", "folder.jpg", "album art", "artwork", "image", "folder", + "artist", "artist.jpg", "artist.png", "band.jpg", "band.png", "artist photo" ) ), SettingSpec( diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ColorSchemeProcessor.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ColorSchemeProcessor.kt index 54d48a8c..898a24e7 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ColorSchemeProcessor.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ColorSchemeProcessor.kt @@ -25,6 +25,7 @@ import com.lostf1sh.pixelplayeross.ui.theme.extractSeedColor import com.lostf1sh.pixelplayeross.ui.theme.generateColorSchemeFromSeed import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -51,6 +52,9 @@ class ColorSchemeProcessor @Inject constructor( private val memoryCache = LruCache(20) private val processingMutex = Mutex() private val inProgressUris = mutableSetOf() + private val localArtworkCacheMutex = Mutex() + @Volatile + private var localArtworkGeneration = 0L /** * Channel for queuing color scheme requests. @@ -77,11 +81,13 @@ class ColorSchemeProcessor @Inject constructor( forceRefresh: Boolean = false ): ColorSchemePair? = traceAsyncSection("ColorSchemeProcessor.getOrGenerate") { val resolvedAccuracyLevel = AlbumArtColorAccuracy.clamp(colorAccuracyLevel) + val generation = localArtworkGeneration if (!forceRefresh) { loadCachedColorScheme( albumArtUri = albumArtUri, paletteStyle = paletteStyle, - colorAccuracyLevel = resolvedAccuracyLevel + colorAccuracyLevel = resolvedAccuracyLevel, + expectedLocalGeneration = generation )?.let { schemePair -> return@traceAsyncSection schemePair } @@ -91,7 +97,8 @@ class ColorSchemeProcessor @Inject constructor( albumArtUri = albumArtUri, paletteStyle = paletteStyle, colorAccuracyLevel = resolvedAccuracyLevel, - forceRefresh = forceRefresh + forceRefresh = forceRefresh, + expectedLocalGeneration = generation ) } @@ -101,15 +108,18 @@ class ColorSchemeProcessor @Inject constructor( colorAccuracyLevel: Int = AlbumArtColorAccuracy.DEFAULT ): ColorSchemePair? { val resolvedAccuracyLevel = AlbumArtColorAccuracy.clamp(colorAccuracyLevel) + val generation = localArtworkGeneration return loadCachedColorScheme( albumArtUri = albumArtUri, paletteStyle = paletteStyle, - colorAccuracyLevel = resolvedAccuracyLevel + colorAccuracyLevel = resolvedAccuracyLevel, + expectedLocalGeneration = generation ) ?: generateAndCacheColorScheme( albumArtUri = albumArtUri, paletteStyle = paletteStyle, colorAccuracyLevel = resolvedAccuracyLevel, - persistToDatabase = false + persistToDatabase = false, + expectedLocalGeneration = generation ) } @@ -122,7 +132,8 @@ class ColorSchemeProcessor @Inject constructor( paletteStyle: AlbumArtPaletteStyle, colorAccuracyLevel: Int, persistToDatabase: Boolean = true, - forceRefresh: Boolean = false + forceRefresh: Boolean = false, + expectedLocalGeneration: Long ): ColorSchemePair? = traceAsyncSection("ColorSchemeProcessor.generate") { try { val cacheKey = buildCacheKey(albumArtUri, paletteStyle, colorAccuracyLevel) @@ -130,36 +141,46 @@ class ColorSchemeProcessor @Inject constructor( loadBitmapForColorExtraction(albumArtUri, forceRefresh) } ?: return@traceAsyncSection null - val schemePair = withContext(Dispatchers.Default) { - val seed = extractSeedColor( - bitmap = bitmap, - config = com.lostf1sh.pixelplayeross.ui.theme.ColorExtractionConfig( - accuracyLevel = colorAccuracyLevel + val schemePair = try { + withContext(Dispatchers.Default) { + val seed = extractSeedColor( + bitmap = bitmap, + config = com.lostf1sh.pixelplayeross.ui.theme.ColorExtractionConfig( + accuracyLevel = colorAccuracyLevel + ) ) - ) + generateColorSchemeFromSeed(seedColor = seed, paletteStyle = paletteStyle) + } + } finally { bitmap.recycle() - generateColorSchemeFromSeed( - seedColor = seed, - paletteStyle = paletteStyle - ) } - memoryCache.put(cacheKey, schemePair) - - if (persistToDatabase) { - withContext(Dispatchers.IO) { - albumArtThemeDao.insertTheme( - mapColorSchemePairToEntity( - uri = albumArtUri, - paletteStyle = paletteStyle, - colorAccuracyLevel = colorAccuracyLevel, - pair = schemePair + suspend fun cacheScheme(): ColorSchemePair { + if (persistToDatabase) { + withContext(Dispatchers.IO) { + albumArtThemeDao.insertTheme( + mapColorSchemePairToEntity( + uri = albumArtUri, + paletteStyle = paletteStyle, + colorAccuracyLevel = colorAccuracyLevel, + pair = schemePair + ) ) - ) + } } + memoryCache.put(cacheKey, schemePair) + return schemePair } - schemePair + if (LocalArtworkUri.isLocalArtworkUri(albumArtUri)) { + localArtworkCacheMutex.withLock { + if (expectedLocalGeneration != localArtworkGeneration) null else cacheScheme() + } + } else { + cacheScheme() + } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { null } @@ -193,6 +214,8 @@ class ColorSchemeProcessor @Inject constructor( drawable.draw(canvas) } } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { null } @@ -248,6 +271,20 @@ class ColorSchemeProcessor @Inject constructor( } } + /** Retire only local palettes; extraction resumes lazily for visible/current artwork. */ + suspend fun invalidateLocalArtworkSchemes() { + localArtworkCacheMutex.withLock { + localArtworkGeneration += 1 + memoryCache.snapshot().keys + .filter(LocalArtworkUri::isLocalArtworkUri) + .forEach { memoryCache.remove(it) } + clearExtractedColorCache() + withContext(Dispatchers.IO) { + albumArtThemeDao.deleteLocalArtworkThemes() + } + } + } + private fun removeUriFromMemoryCache(uri: String) { val prefix = "$uri$CACHE_KEY_SEPARATOR" memoryCache.snapshot().keys @@ -405,22 +442,32 @@ class ColorSchemeProcessor @Inject constructor( private suspend fun loadCachedColorScheme( albumArtUri: String, paletteStyle: AlbumArtPaletteStyle, - colorAccuracyLevel: Int + colorAccuracyLevel: Int, + expectedLocalGeneration: Long ): ColorSchemePair? { val cacheKey = buildCacheKey(albumArtUri, paletteStyle, colorAccuracyLevel) - memoryCache.get(cacheKey)?.let { return it } + suspend fun loadScheme(): ColorSchemePair? { + memoryCache.get(cacheKey)?.let { return it } + + val cachedEntity = withContext(Dispatchers.IO) { + albumArtThemeDao.getThemeByUriAndStyle( + albumArtUri, + paletteStyleCacheKey(paletteStyle, colorAccuracyLevel) + ) + } ?: return null - val cachedEntity = withContext(Dispatchers.IO) { - albumArtThemeDao.getThemeByUriAndStyle( - albumArtUri, - paletteStyleCacheKey(paletteStyle, colorAccuracyLevel) - ) + return mapEntityToColorSchemePair(cachedEntity).also { schemePair -> + memoryCache.put(cacheKey, schemePair) + } } - if (cachedEntity == null) return null - return mapEntityToColorSchemePair(cachedEntity).also { schemePair -> - memoryCache.put(cacheKey, schemePair) + return if (LocalArtworkUri.isLocalArtworkUri(albumArtUri)) { + localArtworkCacheMutex.withLock { + if (expectedLocalGeneration != localArtworkGeneration) null else loadScheme() + } + } else { + loadScheme() } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt index 2d4f41c0..2f5c7c21 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/SettingsViewModel.kt @@ -21,7 +21,7 @@ import com.lostf1sh.pixelplayeross.data.preferences.CarouselStyle import com.lostf1sh.pixelplayeross.data.preferences.LibraryNavigationMode import com.lostf1sh.pixelplayeross.data.preferences.ThemePreference import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository -import com.lostf1sh.pixelplayeross.data.media.ImageCacheManager +import com.lostf1sh.pixelplayeross.utils.FolderArtworkSettingsCoordinator import com.lostf1sh.pixelplayeross.data.preferences.AlbumArtQuality import com.lostf1sh.pixelplayeross.data.preferences.AlbumArtColorAccuracy import com.lostf1sh.pixelplayeross.data.preferences.AlbumArtPaletteStyle @@ -182,7 +182,7 @@ class SettingsViewModel @Inject constructor( private val lyricsRepository: LyricsRepository, private val musicRepository: MusicRepository, private val backupManager: BackupManager, - private val imageCacheManager: ImageCacheManager, + private val folderArtworkSettingsCoordinator: FolderArtworkSettingsCoordinator, @ApplicationContext private val context: Context ) : ViewModel() { @@ -927,21 +927,7 @@ class SettingsViewModel @Inject constructor( */ fun setUseFolderAlbumArt(enabled: Boolean) { viewModelScope.launch { - // Deliberately not skipped when the stored value already matches: re-enabling after - // the image permission was granted back leaves the preference unchanged but still - // needs the caches rebuilt, because everything was resolved without folder access. - // Mirrored before the write so the app-wide observer sees its own value and does not - // invalidate a second time. - com.lostf1sh.pixelplayeross.utils.AlbumArtUtils.setFolderAlbumArtPreference(enabled) - userPreferencesRepository.setUseFolderAlbumArt(enabled) - com.lostf1sh.pixelplayeross.utils.AlbumArtCacheManager.clearAllCache(context) - imageCacheManager.clearAllCoverArtCaches() - // Record what the cache is being rebuilt under so the app-wide reconciler recognises - // its own state and does not clear it again. - userPreferencesRepository.setFolderAlbumArtCacheState( - com.lostf1sh.pixelplayeross.utils.AlbumArtUtils.isFolderAlbumArtEnabled(context) - ) - syncManager.fullSync() + folderArtworkSettingsCoordinator.setEnabled(enabled) } } diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolder.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolder.kt index 3739a90b..701ebc7d 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolder.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolder.kt @@ -9,15 +9,21 @@ import com.lostf1sh.pixelplayeross.data.preferences.ThemePreferencesRepository import com.lostf1sh.pixelplayeross.ui.theme.DarkColorScheme import com.lostf1sh.pixelplayeross.ui.theme.clearExtractedColorCache import com.lostf1sh.pixelplayeross.utils.traceAsyncSection +import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils +import com.lostf1sh.pixelplayeross.utils.LocalArtworkUri import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import timber.log.Timber @@ -51,6 +57,15 @@ class ThemeStateHolder @Inject constructor( fun initialize(scope: CoroutineScope) { this.scope = scope + var observedArtworkVersion = AlbumArtUtils.artworkCacheVersion.value + + scope.launch { + AlbumArtUtils.artworkCacheVersion.collectLatest { version -> + if (version == observedArtworkVersion) return@collectLatest + observedArtworkVersion = version + refreshObservedLocalArtworkSchemes() + } + } scope.launch { combine( @@ -83,7 +98,7 @@ class ThemeStateHolder @Inject constructor( if (!paletteChanged) return@collect val uri = _currentAlbumArtUri.value ?: return@collect - val refreshedScheme = colorSchemeProcessor.getOrGenerateColorScheme( + val refreshedScheme = getCurrentArtworkColorScheme( albumArtUri = uri, paletteStyle = style, colorAccuracyLevel = accuracy @@ -115,7 +130,7 @@ class ThemeStateHolder @Inject constructor( } val uriString = albumArtUriAsUri.toString() - val schemePair = colorSchemeProcessor.getOrGenerateColorScheme( + val schemePair = getCurrentArtworkColorScheme( albumArtUri = uriString, paletteStyle = currentPaletteStyle, colorAccuracyLevel = currentPaletteAccuracy @@ -125,6 +140,8 @@ class ThemeStateHolder @Inject constructor( _currentAlbumArtColorSchemePair.value = schemePair _currentAlbumArtUri.value = uriString } + } catch (e: CancellationException) { + throw e } catch (e: Exception) { if (!isPreload && albumArtUriAsUri != null && currentSongUriString == albumArtUriAsUri.toString()) { _currentAlbumArtColorSchemePair.value = null @@ -140,6 +157,50 @@ class ThemeStateHolder @Inject constructor( } } + private suspend fun refreshObservedLocalArtworkSchemes() { + val localFlows = individualAlbumColorSchemes.entries + .filter { LocalArtworkUri.isLocalArtworkUri(it.key) } + .map { it.key to it.value } + localFlows.forEach { (_, flow) -> flow.value = null } + localFlows.filter { (_, flow) -> flow.subscriptionCount.value > 0 } + .forEach { (uri, flow) -> requestAlbumColorSchemeGeneration(uri, flow) } + + val currentUri = _currentAlbumArtUri.value?.takeIf(LocalArtworkUri::isLocalArtworkUri) ?: return + _currentAlbumArtColorSchemePair.value = null + val scheme = getCurrentArtworkColorScheme( + albumArtUri = currentUri, + paletteStyle = currentPaletteStyle, + colorAccuracyLevel = currentPaletteAccuracy + ) + if (_currentAlbumArtUri.value == currentUri) { + _currentAlbumArtColorSchemePair.value = scheme + individualAlbumColorSchemes[currentUri]?.value = scheme + } + } + + private suspend fun getCurrentArtworkColorScheme( + albumArtUri: String, + paletteStyle: AlbumArtPaletteStyle, + colorAccuracyLevel: Int, + forceRefresh: Boolean = false + ): ColorSchemePair? { + while (true) { + currentCoroutineContext().ensureActive() + val artworkVersion = AlbumArtUtils.artworkCacheVersion.value + val scheme = colorSchemeProcessor.getOrGenerateColorScheme( + albumArtUri = albumArtUri, + paletteStyle = paletteStyle, + colorAccuracyLevel = colorAccuracyLevel, + forceRefresh = forceRefresh + ) + if (!LocalArtworkUri.isLocalArtworkUri(albumArtUri) || + artworkVersion == AlbumArtUtils.artworkCacheVersion.value + ) { + return scheme + } + } + } + private val individualAlbumColorSchemes = object : LinkedHashMap>( 32, 0.75f, true ) { @@ -181,18 +242,31 @@ class ThemeStateHolder @Inject constructor( requestScope.launch(Dispatchers.IO) { var scheme: ColorSchemePair? = null + var cancelled = false + val requestedArtworkVersion = AlbumArtUtils.artworkCacheVersion.value try { - scheme = colorSchemeProcessor.getOrGenerateColorScheme( + scheme = getCurrentArtworkColorScheme( albumArtUri = uriString, paletteStyle = currentPaletteStyle, colorAccuracyLevel = currentPaletteAccuracy ) + } catch (e: CancellationException) { + cancelled = true + throw e } catch (_: Exception) { } finally { val targets = synchronized(pendingAlbumColorSchemeLock) { pendingAlbumColorSchemeTargets.remove(uriString)?.toList().orEmpty() } - targets.forEach { it.value = scheme } + if (!cancelled) { + if (LocalArtworkUri.isLocalArtworkUri(uriString) && + requestedArtworkVersion != AlbumArtUtils.artworkCacheVersion.value + ) { + targets.forEach { requestAlbumColorSchemeGeneration(uriString, it) } + } else { + targets.forEach { it.value = scheme } + } + } } } } @@ -232,7 +306,7 @@ class ThemeStateHolder @Inject constructor( } suspend fun getOrGenerateColorScheme(uriString: String): ColorSchemePair? { - return colorSchemeProcessor.getOrGenerateColorScheme( + return getCurrentArtworkColorScheme( albumArtUri = uriString, paletteStyle = currentPaletteStyle, colorAccuracyLevel = currentPaletteAccuracy @@ -257,7 +331,7 @@ class ThemeStateHolder @Inject constructor( val newScheme = if (regenerateAllStyles) { var selectedStyleScheme: ColorSchemePair? = null AlbumArtPaletteStyle.entries.forEach { style -> - val generated = colorSchemeProcessor.getOrGenerateColorScheme( + val generated = getCurrentArtworkColorScheme( albumArtUri = uriString, paletteStyle = style, colorAccuracyLevel = currentPaletteAccuracy, @@ -269,7 +343,7 @@ class ThemeStateHolder @Inject constructor( } selectedStyleScheme } else { - colorSchemeProcessor.getOrGenerateColorScheme( + getCurrentArtworkColorScheme( albumArtUri = uriString, paletteStyle = currentPaletteStyle, colorAccuracyLevel = currentPaletteAccuracy, diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt index 636b4af8..2d7f2c98 100644 --- a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtils.kt @@ -18,12 +18,16 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update import timber.log.Timber import java.io.ByteArrayOutputStream import java.io.File import java.io.FileInputStream import java.io.InputStream import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicLong import kotlin.math.roundToInt object AlbumArtUtils { @@ -66,6 +70,22 @@ object AlbumArtUtils { fun folderAlbumArtPreferenceOrNull(): Boolean? = folderAlbumArtPreference private val appScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val artworkCacheWriteLock = Any() + private val artworkCacheGeneration = AtomicLong() + private val _artworkCacheVersion = MutableStateFlow(0L) + val artworkCacheVersion = _artworkCacheVersion.asStateFlow() + + /** Publish only after file and Coil caches are cleared, so mounted images can reload safely. */ + internal fun notifyArtworkCacheInvalidated() { + _artworkCacheVersion.update { it + 1L } + } + + /** Prevent in-flight reads from restoring covers or miss markers after settings invalidate them. */ + internal fun bumpArtworkCacheGeneration() { + synchronized(artworkCacheWriteLock) { + artworkCacheGeneration.incrementAndGet() + } + } // Tracks cache files currently being shrunk so rapid repeated loads of the same oversized // cover don't read the large blob into memory more than once concurrently. private val artworkShrinkInFlight = ConcurrentHashMap.newKeySet() @@ -189,6 +209,7 @@ object AlbumArtUtils { filePath: String? = null, forceRefresh: Boolean = false ): File? { + val generation = artworkCacheGeneration.get() val cachedFile = getCachedAlbumArtFile(appContext, songId) val noArtFile = noArtMarkerFile(appContext, songId) @@ -214,17 +235,19 @@ object AlbumArtUtils { // Folder covers outrank embedded pictures when the user has opted in: a cover.jpg is // usually the full-resolution original, while embedded art is often a downscaled copy. readExternalAlbumArtBytes(resolvedPath, isFolderAlbumArtEnabled(appContext))?.let { bytes -> - cacheAlbumArtBytes(appContext, bytes, songId) - return cachedFile.takeIf { it.exists() && it.length() > 0 } + return cacheAlbumArtBytesIfCurrent(appContext, bytes, songId, generation) } extractEmbeddedAlbumArtBytes(resolvedPath)?.let { bytes -> - cacheAlbumArtBytes(appContext, bytes, songId) - return cachedFile.takeIf { it.exists() && it.length() > 0 } + return cacheAlbumArtBytesIfCurrent(appContext, bytes, songId, generation) } - cachedFile.delete() - noArtFile.createNewFile() + synchronized(artworkCacheWriteLock) { + if (generation == artworkCacheGeneration.get()) { + cachedFile.delete() + noArtFile.createNewFile() + } + } return null } @@ -254,6 +277,7 @@ object AlbumArtUtils { songId: Long, deepScan: Boolean ): Boolean { + val generation = artworkCacheGeneration.get() val audioFile = File(filePath) if (!audioFile.exists() || !audioFile.canRead()) { return false @@ -288,8 +312,12 @@ object AlbumArtUtils { return true } - cachedFile.delete() - noArtFile.createNewFile() + synchronized(artworkCacheWriteLock) { + if (generation == artworkCacheGeneration.get()) { + cachedFile.delete() + noArtFile.createNewFile() + } + } return false } @@ -321,13 +349,18 @@ object AlbumArtUtils { return preferred && canReadImageFiles(appContext) } - /** Below API 33 the already-granted READ_EXTERNAL_STORAGE covers sibling image files. */ - internal fun canReadImageFiles(appContext: Context): Boolean { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return true - return ContextCompat.checkSelfPermission( - appContext, + /** Both image access and legacy shared-storage access can be revoked in system settings. */ + @android.annotation.SuppressLint("InlinedApi") // Guarded by the SDK argument, injectable for tests. + internal fun canReadImageFiles( + appContext: Context, + sdkInt: Int = Build.VERSION.SDK_INT + ): Boolean { + val permission = if (sdkInt >= Build.VERSION_CODES.TIRAMISU) { Manifest.permission.READ_MEDIA_IMAGES - ) == PackageManager.PERMISSION_GRANTED + } else { + Manifest.permission.READ_EXTERNAL_STORAGE + } + return ContextCompat.checkSelfPermission(appContext, permission) == PackageManager.PERMISSION_GRANTED } /** @@ -340,23 +373,66 @@ object AlbumArtUtils { maxBytes: Long = MAX_EXTERNAL_ART_BYTES ): ByteArray? { if (!enabled) return null - val artFile = findExternalAlbumArtFile(filePath) ?: return null - if (artFile.length() > maxBytes) return null - return runCatching { artFile.readBytes() }.getOrNull()?.takeIf { it.isNotEmpty() } + return externalAlbumArtCandidates(filePath).firstNotNullOfOrNull { artFile -> + readExternalArtworkFileBytes(artFile, maxBytes) + } } - internal fun findExternalAlbumArtFile(filePath: String): File? { - val audioFile = File(filePath) - val directory = audioFile.parentFile ?: return null - if (!directory.exists() || !directory.isDirectory) return null - if (!shouldTrustDirectoryArtwork(directory.name)) return null - - return commonArtworkFileNames - .asSequence() - .map { name -> File(directory, name) } - .firstOrNull { artFile -> - artFile.exists() && artFile.isFile && artFile.length() > 1024 + internal fun findExternalAlbumArtFile(filePath: String): File? = + externalAlbumArtCandidates(filePath).firstOrNull() + + private fun externalAlbumArtCandidates(filePath: String): List { + val directory = File(filePath).parentFile ?: return emptyList() + if (!directory.isDirectory || !shouldTrustDirectoryArtwork(directory.name)) return emptyList() + + // Android file systems are case-sensitive, but cover names frequently use .JPG or Cover.jpg. + val files = directory.listFiles()?.filter { it.isFile && it.length() > 0 } ?: return emptyList() + return commonArtworkFileNames.flatMap { name -> + files.filter { it.name.equals(name, ignoreCase = true) }.sortedBy { it.name } + } + } + + /** Shared validation for folder album covers and artist portraits. */ + internal fun isUsableExternalArtwork(file: File): Boolean = + readExternalArtworkFileBytes(file) != null + + internal fun readExternalArtworkFileBytes( + file: File, + maxBytes: Long = MAX_EXTERNAL_ART_BYTES + ): ByteArray? = runCatching { + if (!file.isFile || !file.canRead() || file.length() > maxBytes) return null + val bytes = file.inputStream().use { readArtworkBytesWithinLimit(it, maxBytes) } ?: return null + val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true } + BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds) + if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return null + + // Bounds alone also succeed for some truncated images. Check a small decoded thumbnail + // before allowing an arbitrary sibling file to override embedded artwork. + val options = BitmapFactory.Options().apply { + inSampleSize = calculateArtworkInSampleSize(bounds.outWidth, bounds.outHeight, 64) + } + val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size, options) ?: return null + decoded.recycle() + bytes + }.getOrNull() + + internal fun readArtworkBytesWithinLimit(input: InputStream, maxBytes: Long): ByteArray? { + if (maxBytes <= 0) return null + return ByteArrayOutputStream().use { output -> + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var total = 0L + while (true) { + // Read at most one byte beyond the limit, even if the file grows after stat(). + val remaining = maxBytes - total + val readLength = if (remaining >= buffer.size) buffer.size else remaining.toInt() + 1 + val count = input.read(buffer, 0, readLength) + if (count == -1) break + total += count + if (total > maxBytes) return null + output.write(buffer, 0, count) } + output.toByteArray().takeIf { it.isNotEmpty() } + } } internal fun shouldTrustDirectoryArtwork(directoryName: String): Boolean { @@ -456,6 +532,28 @@ object AlbumArtUtils { val file = getCachedAlbumArtFile(appContext, songId) val boundedBytes = boundArtworkForCache(bytes) + return writeAlbumArtBytes(appContext, file, boundedBytes, songId) + } + + private fun cacheAlbumArtBytesIfCurrent( + appContext: Context, + bytes: ByteArray, + songId: Long, + generation: Long + ): File? { + val boundedBytes = boundArtworkForCache(bytes) + return synchronized(artworkCacheWriteLock) { + if (generation != artworkCacheGeneration.get()) return@synchronized null + writeAlbumArtBytes(appContext, getCachedAlbumArtFile(appContext, songId), boundedBytes, songId) + } + } + + private fun writeAlbumArtBytes( + appContext: Context, + file: File, + boundedBytes: ByteArray, + songId: Long + ): File { file.outputStream().use { outputStream -> outputStream.write(boundedBytes) } @@ -477,6 +575,7 @@ object AlbumArtUtils { if (file.length() <= OVERSIZED_CACHED_ART_BYTES) return val key = file.absolutePath if (!artworkShrinkInFlight.add(key)) return + val generation = artworkCacheGeneration.get() appScope.launch { try { if (file.length() <= OVERSIZED_CACHED_ART_BYTES) return@launch @@ -486,8 +585,12 @@ object AlbumArtUtils { val tmp = File(file.parentFile, "${file.name}.shrink.tmp") runCatching { tmp.outputStream().use { it.write(bounded) } - if (!tmp.renameTo(file)) { - file.outputStream().use { it.write(bounded) } + synchronized(artworkCacheWriteLock) { + if (generation == artworkCacheGeneration.get() && file.exists()) { + if (!tmp.renameTo(file)) { + file.outputStream().use { it.write(bounded) } + } + } tmp.delete() } } @@ -661,24 +764,3 @@ internal fun resolveAlbumArtUriForLibraryScan( } return LocalArtworkUri.buildSongUri(songId) } - -/** What the app-wide preference observer should do with a value it just observed. */ -internal enum class FolderAlbumArtUpdate { - /** Already mirrored — whoever set it handled any invalidation. */ - IGNORE, - - /** First value seen this process; nothing was cached under a different setting yet. */ - MIRROR_ONLY, - - /** Changed out-of-band (a backup restore), so cached artwork is now stale. */ - MIRROR_AND_INVALIDATE -} - -internal fun resolveFolderAlbumArtUpdate( - previous: Boolean?, - observed: Boolean -): FolderAlbumArtUpdate = when { - previous == observed -> FolderAlbumArtUpdate.IGNORE - previous == null -> FolderAlbumArtUpdate.MIRROR_ONLY - else -> FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE -} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtistArtUtils.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtistArtUtils.kt new file mode 100644 index 00000000..b763c313 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtistArtUtils.kt @@ -0,0 +1,63 @@ +package com.lostf1sh.pixelplayeross.utils + +import java.io.File +import java.text.Normalizer +import java.util.Locale + +/** Explicit artist portraits beside an album directory, without walking the storage tree. */ +internal object FolderArtistArtUtils { + private val artworkNames = listOf("artist.jpg", "artist.png", "band.jpg", "band.png") + + fun findArtistImage( + audioDirectoryPaths: List, + artistName: String, + isUsableArtwork: (File) -> Boolean = AlbumArtUtils::isUsableExternalArtwork + ): File? { + val normalizedArtistName = normalizeName(artistName) + val visited = mutableSetOf() + for (path in audioDirectoryPaths) { + val audioDirectory = canonicalDirectory(path) ?: continue + // Conventional layouts: Artist/Album/track or Artist/Album/Disc/track. + // Only named artist ancestors qualify, so collaborators and loose tracks do + // not inherit another artist's portrait or an image at the storage root. + val artistAncestors = listOfNotNull( + audioDirectory.parentFile, + audioDirectory.parentFile?.parentFile + ).filter { normalizedArtistName.isNotEmpty() && normalizeName(it.name) == normalizedArtistName } + for (directory in artistAncestors + audioDirectory) { + if (!visited.add(directory.path)) continue + if (!AlbumArtUtils.shouldTrustDirectoryArtwork(directory.name)) continue + + val candidates = runCatching { + directory.listFiles { file -> + file.name.lowercase(Locale.ROOT) in artworkNames + }?.sortedWith( + compareBy { artworkNames.indexOf(it.name.lowercase(Locale.ROOT)) } + .thenBy { it.name } + ) + }.getOrNull().orEmpty() + + for (candidate in candidates) { + val image = runCatching { candidate.canonicalFile }.getOrNull() ?: continue + // A portrait symlink must not escape the directory being inspected. + if (image.parentFile != directory || !image.isFile || !image.canRead()) continue + if (runCatching { isUsableArtwork(image) }.getOrDefault(false)) return image + } + } + } + return null + } + + private fun normalizeName(value: String): String = Normalizer.normalize(value, Normalizer.Form.NFKC) + .lowercase(Locale.ROOT) + .filter { it.isLetterOrDigit() } + + private fun canonicalDirectory(path: String): File? { + if (path.isBlank()) return null + val directory = File(path) + if (!directory.isAbsolute) return null + return runCatching { directory.canonicalFile } + .getOrNull() + ?.takeIf { it.isDirectory } + } +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkPermissions.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkPermissions.kt new file mode 100644 index 00000000..04abd052 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkPermissions.kt @@ -0,0 +1,15 @@ +package com.lostf1sh.pixelplayeross.utils + +import android.Manifest +import android.os.Build + +/** Explicit selected-photo handling avoids Android 14's temporary false full-access grant. */ +@android.annotation.SuppressLint("InlinedApi") // Guarded by the SDK argument, injectable for tests. +internal fun folderArtworkPermissions(sdkVersion: Int): List = when { + sdkVersion >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> listOf( + Manifest.permission.READ_MEDIA_IMAGES, + Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED + ) + sdkVersion >= Build.VERSION_CODES.TIRAMISU -> listOf(Manifest.permission.READ_MEDIA_IMAGES) + else -> listOf(Manifest.permission.READ_EXTERNAL_STORAGE) +} diff --git a/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkSettingsCoordinator.kt b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkSettingsCoordinator.kt new file mode 100644 index 00000000..345f2e98 --- /dev/null +++ b/app/src/main/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkSettingsCoordinator.kt @@ -0,0 +1,101 @@ +package com.lostf1sh.pixelplayeross.utils + +import android.content.Context +import com.lostf1sh.pixelplayeross.data.media.ImageCacheManager +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository +import com.lostf1sh.pixelplayeross.data.repository.ArtistImageRepository +import com.lostf1sh.pixelplayeross.data.worker.SyncManager +import com.lostf1sh.pixelplayeross.presentation.viewmodel.ColorSchemeProcessor +import dagger.Lazy +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext +import timber.log.Timber +import javax.inject.Inject +import javax.inject.Singleton + +/** Serializes settings, foreground permission checks, and backup restores against the same cache. */ +@Singleton +class FolderArtworkSettingsCoordinator @Inject constructor( + @ApplicationContext private val context: Context, + private val preferences: UserPreferencesRepository, + private val imageCacheManager: ImageCacheManager, + private val artistImageRepository: ArtistImageRepository, + private val syncManager: SyncManager, + private val colorSchemeProcessor: Lazy +) { + private val mutex = Mutex() + private var legacyCacheMigrated = false + + suspend fun setEnabled(enabled: Boolean) { + update { preferences.setUseFolderAlbumArt(enabled) } + } + + suspend fun reconcile() { + update {} + } + + private suspend fun update(writePreference: suspend () -> Unit) = withContext(Dispatchers.IO) { + try { + mutex.withLock { + writePreference() + reconcileLocked() + } + } catch (error: CancellationException) { + throw error + } catch (error: Exception) { + // Leave the marker unchanged so the next foreground/settings event retries. + Timber.e(error, "Unable to reconcile folder artwork settings") + } + } + + private suspend fun reconcileLocked() { + if (!legacyCacheMigrated) { + AlbumArtUtils.migrateLegacyCacheLocation(context) + legacyCacheMigrated = true + } + + // Read inside the lock: an observer can have queued an old emission while a setting + // changed again. Applying that emission would put the mirror and marker out of sync. + val enabled = preferences.useFolderAlbumArtFlow.first() + AlbumArtUtils.setFolderAlbumArtPreference(enabled) + val effective = AlbumArtUtils.isFolderAlbumArtEnabled(context) + val recorded = preferences.folderAlbumArtCacheStateFlow.first() + if (resolveFolderAlbumArtUpdate(recorded, effective) == FolderAlbumArtUpdate.IGNORE) return + + AlbumArtUtils.bumpArtworkCacheGeneration() + AlbumArtCacheManager.clearAllCache(context) + // Also retire readers/shrink jobs that observed an old file while deletion was running. + AlbumArtUtils.bumpArtworkCacheGeneration() + imageCacheManager.clearAllCoverArtCaches() + colorSchemeProcessor.get().invalidateLocalArtworkSchemes() + artistImageRepository.clearCache() + AlbumArtUtils.notifyArtworkCacheInvalidated() + + // A song previously resolved without artwork can have a null database URI. Clearing + // files alone never requests its image again, including after a permission regrant. + if (preferences.initialSetupDoneFlow.first()) { + syncManager.fullSync() + } + preferences.setFolderAlbumArtCacheState(effective) + } +} + +internal enum class FolderAlbumArtUpdate { + IGNORE, + MIRROR_AND_INVALIDATE +} + +internal fun resolveFolderAlbumArtUpdate( + previous: Boolean?, + observed: Boolean +): FolderAlbumArtUpdate = when (previous) { + observed -> FolderAlbumArtUpdate.IGNORE + // A missing marker can describe an existing cache from an older installation or a restore. + // It does not establish that the cache is empty or was built with the current preference. + else -> FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE +} diff --git a/app/src/main/res/values-ar/strings_settings.xml b/app/src/main/res/values-ar/strings_settings.xml index 5f74068b..41431b62 100644 --- a/app/src/main/res/values-ar/strings_settings.xml +++ b/app/src/main/res/values-ar/strings_settings.xml @@ -84,9 +84,9 @@ تدوير الأنماط تلقائياً فحص وتعيين ملفات الكلمات المزامنة (.lrc) المتواجدة في نفس المجلد تلقائياً أثناء المزامنة. استخدام صور الغلاف من المجلد - استخدام cover.jpg أو folder.jpg أو صورة مشابهة من مجلد الأغنية كغلاف للألبوم، مع تفضيلها على الغلاف المضمّن. يتم تجاهل المجلدات المشتركة مثل Music و Downloads و DCIM و Pictures. - يلزم الوصول إلى الصور لقراءة صور الغلاف من مجلدات الموسيقى. - جارٍ تحديث أغلفة الألبومات… + استخدام cover.jpg أو folder.jpg أو صورة مشابهة من مجلد الأغنية كغلاف للألبوم، مع تفضيلها على الغلاف المضمّن. يتم تجاهل المجلدات المشتركة مثل Music و Downloads و DCIM و Pictures. يتم أيضًا البحث في مجلدات الفنانين عن artist.jpg وband.jpg ونسخ PNG. تُعطى الأولوية لصور الفنانين المختارة يدويًا. + يلزم الوصول الكامل إلى الصور لقراءة صور الألبومات والفنانين من مجلدات الموسيقى. + جارٍ تحديث الصور… فحص تلقائي لملفات .lrc التشغيل في الخلفية بدون إلقاء نظرة خاطفة diff --git a/app/src/main/res/values-de/strings_settings.xml b/app/src/main/res/values-de/strings_settings.xml index 60d9c617..9daf4382 100644 --- a/app/src/main/res/values-de/strings_settings.xml +++ b/app/src/main/res/values-de/strings_settings.xml @@ -84,9 +84,9 @@ Muster automatisch wechseln .lrc-Dateien im selben Ordner werden beim Sync automatisch erkannt und zugewiesen. Ordner-Coverbilder verwenden - cover.jpg, folder.jpg oder ein ähnliches Bild aus dem Ordner eines Titels wird als Albumcover verwendet, bevorzugt gegenüber eingebetteten Covern. Gemeinsam genutzte Ordner wie Music, Downloads, DCIM und Pictures werden ignoriert. - Für das Lesen von Coverbildern aus deinen Musikordnern wird die Berechtigung für Fotos benötigt. - Albumcover werden aktualisiert… + cover.jpg, folder.jpg oder ein ähnliches Bild aus dem Ordner eines Titels wird als Albumcover verwendet, bevorzugt gegenüber eingebetteten Covern. Gemeinsam genutzte Ordner wie Music, Downloads, DCIM und Pictures werden ignoriert. In Künstlerordnern wird auch nach artist.jpg, band.jpg und PNG-Varianten gesucht. Manuell gewählte Künstlerfotos haben Vorrang. + Voller Fotozugriff ist erforderlich, um Album- und Künstlerbilder aus deinen Musikordnern zu lesen. + Bilder werden aktualisiert… .lrc-Dateien automatisch scannen Hintergrundwiedergabe Kein Peek diff --git a/app/src/main/res/values-es/strings_settings.xml b/app/src/main/res/values-es/strings_settings.xml index f25e2a7e..2d2617d4 100644 --- a/app/src/main/res/values-es/strings_settings.xml +++ b/app/src/main/res/values-es/strings_settings.xml @@ -84,9 +84,9 @@ Rotar patrones automáticamente Buscar y asignar automáticamente archivos .lrc en la misma carpeta durante la sincronización de la biblioteca. Usar imágenes de portada de la carpeta - Usar cover.jpg, folder.jpg o una imagen similar de la carpeta de cada canción como carátula, en lugar de la carátula incrustada. Se ignoran las carpetas compartidas como Music, Downloads, DCIM y Pictures. - Se necesita acceso a las fotos para leer las imágenes de portada de tus carpetas de música. - Actualizando carátulas… + Usar cover.jpg, folder.jpg o una imagen similar de la carpeta de cada canción como carátula, en lugar de la carátula incrustada. Se ignoran las carpetas compartidas como Music, Downloads, DCIM y Pictures. También se buscan artist.jpg, band.jpg y variantes PNG en las carpetas de los artistas. Las fotos elegidas manualmente tienen prioridad. + Se necesita acceso completo a las fotos para leer imágenes de álbumes y artistas de tus carpetas de música. + Actualizando imágenes… Escanear archivos .lrc automáticamente Reproducción en segundo plano Sin vista lateral diff --git a/app/src/main/res/values-fr/strings_settings.xml b/app/src/main/res/values-fr/strings_settings.xml index 12c00d36..422d264c 100644 --- a/app/src/main/res/values-fr/strings_settings.xml +++ b/app/src/main/res/values-fr/strings_settings.xml @@ -84,9 +84,9 @@ Rotation auto des motifs Analyser et assigner automatiquement les fichiers .lrc du même dossier lors de la synchro. Utiliser les pochettes du dossier - Utiliser cover.jpg, folder.jpg ou une image similaire du dossier d\'un morceau comme pochette, de préférence à la pochette intégrée. Les dossiers partagés comme Music, Downloads, DCIM et Pictures sont ignorés. - L\'accès aux photos est nécessaire pour lire les images de pochette dans vos dossiers de musique. - Actualisation des pochettes… + Utiliser cover.jpg, folder.jpg ou une image similaire du dossier d\'un morceau comme pochette, de préférence à la pochette intégrée. Les dossiers partagés comme Music, Downloads, DCIM et Pictures sont ignorés. Les dossiers des artistes sont aussi parcourus pour trouver artist.jpg, band.jpg et leurs variantes PNG. Les photos choisies manuellement sont prioritaires. + L\'accès complet aux photos est nécessaire pour lire les images des albums et des artistes dans vos dossiers de musique. + Actualisation des images… Analyse auto des fichiers .lrc Lecture en arrière-plan Sans aperçu diff --git a/app/src/main/res/values-in/strings_settings.xml b/app/src/main/res/values-in/strings_settings.xml index 3d6b6007..d48c9ba0 100644 --- a/app/src/main/res/values-in/strings_settings.xml +++ b/app/src/main/res/values-in/strings_settings.xml @@ -84,9 +84,9 @@ Putar Pola Otomatis Secara otomatis memindai dan menetapkan file .lrc di folder yang sama selama sinkronisasi pustaka. Gunakan gambar sampul dari folder - Gunakan cover.jpg, folder.jpg, atau gambar serupa dari folder lagu sebagai sampul album, didahulukan daripada sampul yang tertanam. Folder bersama seperti Music, Downloads, DCIM, dan Pictures diabaikan. - Akses foto diperlukan untuk membaca gambar sampul dari folder musik Anda. - Memperbarui sampul album… + Gunakan cover.jpg, folder.jpg, atau gambar serupa dari folder lagu sebagai sampul album, didahulukan daripada sampul yang tertanam. Folder bersama seperti Music, Downloads, DCIM, dan Pictures diabaikan. Folder artis juga diperiksa untuk artist.jpg, band.jpg, dan varian PNG. Foto artis yang dipilih secara manual diutamakan. + Akses foto penuh diperlukan untuk membaca gambar album dan artis dari folder musik Anda. + Memperbarui gambar… Pindai otomatis file .lrc Pemutaran Latar Belakang Tanpa Intip diff --git a/app/src/main/res/values-it/strings_settings.xml b/app/src/main/res/values-it/strings_settings.xml index c5a2c434..c8d39439 100644 --- a/app/src/main/res/values-it/strings_settings.xml +++ b/app/src/main/res/values-it/strings_settings.xml @@ -84,9 +84,9 @@ Rotazione automatica pattern Scansiona e assegna automaticamente file .lrc nella stessa cartella durante la sincronizzazione libreria. Usa le copertine della cartella - Usa cover.jpg, folder.jpg o un\'immagine simile dalla cartella del brano come copertina dell\'album, con precedenza sulla copertina incorporata. Le cartelle condivise come Music, Downloads, DCIM e Pictures vengono ignorate. - L\'accesso alle foto è necessario per leggere le copertine dalle tue cartelle musicali. - Aggiornamento copertine… + Usa cover.jpg, folder.jpg o un\'immagine simile dalla cartella del brano come copertina dell\'album, con precedenza sulla copertina incorporata. Le cartelle condivise come Music, Downloads, DCIM e Pictures vengono ignorate. Nelle cartelle degli artisti vengono cercati anche artist.jpg, band.jpg e le varianti PNG. Le foto scelte manualmente hanno la precedenza. + È necessario l\'accesso completo alle foto per leggere le immagini degli album e degli artisti dalle cartelle musicali. + Aggiornamento immagini… Scansione automatica file .lrc Riproduzione in background Nessuna anteprima diff --git a/app/src/main/res/values-ko/strings_settings.xml b/app/src/main/res/values-ko/strings_settings.xml index 368c785d..6178f210 100644 --- a/app/src/main/res/values-ko/strings_settings.xml +++ b/app/src/main/res/values-ko/strings_settings.xml @@ -84,9 +84,9 @@ 패턴 자동 회전 라이브러리 동기화 중 같은 폴더에 있는 .lrc 파일을 자동으로 스캔하여 할당합니다. 폴더의 커버 이미지 사용 - 곡이 있는 폴더의 cover.jpg, folder.jpg 등의 이미지를 내장 아트워크보다 우선해 앨범 아트로 사용합니다. Music, Downloads, DCIM, Pictures 같은 공용 폴더는 무시됩니다. - 음악 폴더의 커버 이미지를 읽으려면 사진 접근 권한이 필요합니다. - 앨범 아트를 새로 고치는 중… + 곡이 있는 폴더의 cover.jpg, folder.jpg 등의 이미지를 내장 아트워크보다 우선해 앨범 아트로 사용합니다. Music, Downloads, DCIM, Pictures 같은 공용 폴더는 무시됩니다. 아티스트 폴더에서도 artist.jpg, band.jpg 및 PNG 파일을 찾습니다. 직접 선택한 아티스트 사진이 우선합니다. + 음악 폴더에서 앨범 및 아티스트 이미지를 읽으려면 모든 사진에 대한 접근 권한이 필요합니다. + 이미지를 새로 고치는 중… .lrc 파일 자동 스캔 백그라운드 재생 미리보기 없음 diff --git a/app/src/main/res/values-nb/strings_settings.xml b/app/src/main/res/values-nb/strings_settings.xml index d0b8b4fc..7ed94462 100644 --- a/app/src/main/res/values-nb/strings_settings.xml +++ b/app/src/main/res/values-nb/strings_settings.xml @@ -84,9 +84,9 @@ Roter mønstre automatisk Skann og tildel .lrc-filer i samme mappe automatisk under synkronisering. Bruk omslagsbilder fra mappen - Bruk cover.jpg, folder.jpg eller et lignende bilde fra sangens egen mappe som albumbilde, foran innebygd omslag. Delte mapper som Music, Downloads, DCIM og Pictures ignoreres. - Tilgang til bilder kreves for å lese omslagsbilder fra musikkmappene dine. - Oppdaterer albumbilder… + Bruk cover.jpg, folder.jpg eller et lignende bilde fra sangens egen mappe som albumbilde, foran innebygd omslag. Delte mapper som Music, Downloads, DCIM og Pictures ignoreres. Artistmapper sjekkes også for artist.jpg, band.jpg og PNG-varianter. Manuelt valgte artistbilder prioriteres. + Full tilgang til bilder kreves for å lese album- og artistbilder fra musikkmappene dine. + Oppdaterer bilder… Skann .lrc-filer automatisk Bakgrunnsavspilling Ingen kikk diff --git a/app/src/main/res/values-ru/strings_settings.xml b/app/src/main/res/values-ru/strings_settings.xml index d0751ce6..599e84eb 100644 --- a/app/src/main/res/values-ru/strings_settings.xml +++ b/app/src/main/res/values-ru/strings_settings.xml @@ -84,9 +84,9 @@ Автосмена шаблонов Автоматически искать и привязывать .lrc-файлы из той же папки во время синхронизации. Использовать обложки из папки - Использовать cover.jpg, folder.jpg или похожее изображение из папки трека в качестве обложки вместо встроенной. Общие папки вроде Music, Downloads, DCIM и Pictures игнорируются. - Для чтения обложек из ваших музыкальных папок нужен доступ к фото. - Обновление обложек… + Использовать cover.jpg, folder.jpg или похожее изображение из папки трека в качестве обложки вместо встроенной. Общие папки вроде Music, Downloads, DCIM и Pictures игнорируются. В папках исполнителей также ищутся artist.jpg, band.jpg и варианты PNG. Фотографии, выбранные вручную, имеют приоритет. + Для чтения изображений альбомов и исполнителей из музыкальных папок нужен полный доступ к фото. + Обновление изображений… Автосканирование .lrc-файлов Фоновое воспроизведение Без предпросмотра diff --git a/app/src/main/res/values-tr/strings_settings.xml b/app/src/main/res/values-tr/strings_settings.xml index 403f0236..4b82d328 100644 --- a/app/src/main/res/values-tr/strings_settings.xml +++ b/app/src/main/res/values-tr/strings_settings.xml @@ -94,9 +94,9 @@ Desenleri Otomatik Döndür Kitaplık senkronizasyonu sırasında aynı klasördeki .lrc dosyalarını otomatik olarak tara ve ata. Klasördeki kapak görsellerini kullan - Parçanın kendi klasöründeki cover.jpg, folder.jpg veya benzeri bir görseli, gömülü kapak yerine albüm kapağı olarak kullan. Music, Downloads, DCIM ve Pictures gibi ortak klasörler yok sayılır. - Müzik klasörlerinizdeki kapak görsellerini okumak için fotoğraf erişimi gerekir. - Albüm kapakları yenileniyor… + Parçanın kendi klasöründeki cover.jpg, folder.jpg veya benzeri bir görseli, gömülü kapak yerine albüm kapağı olarak kullan. Music, Downloads, DCIM ve Pictures gibi ortak klasörler yok sayılır. Sanatçı klasörlerinde artist.jpg, band.jpg ve PNG sürümleri de aranır. Elle seçilen sanatçı fotoğrafları önceliklidir. + Müzik klasörlerinizdeki albüm ve sanatçı görsellerini okumak için tüm fotoğraflara erişim gerekir. + Görseller yenileniyor… .lrc dosyalarını otomatik tara Arka Planda Oynatma Göz Atma Yok diff --git a/app/src/main/res/values-zh-rCN/strings_settings.xml b/app/src/main/res/values-zh-rCN/strings_settings.xml index fc05b149..6b695b85 100644 --- a/app/src/main/res/values-zh-rCN/strings_settings.xml +++ b/app/src/main/res/values-zh-rCN/strings_settings.xml @@ -53,9 +53,9 @@ 自动扫描.lrc歌词文件 曲库同步时自动扫描同目录下的lrc歌词并绑定歌曲 使用文件夹中的封面图片 - 优先使用歌曲所在文件夹中的 cover.jpg、folder.jpg 等图片作为专辑封面,而非内嵌封面。Music、Downloads、DCIM 和 Pictures 等共用文件夹会被忽略。 - 读取音乐文件夹中的封面图片需要照片访问权限。 - 正在刷新专辑封面… + 优先使用歌曲所在文件夹中的 cover.jpg、folder.jpg 等图片作为专辑封面,而非内嵌封面。Music、Downloads、DCIM 和 Pictures 等共用文件夹会被忽略。 也会在艺术家文件夹中查找 artist.jpg、band.jpg 及其 PNG 版本。手动选择的艺术家照片优先。 + 读取音乐文件夹中的专辑和艺术家图片需要完整的照片访问权限。 + 正在刷新图片… 可选在线服务 来自 LRCLIB 的在线歌词 本地无歌词时允许从 lrclib.net 搜索、手动获取歌词 diff --git a/app/src/main/res/values/strings_settings.xml b/app/src/main/res/values/strings_settings.xml index e70e1486..531e5947 100644 --- a/app/src/main/res/values/strings_settings.xml +++ b/app/src/main/res/values/strings_settings.xml @@ -61,9 +61,9 @@ Auto-scan .lrc files Automatically scan and assign .lrc files in the same folder during library sync. Use folder cover images - Use cover.jpg, folder.jpg or a similar image from a song\'s own folder as its album art, in preference to embedded artwork. Shared folders such as Music, Downloads, DCIM and Pictures are ignored. - Photo access is required to read cover images from your music folders. - Refreshing album art… + Use cover.jpg, folder.jpg or a similar image from a song\'s own folder as its album art, in preference to embedded artwork. Shared folders such as Music, Downloads, DCIM and Pictures are ignored. Artist folders are also checked for artist.jpg, band.jpg and PNG variants. Manually chosen artist photos take priority. + Full photo access is required to read album and artist images from your music folders. + Refreshing artwork… Optional online services Online lyrics from LRCLIB Allow searches and manual fetches from lrclib.net when lyrics are missing locally. diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/ArtistImageRepositoryTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/ArtistImageRepositoryTest.kt index 1d864ffd..e90b01b2 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/ArtistImageRepositoryTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/ArtistImageRepositoryTest.kt @@ -1,19 +1,27 @@ package com.lostf1sh.pixelplayeross.data.repository +import android.content.Context import com.lostf1sh.pixelplayeross.data.database.MusicDao import com.lostf1sh.pixelplayeross.data.network.deezer.DeezerApiService import com.lostf1sh.pixelplayeross.data.network.deezer.DeezerArtist import com.lostf1sh.pixelplayeross.data.network.deezer.DeezerSearchResponse import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository +import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils +import com.lostf1sh.pixelplayeross.utils.FolderArtistArtUtils import io.mockk.coEvery import io.mockk.coJustRun import io.mockk.coVerify import io.mockk.every import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import java.io.File import java.util.concurrent.atomic.AtomicInteger import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals @@ -39,7 +47,7 @@ class ArtistImageRepositoryTest { fun `cancelled prefetch does not mark artist as failed for the session`() = runTest { val deezerApiService = mockk() val musicDao = mockk() - val repository = ArtistImageRepository(deezerApiService, musicDao, userPreferencesRepository()) + val repository = ArtistImageRepository(deezerApiService, musicDao, userPreferencesRepository(), mockk()) val firstAttemptStarted = CompletableDeferred() val searchAttempts = AtomicInteger(0) val rawUrl = "https://cdn-images.dzcdn.net/images/artist/250x250-000000-80-0-0.jpg" @@ -89,7 +97,8 @@ class ArtistImageRepositoryTest { val repository = ArtistImageRepository( deezerApiService, musicDao, - userPreferencesRepository(externalArtistImagesEnabled = false) + userPreferencesRepository(externalArtistImagesEnabled = false), + mockk() ) val imageUrl = repository.getArtistImageUrl("Artist Name", 42L) @@ -98,9 +107,121 @@ class ArtistImageRepositoryTest { coVerify(exactly = 0) { deezerApiService.searchArtist(any(), any()) } } - private fun userPreferencesRepository(externalArtistImagesEnabled: Boolean = true): UserPreferencesRepository { + @Test + fun `folder portrait is preferred even with third party artist images disabled`() = runTest { + val deezerApiService = mockk() + val musicDao = mockk() + val context = mockk() + val image = File("/Music/Artist Name/artist.jpg") + val directories = listOf("/Music/Artist Name/Album") + val repository = ArtistImageRepository( + deezerApiService, + musicDao, + userPreferencesRepository(externalArtistImagesEnabled = false, folderArtworkEnabled = true), + context + ) + coEvery { musicDao.getArtistIdByNormalizedName("Artist Name") } returns 42L + coEvery { musicDao.getLocalArtistDirectories(42L, 64) } returns directories + mockkObject(AlbumArtUtils, FolderArtistArtUtils) + try { + every { AlbumArtUtils.canReadImageFiles(context) } returns true + every { FolderArtistArtUtils.findArtistImage(directories, "Artist Name", any()) } returns image + + assertEquals(image.absolutePath, repository.getArtistImageUrl("Artist Name", 42L)) + + coVerify(exactly = 0) { deezerApiService.searchArtist(any(), any()) } + coVerify(exactly = 0) { musicDao.updateArtistImageUrl(any(), any()) } + } finally { + unmockkObject(AlbumArtUtils, FolderArtistArtUtils) + } + } + + @Test + fun `manual artist image retains priority over opted in folder image`() = runTest { + val deezerApiService = mockk() + val musicDao = mockk() + val preferences = userPreferencesRepository(folderArtworkEnabled = true) + val repository = ArtistImageRepository(deezerApiService, musicDao, preferences, mockk()) + coEvery { musicDao.getArtistCustomImage(42L) } returns "/internal/custom_artist.jpg" + + assertEquals( + "/internal/custom_artist.jpg", + repository.getEffectiveArtistImageUrl(42L, "Artist Name") + ) + coVerify(exactly = 0) { musicDao.getLocalArtistDirectories(any(), any()) } + verify(exactly = 0) { preferences.useFolderAlbumArtFlow } + coVerify(exactly = 0) { deezerApiService.searchArtist(any(), any()) } + } + + @Test + fun `disabling folder artwork restores persisted third party image without replacing it`() = runTest { + val deezerApiService = mockk() + val musicDao = mockk() + val context = mockk() + val enabled = MutableStateFlow(true) + val preferences = userPreferencesRepository() + every { preferences.useFolderAlbumArtFlow } returns enabled + val repository = ArtistImageRepository(deezerApiService, musicDao, preferences, context) + val directories = listOf("/Music/Artist Name/Album") + val image = File("/Music/Artist Name/artist.jpg") + val remoteImage = "https://example.com/artist.jpg" + coEvery { musicDao.getLocalArtistDirectories(42L, 64) } returns directories + coEvery { musicDao.getArtistIdByNormalizedName("Artist Name") } returns 42L + coEvery { musicDao.getArtistImageUrl(42L) } returns remoteImage + mockkObject(AlbumArtUtils, FolderArtistArtUtils) + try { + every { AlbumArtUtils.canReadImageFiles(context) } returns true + every { FolderArtistArtUtils.findArtistImage(directories, "Artist Name", any()) } returns image + + assertEquals(image.absolutePath, repository.getArtistImageUrl("Artist Name", 42L)) + enabled.value = false + assertEquals(remoteImage, repository.getArtistImageUrl("Artist Name", 42L)) + + coVerify(exactly = 0) { deezerApiService.searchArtist(any(), any()) } + coVerify(exactly = 0) { musicDao.updateArtistImageUrl(any(), any()) } + } finally { + unmockkObject(AlbumArtUtils, FolderArtistArtUtils) + } + } + + @Test + fun `folder portrait lookup skips disk discovery without image permission`() = runTest { + val musicDao = mockk() + val context = mockk() + val repository = ArtistImageRepository( + mockk(), + musicDao, + userPreferencesRepository(folderArtworkEnabled = true), + context + ) + mockkObject(AlbumArtUtils) + try { + every { AlbumArtUtils.canReadImageFiles(context) } returns false + + assertEquals(null, repository.getFolderArtistImageUrl(42L, "Artist Name")) + + coVerify(exactly = 0) { musicDao.getLocalArtistDirectories(any(), any()) } + } finally { + unmockkObject(AlbumArtUtils) + } + } + + @Test + fun `clearing images signals folder image consumers to reload`() { + val repository = ArtistImageRepository(mockk(), mockk(), userPreferencesRepository(), mockk()) + + repository.clearCache() + + assertEquals(1L, repository.folderArtworkRevision.value) + } + + private fun userPreferencesRepository( + externalArtistImagesEnabled: Boolean = true, + folderArtworkEnabled: Boolean = false + ): UserPreferencesRepository { return mockk { every { this@mockk.externalArtistImagesEnabledFlow } returns flowOf(externalArtistImagesEnabled) + every { this@mockk.useFolderAlbumArtFlow } returns flowOf(folderArtworkEnabled) } } } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt index a3992ee7..06f74754 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/data/repository/MusicRepositoryImplTest.kt @@ -15,6 +15,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.MutableStateFlow +import app.cash.turbine.test import kotlinx.coroutines.test.* import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals @@ -44,6 +46,8 @@ class MusicRepositoryImplTest { @BeforeEach fun setUp() { Dispatchers.setMain(testDispatcher) + every { mockArtistImageRepository.folderArtworkRevision } returns MutableStateFlow(0L) + coEvery { mockArtistImageRepository.getFolderArtistImageUrl(any(), any()) } returns null coEvery { mockUserPreferencesRepository.allowedDirectoriesFlow } returns flowOf(emptySet()) coEvery { mockUserPreferencesRepository.blockedDirectoriesFlow } returns flowOf(setOf("/dummy")) coEvery { mockUserPreferencesRepository.initialSetupDoneFlow } returns flowOf(true) @@ -106,6 +110,46 @@ class MusicRepositoryImplTest { Dispatchers.resetMain() } + @Test + fun `artist lists prefer folder art and restore remote art after invalidation`() = runTest(testDispatcher) { + val revision = MutableStateFlow(0L) + every { mockArtistImageRepository.folderArtworkRevision } returns revision + every { mockMusicDao.getArtistsWithSongCountsFiltered(any(), any(), any()) } returns flowOf( + listOf(ArtistEntity(101L, "Artist", 1, imageUrl = "https://example.com/remote.jpg")) + ) + coEvery { mockArtistImageRepository.getFolderArtistImageUrl(101L, "Artist") } returns + "/Music/Artist/artist.jpg" + + musicRepository.getArtists().test { + assertEquals("/Music/Artist/artist.jpg", awaitItem().single().effectiveImageUrl) + coEvery { mockArtistImageRepository.getFolderArtistImageUrl(101L, "Artist") } returns null + revision.value++ + assertEquals("https://example.com/remote.jpg", awaitItem().single().effectiveImageUrl) + cancelAndIgnoreRemainingEvents() + } + coVerify(exactly = 0) { mockMusicDao.updateArtistImageUrl(any(), any()) } + } + + @Test + fun `artist search keeps manually chosen photos ahead of folder art`() = runTest(testDispatcher) { + every { mockMusicDao.searchArtists("Artist", any(), any()) } returns flowOf( + listOf(ArtistEntity(101L, "Artist", 1, imageUrl = "https://example.com/remote.jpg", customImageUri = "/custom.jpg")) + ) + assertEquals("/custom.jpg", musicRepository.searchArtists("Artist").first().single().effectiveImageUrl) + coVerify(exactly = 0) { mockArtistImageRepository.getFolderArtistImageUrl(any(), any()) } + } + + @Test + fun `artist detail and song artists resolve folder images`() = runTest(testDispatcher) { + val entity = ArtistEntity(101L, "Artist", 1, imageUrl = "https://example.com/remote.jpg") + every { mockMusicDao.getArtistById(101L) } returns flowOf(entity) + every { mockMusicDao.getArtistsForSong(5L) } returns flowOf(listOf(entity)) + coEvery { mockArtistImageRepository.getFolderArtistImageUrl(101L, "Artist") } returns "/Music/Artist/band.png" + + assertEquals("/Music/Artist/band.png", musicRepository.getArtistById(101L).first()?.effectiveImageUrl) + assertEquals("/Music/Artist/band.png", musicRepository.getArtistsForSong(5L).first().single().effectiveImageUrl) + } + @Test fun `getAudioFiles returns songs from DAO, filtered by allowed directories`() = runTest(testDispatcher) { val songEntities = listOf( diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/LocalArtworkImageModelTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/LocalArtworkImageModelTest.kt new file mode 100644 index 00000000..4ec909dc --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/components/LocalArtworkImageModelTest.kt @@ -0,0 +1,69 @@ +package com.lostf1sh.pixelplayeross.presentation.components + +import android.content.Context +import coil.memory.MemoryCache +import coil.request.CachePolicy +import coil.request.ImageRequest +import com.google.common.truth.Truth.assertThat +import com.lostf1sh.pixelplayeross.utils.LocalArtworkUri +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import org.junit.jupiter.api.Test + +class LocalArtworkImageModelTest { + @Test + fun `cache revisions change local request data while preserving song and edit token`() { + val uri = "${LocalArtworkUri.buildSongUri(42L)}?t=123" + + val before = withLocalArtworkCacheVersion(uri, 1L) as String + val after = withLocalArtworkCacheVersion(uri, 2L) as String + + assertThat(after).isNotEqualTo(before) + assertThat(LocalArtworkUri.parseSongId(after)).isEqualTo(42L) + assertThat(LocalArtworkUri.extractCacheBustToken(after)).isEqualTo("123") + assertThat(after).contains("artwork_version=2") + } + + @Test + fun `cloud custom and direct image models retain their identities`() { + listOf( + "https://music.example/cover.jpg", + "content://custom.provider/image/42", + "/storage/emulated/0/Pictures/custom.jpg", + Any(), + null + ).forEach { model -> + assertThat(withLocalArtworkCacheVersion(model, 2L)).isSameInstanceAs(model) + } + val request = mockk() + every { request.data } returns "https://music.example/cover.jpg" + assertThat(withLocalArtworkCacheVersion(request, 2L)).isSameInstanceAs(request) + } + + @Test + fun `local requests change custom cache keys and discard stale placeholders`() { + val uri = LocalArtworkUri.buildSongUri(42L) + val context = mockk() + val request = mockk() + val builder = mockk() + val updated = mockk() + val originalKey = MemoryCache.Key("custom-album-art", mapOf("size" to "128")) + every { request.data } returns uri + every { request.context } returns context + every { request.memoryCacheKey } returns originalKey + every { request.newBuilder(context) } returns builder + every { builder.data(any()) } returns builder + every { builder.memoryCacheKey(any()) } returns builder + every { builder.placeholderMemoryCacheKey(null as MemoryCache.Key?) } returns builder + every { builder.diskCachePolicy(CachePolicy.DISABLED) } returns builder + every { builder.build() } returns updated + + assertThat(withLocalArtworkCacheVersion(request, 3L)).isSameInstanceAs(updated) + verify { builder.data("$uri?artwork_version=3") } + verify { + builder.memoryCacheKey(MemoryCache.Key("custom-album-art_artwork_version_3", originalKey.extras)) + } + verify { builder.placeholderMemoryCacheKey(null as MemoryCache.Key?) } + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolderArtworkTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolderArtworkTest.kt new file mode 100644 index 00000000..1ec66bbb --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/presentation/viewmodel/ThemeStateHolderArtworkTest.kt @@ -0,0 +1,129 @@ +package com.lostf1sh.pixelplayeross.presentation.viewmodel + +import android.net.Uri +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import com.lostf1sh.pixelplayeross.data.preferences.AlbumArtColorAccuracy +import com.lostf1sh.pixelplayeross.data.preferences.AlbumArtPaletteStyle +import com.lostf1sh.pixelplayeross.data.preferences.ThemePreference +import com.lostf1sh.pixelplayeross.data.preferences.ThemePreferencesRepository +import com.lostf1sh.pixelplayeross.utils.AlbumArtUtils +import com.lostf1sh.pixelplayeross.utils.LocalArtworkUri +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class ThemeStateHolderArtworkTest { + private val artworkVersion = MutableStateFlow(0L) + private val processor = mockk() + private val oldScheme = scheme(Color.Red) + private val newScheme = scheme(Color.Blue) + + @BeforeEach + fun setUpArtworkVersion() { + mockkObject(AlbumArtUtils) + every { AlbumArtUtils.artworkCacheVersion } returns artworkVersion + coEvery { processor.getOrGenerateColorScheme(any(), any(), any(), any()) } answers { + if (artworkVersion.value == 0L) oldScheme else newScheme + } + } + + @AfterEach + fun restoreArtworkUtils() { + unmockkObject(AlbumArtUtils) + } + + @Test + fun `artwork refresh regenerates subscribed local palettes and leaves remote palettes intact`() = runTest { + val holder = holder() + holder.initialize(backgroundScope) + val localUri = LocalArtworkUri.buildSongUri(1L) + val inactiveUri = LocalArtworkUri.buildSongUri(2L) + val remoteUri = "https://example.com/cover.jpg" + val local = holder.getAlbumColorSchemeFlow(localUri, eager = false) + val inactive = holder.getAlbumColorSchemeFlow(inactiveUri, eager = false) + val remote = holder.getAlbumColorSchemeFlow(remoteUri, eager = false) + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { local.collect {} } + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { remote.collect {} } + listOf(localUri, inactiveUri, remoteUri).forEach(holder::ensureAlbumColorScheme) + local.first { it == oldScheme } + inactive.first { it == oldScheme } + remote.first { it == oldScheme } + runCurrent() + + artworkVersion.value = 1L + local.first { it == newScheme } + + assertNull(inactive.value) + assertEquals(oldScheme, remote.value) + coVerify(exactly = 2) { processor.getOrGenerateColorScheme(localUri, any(), any(), any()) } + coVerify(exactly = 1) { processor.getOrGenerateColorScheme(inactiveUri, any(), any(), any()) } + coVerify(exactly = 1) { processor.getOrGenerateColorScheme(remoteUri, any(), any(), any()) } + } + + @Test + fun `artwork refresh updates current playing local palette without an album subscriber`() = runTest { + val holder = holder() + holder.initialize(backgroundScope) + val localUri = LocalArtworkUri.buildSongUri(1L) + val uri = mockk() + every { uri.toString() } returns localUri + holder.extractAndGenerateColorScheme(uri, localUri) + runCurrent() + assertEquals(oldScheme, holder.currentAlbumArtColorSchemePair.value) + + artworkVersion.value = 1L + holder.currentAlbumArtColorSchemePair.first { it == newScheme } + + assertEquals(localUri, holder.currentAlbumArtUri.value) + coVerify(exactly = 2) { processor.getOrGenerateColorScheme(localUri, any(), any(), any()) } + } + + @Test + fun `cancelled palette generation propagates cancellation`() = runTest { + val holder = holder() + val localUri = LocalArtworkUri.buildSongUri(1L) + val uri = mockk() + every { uri.toString() } returns localUri + coEvery { processor.getOrGenerateColorScheme(any(), any(), any(), any()) } throws CancellationException() + + val failure = runCatching { holder.extractAndGenerateColorScheme(uri, localUri) }.exceptionOrNull() + + assertTrue(failure is CancellationException) + } + + private fun holder(): ThemeStateHolder { + val preferences = mockk { + every { playerThemePreferenceFlow } returns flowOf(ThemePreference.ALBUM_ART) + every { globalNowPlayingThemeEnabledFlow } returns flowOf(false) + every { albumArtPaletteStyleFlow } returns flowOf(AlbumArtPaletteStyle.default) + every { albumArtColorAccuracyFlow } returns flowOf(AlbumArtColorAccuracy.DEFAULT) + } + return ThemeStateHolder(processor, preferences) + } + + private fun scheme(color: Color): ColorSchemePair = ColorSchemePair( + light = lightColorScheme(primary = color), + dark = darkColorScheme(primary = color) + ) +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtilsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtilsTest.kt index b0245251..1fb3dc91 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtilsTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/AlbumArtUtilsTest.kt @@ -1,11 +1,73 @@ package com.lostf1sh.pixelplayeross.utils +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.media.MediaMetadataRetriever +import androidx.core.content.ContextCompat import com.google.common.truth.Truth.assertThat +import com.lostf1sh.pixelplayeross.data.media.AudioMetadataReader +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import io.mockk.verify +import java.io.ByteArrayInputStream import kotlin.io.path.createTempDirectory +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test class AlbumArtUtilsTest { + @BeforeEach + fun setUp() { + // Android's decoder is stubbed on the JVM. A leading 7 represents a decoded image; + // a leading 8 represents a truncated image with readable bounds but no bitmap. + mockkStatic(BitmapFactory::class, ContextCompat::class) + every { ContextCompat.checkSelfPermission(any(), any()) } returns PackageManager.PERMISSION_GRANTED + every { BitmapFactory.decodeByteArray(any(), any(), any(), any()) } answers { + val bytes = firstArg() + val options = arg(3) + val header = bytes.firstOrNull()?.toInt() + options.outWidth = if (header == 7 || header == 8) 128 else -1 + options.outHeight = options.outWidth + if (!options.inJustDecodeBounds && header == 7) mockk(relaxed = true) else null + } + } + + @AfterEach + fun tearDown() { + unmockkStatic(BitmapFactory::class, ContextCompat::class) + unmockkObject(MediaMetadataRetrieverPool, AudioMetadataReader) + } + + @Test + fun canReadImageFiles_checksLegacyStoragePermissionBeforeAndroid13() { + val context = mockk() + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) + } returns PackageManager.PERMISSION_DENIED + + assertThat(AlbumArtUtils.canReadImageFiles(context, sdkInt = 32)).isFalse() + verify { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_EXTERNAL_STORAGE) } + } + + @Test + fun canReadImageFiles_checksImagePermissionFromAndroid13() { + val context = mockk() + every { + ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) + } returns PackageManager.PERMISSION_DENIED + + assertThat(AlbumArtUtils.canReadImageFiles(context, sdkInt = 33)).isFalse() + verify { ContextCompat.checkSelfPermission(context, Manifest.permission.READ_MEDIA_IMAGES) } + } + @Test fun findExternalAlbumArtFile_returnsExplicitCoverFromDedicatedAlbumFolder() { val root = createTempDirectory("album-art-test").toFile() @@ -114,4 +176,150 @@ class AlbumArtUtilsTest { assertThat(resolved).isNull() root.deleteRecursively() } + + @Test + fun readExternalAlbumArtBytes_skipsCorruptCoverAndUsesNextImage() { + val root = createTempDirectory("album-art-test").toFile() + try { + val songFile = root.resolve("Song.mp3").apply { writeBytes(byteArrayOf(1)) } + root.resolve("cover.jpg").writeBytes(ByteArray(2048) { 0 }) + val validBytes = ByteArray(2048) { 7 } + root.resolve("folder.png").writeBytes(validBytes) + + assertThat(AlbumArtUtils.readExternalAlbumArtBytes(songFile.path, true)).isEqualTo(validBytes) + } finally { + root.deleteRecursively() + } + } + + @Test + fun readExternalAlbumArtBytes_rejectsTruncatedImagesEvenWhenBoundsAreReadable() { + val root = createTempDirectory("album-art-test").toFile() + try { + val songFile = root.resolve("Song.mp3").apply { writeBytes(byteArrayOf(1)) } + root.resolve("cover.jpg").writeBytes(ByteArray(2048) { 8 }) + + assertThat(AlbumArtUtils.readExternalAlbumArtBytes(songFile.path, true)).isNull() + } finally { + root.deleteRecursively() + } + } + + @Test + fun readExternalAlbumArtBytes_skipsOversizedCoverAndUsesNextImage() { + val root = createTempDirectory("album-art-test").toFile() + try { + val songFile = root.resolve("Song.mp3").apply { writeBytes(byteArrayOf(1)) } + root.resolve("cover.jpg").writeBytes(ByteArray(4096) { 7 }) + val validBytes = ByteArray(2048) { 7 } + root.resolve("cover.png").writeBytes(validBytes) + + assertThat(AlbumArtUtils.readExternalAlbumArtBytes(songFile.path, true, 2048L)) + .isEqualTo(validBytes) + } finally { + root.deleteRecursively() + } + } + + @Test + fun readExternalAlbumArtBytes_acceptsSmallImagesAndCaseInsensitiveNames() { + val root = createTempDirectory("album-art-test").toFile() + try { + val songFile = root.resolve("Song.mp3").apply { writeBytes(byteArrayOf(1)) } + val validBytes = ByteArray(128) { 7 } + root.resolve("Cover.JPG").writeBytes(validBytes) + + assertThat(AlbumArtUtils.readExternalAlbumArtBytes(songFile.path, true)).isEqualTo(validBytes) + } finally { + root.deleteRecursively() + } + } + + @Test + fun readArtworkBytesWithinLimit_stopsWhenStreamExceedsLimit() { + val input = ByteArrayInputStream(ByteArray(4096)) + + assertThat(AlbumArtUtils.readArtworkBytesWithinLimit(input, 2048L)).isNull() + assertThat(input.available()).isEqualTo(2047) + } + + @Test + fun readArtworkBytesWithinLimit_acceptsExactLimit() { + val bytes = ByteArray(2048) { 7 } + + assertThat(AlbumArtUtils.readArtworkBytesWithinLimit(bytes.inputStream(), 2048L)) + .isEqualTo(bytes) + } + + @Test + fun getAlbumArtUri_fallsBackToEmbeddedArtWhenFolderCoverIsCorrupt() { + val root = createTempDirectory("album-art-test").toFile() + try { + val context = mockk() + every { context.filesDir } returns root + val songFile = root.resolve("Song.mp3").apply { writeBytes(byteArrayOf(1)) } + root.resolve("cover.jpg").writeBytes(ByteArray(2048) { 0 }) + val retriever = mockk(relaxed = true) + mockkObject(MediaMetadataRetrieverPool) + every { MediaMetadataRetrieverPool.acquire() } returns retriever + every { MediaMetadataRetrieverPool.release(any()) } returns Unit + every { retriever.embeddedPicture } returns ByteArray(2048) { 7 } + AlbumArtUtils.setFolderAlbumArtPreference(true) + + assertThat(AlbumArtUtils.getAlbumArtUri(context, songFile.path, 123L, false)).isNotNull() + verify(exactly = 1) { retriever.embeddedPicture } + } finally { + AlbumArtUtils.setFolderAlbumArtPreference(false) + root.deleteRecursively() + } + } + + @Test + fun ensureAlbumArtCachedFile_doesNotRestoreArtAfterSettingsInvalidateAnInFlightRead() { + val root = createTempDirectory("album-art-test").toFile() + try { + val context = mockk() + every { context.filesDir } returns root + val songFile = root.resolve("Song.mp3").apply { writeBytes(byteArrayOf(1)) } + val retriever = mockk(relaxed = true) + mockkObject(MediaMetadataRetrieverPool) + every { MediaMetadataRetrieverPool.acquire() } returns retriever + every { MediaMetadataRetrieverPool.release(any()) } returns Unit + every { retriever.embeddedPicture } answers { + AlbumArtUtils.bumpArtworkCacheGeneration() + ByteArray(2048) { 7 } + } + AlbumArtUtils.setFolderAlbumArtPreference(false) + + assertThat(AlbumArtUtils.ensureAlbumArtCachedFile(context, 123L, songFile.path)).isNull() + assertThat(AlbumArtUtils.getAlbumArtDir(context).listFiles()).isEmpty() + } finally { + root.deleteRecursively() + } + } + + @Test + fun ensureAlbumArtCachedFile_doesNotRestoreMissAfterSettingsInvalidateAnInFlightRead() { + val root = createTempDirectory("album-art-test").toFile() + try { + val context = mockk() + every { context.filesDir } returns root + val songFile = root.resolve("Song.mp3").apply { writeBytes(byteArrayOf(1)) } + val retriever = mockk(relaxed = true) + mockkObject(MediaMetadataRetrieverPool, AudioMetadataReader) + every { MediaMetadataRetrieverPool.acquire() } returns retriever + every { MediaMetadataRetrieverPool.release(any()) } returns Unit + every { retriever.embeddedPicture } returns null + every { AudioMetadataReader.read(songFile) } answers { + AlbumArtUtils.bumpArtworkCacheGeneration() + null + } + AlbumArtUtils.setFolderAlbumArtPreference(false) + + assertThat(AlbumArtUtils.ensureAlbumArtCachedFile(context, 123L, songFile.path)).isNull() + assertThat(AlbumArtUtils.getAlbumArtDir(context).listFiles()).isEmpty() + } finally { + root.deleteRecursively() + } + } } diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt index 30b61d09..95706393 100644 --- a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderAlbumArtUpdateTest.kt @@ -11,11 +11,11 @@ import org.junit.jupiter.api.Test class FolderAlbumArtUpdateTest { @Test - fun firstRecordedState_onlyStoresIt() { + fun missingRecordedState_invalidatesPreexistingArtwork() { assertThat(resolveFolderAlbumArtUpdate(previous = null, observed = true)) - .isEqualTo(FolderAlbumArtUpdate.MIRROR_ONLY) + .isEqualTo(FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) assertThat(resolveFolderAlbumArtUpdate(previous = null, observed = false)) - .isEqualTo(FolderAlbumArtUpdate.MIRROR_ONLY) + .isEqualTo(FolderAlbumArtUpdate.MIRROR_AND_INVALIDATE) } @Test diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtistArtUtilsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtistArtUtilsTest.kt new file mode 100644 index 00000000..216d30e9 --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtistArtUtilsTest.kt @@ -0,0 +1,120 @@ +package com.lostf1sh.pixelplayeross.utils + +import java.io.File +import java.nio.file.Files +import java.nio.file.Path +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir + +class FolderArtistArtUtilsTest { + @TempDir + lateinit var root: Path + + @Test + fun `finds artist portrait in parent of album folder with case insensitive name`() { + val album = directory("Music/Daft Punk/Discovery") + val image = album.parentFile!!.resolve("ArTiSt.PnG").apply { writeText("image") } + + assertEquals(image.canonicalFile, findImage(album)) + } + + @Test + fun `accepts band portrait beside tracks`() { + val artist = directory("Music/Daft Punk") + val image = artist.resolve("band.jpg").apply { writeText("image") } + + assertEquals(image.canonicalFile, findImage(artist)) + } + + @Test + fun `artist directory portrait wins over album directory portrait`() { + val album = directory("Music/Daft Punk/Discovery") + val image = album.parentFile!!.resolve("artist.jpg").apply { writeText("image") } + album.resolve("artist.png").writeText("image") + + assertEquals(image.canonicalFile, findImage(album)) + } + + @Test + fun `skips invalid candidate and tries remaining explicit portrait names`() { + val album = directory("Music/Daft Punk/Discovery") + album.parentFile!!.resolve("artist.jpg").writeText("invalid") + val image = album.parentFile!!.resolve("band.png").apply { writeText("image") } + + assertEquals(image.canonicalFile, findImage(album)) + } + + @Test + fun `does not use album covers as artist portraits`() { + val album = directory("Music/Daft Punk/Discovery") + album.resolve("cover.jpg").writeText("image") + album.parentFile!!.resolve("folder.jpg").writeText("image") + + assertNull(findImage(album)) + } + + @Test + fun `ignores shared music and downloads directory portraits`() { + val album = directory("Music/Discovery") + album.parentFile!!.resolve("artist.jpg").writeText("image") + val downloads = directory("Downloads") + downloads.resolve("band.png").writeText("image") + + assertNull(findImage(album, downloads)) + } + + @Test + fun `does not use an unrelated grandparent portrait`() { + val album = directory("Collection/Artist/Album") + root.resolve("Collection/artist.jpg").toFile().writeText("image") + + assertNull(findImage(album, artistName = "Artist")) + } + + @Test + fun `finds named artist directory above a disc folder`() { + val disc = directory("Music/Daft Punk/Discovery/Disc 1") + val image = root.resolve("Music/Daft Punk/band.png").toFile().apply { writeText("image") } + + assertEquals(image.canonicalFile, findImage(disc)) + } + + @Test + fun `does not inherit another artist portrait for a collaborator`() { + val album = directory("Music/Daft Punk/Discovery") + album.parentFile!!.resolve("artist.jpg").writeText("image") + + assertNull(findImage(album, artistName = "Guest Singer")) + } + + @Test + fun `rejects portrait symlinks escaping the inspected directory`() { + val album = directory("Music/Artist/Album") + val outside = root.resolve("private.jpg").toFile().apply { writeText("image") } + Files.createSymbolicLink(album.parentFile!!.resolve("artist.jpg").toPath(), outside.toPath()) + + assertNull(findImage(album, artistName = "Artist")) + } + + @Test + fun `considers multiple album directories and ignores missing directories`() { + val first = directory("Music/Artist/First Album") + val second = directory("Other Music/Artist/Second Album") + val image = second.parentFile!!.resolve("artist.png").apply { writeText("image") } + + assertEquals( + image.canonicalFile, + findImage(root.resolve("missing").toFile(), first, second, artistName = "Artist") + ) + } + + private fun directory(path: String): File = root.resolve(path).toFile().apply { mkdirs() } + + private fun findImage(vararg directories: File, artistName: String = "Daft Punk"): File? = FolderArtistArtUtils.findArtistImage( + audioDirectoryPaths = directories.map { it.absolutePath }, + artistName = artistName, + isUsableArtwork = { it.readText() == "image" } + ) +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkPermissionsTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkPermissionsTest.kt new file mode 100644 index 00000000..13338b6a --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkPermissionsTest.kt @@ -0,0 +1,25 @@ +package com.lostf1sh.pixelplayeross.utils + +import android.Manifest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class FolderArtworkPermissionsTest { + @Test + fun `Android 14 requests selected photo permission with full access to avoid compatibility mode`() { + assertEquals( + listOf(Manifest.permission.READ_MEDIA_IMAGES, Manifest.permission.READ_MEDIA_VISUAL_USER_SELECTED), + folderArtworkPermissions(34) + ) + } + + @Test + fun `Android 13 only requests image access`() { + assertEquals(listOf(Manifest.permission.READ_MEDIA_IMAGES), folderArtworkPermissions(33)) + } + + @Test + fun `Android 12 requests storage access again if the setup grant was revoked`() { + assertEquals(listOf(Manifest.permission.READ_EXTERNAL_STORAGE), folderArtworkPermissions(32)) + } +} diff --git a/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkSettingsCoordinatorTest.kt b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkSettingsCoordinatorTest.kt new file mode 100644 index 00000000..5060f3bd --- /dev/null +++ b/app/src/test/java/com/lostf1sh/pixelplayeross/utils/FolderArtworkSettingsCoordinatorTest.kt @@ -0,0 +1,203 @@ +package com.lostf1sh.pixelplayeross.utils + +import android.content.Context +import com.google.common.truth.Truth.assertThat +import com.lostf1sh.pixelplayeross.data.media.ImageCacheManager +import com.lostf1sh.pixelplayeross.data.preferences.UserPreferencesRepository +import com.lostf1sh.pixelplayeross.data.repository.ArtistImageRepository +import com.lostf1sh.pixelplayeross.data.worker.SyncManager +import com.lostf1sh.pixelplayeross.presentation.viewmodel.ColorSchemeProcessor +import dagger.Lazy +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.coVerifyOrder +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import java.io.IOException + +class FolderArtworkSettingsCoordinatorTest { + private val context = mockk() + private val preferences = mockk() + private val imageCacheManager = mockk(relaxed = true) + private val artistImageRepository = mockk(relaxed = true) + private val syncManager = mockk(relaxed = true) + private val colorSchemeProcessor = mockk(relaxed = true) + private val enabled = MutableStateFlow(false) + private val recorded = MutableStateFlow(false) + private val setupDone = MutableStateFlow(true) + private var mirrored = false + private var hasImagePermission = true + private lateinit var coordinator: FolderArtworkSettingsCoordinator + + @BeforeEach + fun setUp() { + mockkObject(AlbumArtUtils, AlbumArtCacheManager) + every { preferences.useFolderAlbumArtFlow } returns enabled + every { preferences.folderAlbumArtCacheStateFlow } returns recorded + every { preferences.initialSetupDoneFlow } returns setupDone + coEvery { preferences.setUseFolderAlbumArt(any()) } answers { + enabled.value = firstArg() + } + coEvery { preferences.setFolderAlbumArtCacheState(any()) } answers { + recorded.value = firstArg() + } + every { AlbumArtUtils.migrateLegacyCacheLocation(context) } just Runs + every { AlbumArtUtils.setFolderAlbumArtPreference(any()) } answers { + mirrored = firstArg() + } + every { AlbumArtUtils.isFolderAlbumArtEnabled(context) } answers { + mirrored && hasImagePermission + } + every { AlbumArtUtils.bumpArtworkCacheGeneration() } just Runs + every { AlbumArtUtils.notifyArtworkCacheInvalidated() } just Runs + coEvery { AlbumArtCacheManager.clearAllCache(context) } returns 0 + coordinator = FolderArtworkSettingsCoordinator( + context, preferences, imageCacheManager, artistImageRepository, syncManager, + Lazy { colorSchemeProcessor } + ) + } + + @AfterEach + fun tearDown() { + unmockkObject(AlbumArtUtils, AlbumArtCacheManager) + } + + @Test + fun `setting and observer reconcile share one invalidation`() = runTest { + val clearStarted = CompletableDeferred() + val finishClear = CompletableDeferred() + coEvery { AlbumArtCacheManager.clearAllCache(context) } coAnswers { + clearStarted.complete(Unit) + finishClear.await() + 0 + } + + val setting = async { coordinator.setEnabled(true) } + clearStarted.await() + val observer = async { coordinator.reconcile() } + finishClear.complete(Unit) + setting.await() + observer.await() + + assertThat(recorded.value).isTrue() + assertThat(mirrored).isTrue() + coVerify(exactly = 1) { AlbumArtCacheManager.clearAllCache(context) } + verify(exactly = 1) { imageCacheManager.clearAllCoverArtCaches() } + verify(exactly = 1) { artistImageRepository.clearCache() } + verify(exactly = 1) { syncManager.fullSync() } + } + + @Test + fun `mounted artwork reloads only after file image and palette caches are cleared`() = runTest { + enabled.value = true + + coordinator.reconcile() + + coVerifyOrder { + AlbumArtCacheManager.clearAllCache(context) + imageCacheManager.clearAllCoverArtCaches() + colorSchemeProcessor.invalidateLocalArtworkSchemes() + artistImageRepository.clearCache() + AlbumArtUtils.notifyArtworkCacheInvalidated() + } + } + + @Test + fun `second toggle waits for the first cache update before recording its own state`() = runTest { + val clearStarted = CompletableDeferred() + val finishClear = CompletableDeferred() + coEvery { AlbumArtCacheManager.clearAllCache(context) } coAnswers { + clearStarted.complete(Unit) + finishClear.await() + 0 + } + + val firstToggle = async { coordinator.setEnabled(true) } + clearStarted.await() + val secondToggle = async { coordinator.setEnabled(false) } + val foreground = async { coordinator.reconcile() } + finishClear.complete(Unit) + firstToggle.await() + secondToggle.await() + foreground.await() + + assertThat(enabled.value).isFalse() + assertThat(mirrored).isFalse() + assertThat(recorded.value).isFalse() + coVerify(exactly = 2) { AlbumArtCacheManager.clearAllCache(context) } + coVerify(exactly = 1) { preferences.setFolderAlbumArtCacheState(true) } + coVerify(exactly = 1) { preferences.setFolderAlbumArtCacheState(false) } + } + + @Test + fun `permission regrant rescans songs with missing stored artwork without changing opt in`() = runTest { + enabled.value = true + hasImagePermission = false + coordinator.reconcile() + coVerify(exactly = 0) { AlbumArtCacheManager.clearAllCache(context) } + + hasImagePermission = true + coordinator.reconcile() + + assertThat(recorded.value).isTrue() + coVerify(exactly = 1) { AlbumArtCacheManager.clearAllCache(context) } + coVerify(exactly = 0) { preferences.setUseFolderAlbumArt(any()) } + verify(exactly = 1) { syncManager.fullSync() } + } + + @Test + fun `first launch clears unknown old cache without starting a scan before setup`() = runTest { + recorded.value = null + setupDone.value = false + + coordinator.reconcile() + + assertThat(recorded.value).isFalse() + coVerify(exactly = 1) { AlbumArtCacheManager.clearAllCache(context) } + verify(exactly = 0) { syncManager.fullSync(any()) } + } + + @Test + fun `failed invalidation leaves marker unchanged and next reconciliation retries`() = runTest { + enabled.value = true + coEvery { AlbumArtCacheManager.clearAllCache(context) } throws IOException("Cache unavailable") + + coordinator.reconcile() + + assertThat(recorded.value).isFalse() + coVerify(exactly = 0) { preferences.setFolderAlbumArtCacheState(any()) } + verify(exactly = 0) { AlbumArtUtils.notifyArtworkCacheInvalidated() } + + coEvery { AlbumArtCacheManager.clearAllCache(context) } returns 0 + coordinator.reconcile() + + assertThat(recorded.value).isTrue() + verify(exactly = 1) { syncManager.fullSync() } + } + + @Test + fun `failed marker read is not treated as an unknown marker and overwritten`() = runTest { + enabled.value = true + every { preferences.folderAlbumArtCacheStateFlow } returns flow { + throw IOException("Preferences unavailable") + } + + coordinator.reconcile() + + coVerify(exactly = 0) { preferences.setFolderAlbumArtCacheState(any()) } + coVerify(exactly = 0) { AlbumArtCacheManager.clearAllCache(context) } + } +}